feat: tiered API plans (Free/Bronze/Gold/Platinum)
- Free: $0, 30 req/min, market data only - Bronze: $29/mo, 100 req/min, + companies & search - Gold: $99/mo, 500 req/min, + filings & historical - Platinum: $299/mo, 2000 req/min, all features + priority - Plan-aware rate limiting per API key (or per IP for free) - Usage tracking with daily aggregation - GET /api/v1/plans — plan listing - POST /api/v1/plans/register — instant free API key - GET /api/v1/plans/usage — usage stats - /pricing — dark-themed HTML pricing page - X-RateLimit-* and X-Plan headers on every response - Restricted endpoints return upgrade prompt - Updated OpenAPI spec with security scheme - 53 handlers, compiles clean
This commit is contained in:
198
internal/db/apikeys.go
Normal file
198
internal/db/apikeys.go
Normal file
@@ -0,0 +1,198 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type APIKey struct {
|
||||
ID int64 `json:"id"`
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Plan string `json:"plan"`
|
||||
RateLimit int `json:"rate_limit"`
|
||||
RequestsToday int `json:"requests_today"`
|
||||
RequestsMonth int `json:"requests_month"`
|
||||
LastRequestAt *time.Time `json:"last_request_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
Active bool `json:"active"`
|
||||
}
|
||||
|
||||
type UsageStats struct {
|
||||
TotalRequests int `json:"total_requests"`
|
||||
DailyBreakdown []DailyUsage `json:"daily_breakdown"`
|
||||
TopEndpoints []EndpointStat `json:"top_endpoints"`
|
||||
}
|
||||
|
||||
type DailyUsage struct {
|
||||
Date string `json:"date"`
|
||||
Requests int `json:"requests"`
|
||||
}
|
||||
|
||||
type EndpointStat struct {
|
||||
Endpoint string `json:"endpoint"`
|
||||
Requests int `json:"requests"`
|
||||
AvgLatencyMs float64 `json:"avg_latency_ms"`
|
||||
}
|
||||
|
||||
func planRateLimit(plan string) int {
|
||||
switch plan {
|
||||
case "bronze":
|
||||
return 100
|
||||
case "gold":
|
||||
return 500
|
||||
case "platinum":
|
||||
return 2000
|
||||
default:
|
||||
return 30
|
||||
}
|
||||
}
|
||||
|
||||
func (d *DB) CreateAPIKey(name, email, plan string) (*APIKey, error) {
|
||||
key := uuid.New().String()
|
||||
rl := planRateLimit(plan)
|
||||
res, err := d.Conn.Exec(
|
||||
`INSERT INTO api_keys (key, name, email, plan, rate_limit) VALUES (?, ?, ?, ?, ?)`,
|
||||
key, name, email, plan, rl,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create api key: %w", err)
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
return &APIKey{
|
||||
ID: id, Key: key, Name: name, Email: email,
|
||||
Plan: plan, RateLimit: rl, Active: true, CreatedAt: time.Now(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (d *DB) CreateAPIKeyWithValue(key, name, email, plan string) (*APIKey, error) {
|
||||
rl := planRateLimit(plan)
|
||||
res, err := d.Conn.Exec(
|
||||
`INSERT OR IGNORE INTO api_keys (key, name, email, plan, rate_limit) VALUES (?, ?, ?, ?, ?)`,
|
||||
key, name, email, plan, rl,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create api key: %w", err)
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
return &APIKey{
|
||||
ID: id, Key: key, Name: name, Email: email,
|
||||
Plan: plan, RateLimit: rl, Active: true, CreatedAt: time.Now(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (d *DB) GetAPIKey(key string) (*APIKey, error) {
|
||||
row := d.Conn.QueryRow(
|
||||
`SELECT id, key, name, email, plan, rate_limit, requests_today, requests_month, last_request_at, created_at, expires_at, active
|
||||
FROM api_keys WHERE key = ? AND active = 1`, key,
|
||||
)
|
||||
var ak APIKey
|
||||
var lastReq, expiresAt sql.NullString
|
||||
var activeInt int
|
||||
err := row.Scan(&ak.ID, &ak.Key, &ak.Name, &ak.Email, &ak.Plan, &ak.RateLimit,
|
||||
&ak.RequestsToday, &ak.RequestsMonth, &lastReq, &ak.CreatedAt, &expiresAt, &activeInt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ak.Active = activeInt == 1
|
||||
if lastReq.Valid {
|
||||
t, _ := time.Parse("2006-01-02 15:04:05", lastReq.String)
|
||||
ak.LastRequestAt = &t
|
||||
}
|
||||
if expiresAt.Valid {
|
||||
t, _ := time.Parse("2006-01-02 15:04:05", expiresAt.String)
|
||||
ak.ExpiresAt = &t
|
||||
}
|
||||
return &ak, nil
|
||||
}
|
||||
|
||||
func (d *DB) ListAPIKeys() ([]APIKey, error) {
|
||||
rows, err := d.Conn.Query(`SELECT id, key, name, email, plan, rate_limit, requests_today, requests_month, active FROM api_keys`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var keys []APIKey
|
||||
for rows.Next() {
|
||||
var ak APIKey
|
||||
var activeInt int
|
||||
if err := rows.Scan(&ak.ID, &ak.Key, &ak.Name, &ak.Email, &ak.Plan, &ak.RateLimit, &ak.RequestsToday, &ak.RequestsMonth, &activeInt); err != nil {
|
||||
continue
|
||||
}
|
||||
ak.Active = activeInt == 1
|
||||
keys = append(keys, ak)
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
func (d *DB) UpdatePlan(keyID int64, plan string) error {
|
||||
rl := planRateLimit(plan)
|
||||
_, err := d.Conn.Exec(`UPDATE api_keys SET plan = ?, rate_limit = ? WHERE id = ?`, plan, rl, keyID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) IncrementUsage(keyID int64, endpoint string, statusCode int, responseTimeMs int, ip string) error {
|
||||
now := time.Now()
|
||||
date := now.Format("2006-01-02")
|
||||
|
||||
_, err := d.Conn.Exec(`UPDATE api_keys SET requests_today = requests_today + 1, requests_month = requests_month + 1, last_request_at = ? WHERE id = ?`, now, keyID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = d.Conn.Exec(`INSERT INTO usage_log (api_key_id, endpoint, method, status_code, response_time_ms, ip) VALUES (?, ?, 'GET', ?, ?, ?)`,
|
||||
keyID, endpoint, statusCode, responseTimeMs, ip)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = d.Conn.Exec(`INSERT INTO usage_daily (api_key_id, date, requests) VALUES (?, ?, 1) ON CONFLICT(api_key_id, date) DO UPDATE SET requests = requests + 1`,
|
||||
keyID, date)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) GetUsageStats(keyID int64, from, to string) (*UsageStats, error) {
|
||||
stats := &UsageStats{}
|
||||
|
||||
// Total
|
||||
d.Conn.QueryRow(`SELECT COALESCE(SUM(requests), 0) FROM usage_daily WHERE api_key_id = ? AND date >= ? AND date <= ?`, keyID, from, to).Scan(&stats.TotalRequests)
|
||||
|
||||
// Daily
|
||||
rows, err := d.Conn.Query(`SELECT date, requests FROM usage_daily WHERE api_key_id = ? AND date >= ? AND date <= ? ORDER BY date`, keyID, from, to)
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var du DailyUsage
|
||||
rows.Scan(&du.Date, &du.Requests)
|
||||
stats.DailyBreakdown = append(stats.DailyBreakdown, du)
|
||||
}
|
||||
}
|
||||
|
||||
// Top endpoints
|
||||
rows2, err := d.Conn.Query(`SELECT endpoint, COUNT(*) as cnt, AVG(response_time_ms) as avg_ms FROM usage_log WHERE api_key_id = ? AND created_at >= ? AND created_at <= ? GROUP BY endpoint ORDER BY cnt DESC LIMIT 10`, keyID, from, to+" 23:59:59")
|
||||
if err == nil {
|
||||
defer rows2.Close()
|
||||
for rows2.Next() {
|
||||
var es EndpointStat
|
||||
rows2.Scan(&es.Endpoint, &es.Requests, &es.AvgLatencyMs)
|
||||
stats.TopEndpoints = append(stats.TopEndpoints, es)
|
||||
}
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func (d *DB) ResetDailyCounters() error {
|
||||
_, err := d.Conn.Exec(`UPDATE api_keys SET requests_today = 0`)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) ResetMonthlyCounters() error {
|
||||
_, err := d.Conn.Exec(`UPDATE api_keys SET requests_month = 0`)
|
||||
return err
|
||||
}
|
||||
Reference in New Issue
Block a user