diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 35a4be13..e03b288e 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -20,6 +20,12 @@ - [ ] Tests pass locally - [ ] Documentation updated (if applicable) - [ ] No breaking changes, or breaking changes are documented above +- [ ] If you **added or changed an API endpoint**, regenerated the OpenAPI spec and committed the result: + ```bash + cd services/api && cargo run --bin generate-openapi > openapi.yaml + git add openapi.yaml && git commit -m "chore: regenerate openapi.yaml" + ``` +- [ ] If you **changed system architecture** (new service, database, external dependency, or network boundary), updated [`docs/architecture.md`](../docs/architecture.md) ## Related Issues diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f270b8ad..0f8df438 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -831,6 +831,79 @@ jobs: path: frontend/coverage/ retention-days: 14 + openapi-drift-check: + name: OpenAPI Spec Drift Check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo dependencies + uses: actions/cache@v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-api-openapi-${{ hashFiles('services/api/Cargo.lock') }} + + - name: Build generate-openapi binary + run: cargo build --bin generate-openapi + working-directory: services/api + + - name: Generate OpenAPI spec from code annotations + run: cargo run --bin generate-openapi > /tmp/generated-openapi.yaml + working-directory: services/api + + - name: Compare path sets (committed vs generated) + run: | + python3 << 'EOF' + import yaml + import sys + + with open('/tmp/generated-openapi.yaml') as f: + generated = yaml.safe_load(f) + + with open('services/api/openapi.yaml') as f: + committed = yaml.safe_load(f) + + gen_paths = set(generated.get('paths', {}).keys()) + com_paths = set(committed.get('paths', {}).keys()) + + missing_in_committed = gen_paths - com_paths + extra_in_committed = com_paths - gen_paths + + failed = False + + if missing_in_committed: + print('❌ Paths annotated in code but missing from openapi.yaml:') + for p in sorted(missing_in_committed): + print(f' {p}') + failed = True + + if extra_in_committed: + print('⚠️ Paths in openapi.yaml with no matching code annotation (may be stale):') + for p in sorted(extra_in_committed): + print(f' {p}') + + if failed: + print() + print('Run the following to regenerate and commit the spec:') + print(' cd services/api && cargo run --bin generate-openapi > openapi.yaml') + sys.exit(1) + + print(f'✅ OpenAPI spec is in sync ({len(gen_paths)} paths)') + EOF + + - name: Fail if out of sync + if: failure() + run: | + echo "openapi.yaml is out of sync with the code annotations." + echo "See the step above for the list of missing / extra paths." + exit 1 + all-tests-passed: name: All Tests Passed needs: @@ -852,6 +925,7 @@ jobs: - api-cache-tests - e2e-market-creation - documentation-sync + - openapi-drift-check - validate-migration-rollbacks - api-criterion-benchmarks - frontend-unit-coverage diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 00000000..337c1fb9 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,109 @@ +# PredictIQ — System Architecture + +## System Context (C4 Level 1) + +Shows PredictIQ and the external actors and systems it interacts with. + +```mermaid +C4Context + title System Context — PredictIQ + + Person(user, "User", "Browses markets, places bets, manages subscriptions via a web browser.") + Person(admin, "Operator", "Monitors system health, manages deployments, reviews audit logs.") + + System(predictiq, "PredictIQ", "Prediction-markets platform: creates and resolves markets, tracks bets, sends notifications.") + + System_Ext(stellar, "Stellar RPC", "Blockchain network used to record bets and resolve market outcomes on-chain.") + System_Ext(sendgrid, "SendGrid", "Transactional email delivery: subscription confirmations, notifications, GDPR export links.") + + Rel(user, predictiq, "Uses", "HTTPS") + Rel(admin, predictiq, "Operates", "HTTPS / AWS Console") + Rel(predictiq, stellar, "Submits & polls transactions", "HTTPS / JSON-RPC") + Rel(predictiq, sendgrid, "Sends emails via", "HTTPS / REST API") +``` + +--- + +## Container Diagram (C4 Level 2) + +Shows the deployable containers that make up PredictIQ and how they communicate. All containers run inside a single AWS VPC; the ALB and frontend are in public subnets while the API, databases, and TTS service live in private subnets. + +```mermaid +C4Container + title Container Diagram — PredictIQ + + Person(user, "User") + Person(admin, "Operator") + + System_Ext(stellar, "Stellar RPC") + System_Ext(sendgrid, "SendGrid") + + Boundary(aws, "AWS (us-east-1)") { + + Boundary(pub_subnet, "Public Subnet") { + Container(alb, "Application Load Balancer", "AWS ALB", "Terminates TLS, routes HTTPS traffic to API tasks.") + Container(frontend, "Frontend", "Next.js / Node.js", "Server-side-rendered web UI. Fetches market data from the API.") + } + + Boundary(priv_subnet, "Private Subnet") { + Container(api, "API Service", "Rust / Axum on ECS Fargate", "REST API: market CRUD, bet placement, newsletter, email management, audit logging.") + Container(tts, "TTS Service", "Node.js on ECS Fargate", "Text-to-speech micro-service for market narration audio.") + ContainerDb(postgres, "PostgreSQL", "AWS RDS (PostgreSQL)", "Primary persistent store: markets, bets, users, newsletter subscribers, audit events.") + ContainerDb(redis, "Redis", "AWS ElastiCache", "Cache (market data, rate-limit counters, idempotency keys), blockchain circuit-breaker state.") + } + } + + Rel(user, alb, "HTTPS requests", "443") + Rel(admin, alb, "HTTPS requests", "443") + + Rel(alb, frontend, "HTTP", "3000") + Rel(alb, api, "HTTP", "8080") + + Rel(frontend, api, "API calls", "HTTP / JSON") + Rel(frontend, tts, "Audio requests", "HTTP") + + Rel(api, postgres, "Reads / writes", "PostgreSQL protocol / TLS") + Rel(api, redis, "Caches / rate-limits", "RESP / TLS") + Rel(api, stellar, "Submits & polls transactions", "HTTPS / JSON-RPC") + Rel(api, sendgrid, "Sends transactional email", "HTTPS / REST API") + + Rel(tts, api, "Reads market metadata", "HTTP / JSON") +``` + +--- + +## Network Boundaries + +| Boundary | Contents | Inbound access | +|---|---|---| +| Public subnets | ALB, (optional) bastion host | Internet (0.0.0.0/0) on port 443 | +| Private subnets | ECS tasks (API, TTS), RDS, ElastiCache | Only from within the VPC | +| AWS Secrets Manager | Credentials (DB password, SendGrid key, etc.) | ECS task IAM role only | + +--- + +## Key Technology Choices + +| Component | Technology | Reason | +|---|---|---| +| API runtime | Rust / Axum | Memory-safe, high throughput, low latency | +| TTS service | Node.js | Rapid integration with browser-compatible TTS libraries | +| Frontend | Next.js | SSR for SEO; seamless API integration | +| Primary DB | PostgreSQL (RDS) | ACID guarantees for financial/market data | +| Cache / rate-limit | Redis (ElastiCache) | Sub-millisecond reads, built-in TTL, stream support | +| Blockchain | Stellar (Soroban) | Low-fee, fast-finality smart contracts | +| Email | SendGrid | Reliable delivery, webhook support for tracking | +| Compute | AWS ECS Fargate | Serverless containers; no EC2 management | +| IaC | Terraform | Reproducible, version-controlled infrastructure | + +--- + +## Architecture Review — PR Checklist + +When a pull request changes any of the components documented above, update this file as part of the PR. Specifically review if the change affects: + +- [ ] A new or removed service / container +- [ ] A new external dependency (third-party API, data store, etc.) +- [ ] A change in network boundary (e.g. moving a service to a public subnet) +- [ ] A change in communication protocol between services +- [ ] A significant change to data-at-rest or data-in-transit trust boundaries diff --git a/docs/data-flow.md b/docs/data-flow.md new file mode 100644 index 00000000..a6c1b332 --- /dev/null +++ b/docs/data-flow.md @@ -0,0 +1,210 @@ +# PredictIQ — Data Flow & Lifecycle + +This document describes how each major data type flows through PredictIQ: where it is collected, where it is stored, how long it is retained, how it is deleted, and who has access. It is intended to support GDPR/privacy compliance and internal security reviews. + +--- + +## Data Flow Overview + +```mermaid +flowchart TD + subgraph Browser["User Browser"] + A([User]) -->|Submits email| NL[Newsletter form] + A -->|Places bet| BET[Bet UI] + A -->|Views market| MKT[Market page] + end + + subgraph API["API Service (ECS / Private Subnet)"] + NL -->|POST /api/v1/newsletter/subscribe| NLSVC[Newsletter handler] + BET -->|POST /api/v1/markets/:id/bet| BETSVC[Market handler] + MKT -->|GET /api/v1/markets| MKTSVC[Market handler] + + NLSVC -->|Write subscription| PG[(PostgreSQL)] + NLSVC -->|Queue confirmation email| EQ[Email queue] + BETSVC -->|Submit transaction| STELLAR[Stellar RPC] + BETSVC -->|Write market record| PG + MKTSVC -->|Read-through cache| REDIS[(Redis)] + REDIS -->|Cache miss| PG + end + + subgraph Email["Email Pipeline"] + EQ -->|Deliver via API| SG[SendGrid] + SG -->|Webhook events| EVTSVC[Webhook handler] + EVTSVC -->|Write events| PG + end + + subgraph Audit["Audit Trail"] + NLSVC -->|Write audit entry| AUDIT[Audit logger] + BETSVC -->|Write audit entry| AUDIT + AUDIT -->|Persist| PG + end + + subgraph Blockchain["Blockchain (Stellar)"] + STELLAR -->|Confirmed transaction| SYNC[Blockchain sync worker] + SYNC -->|Update market state| PG + SYNC -->|Invalidate cache| REDIS + end +``` + +--- + +## Data Types + +### 1. Email Addresses (Newsletter Subscribers) + +| Attribute | Detail | +|---|---| +| **Collection point** | `POST /api/v1/newsletter/subscribe` — user submits email voluntarily | +| **Storage — primary** | `newsletter_subscribers` table in PostgreSQL (RDS, private subnet) | +| **Storage — cache** | Not cached in Redis | +| **Storage — email processor** | Forwarded to SendGrid for delivery; SendGrid stores message metadata in its own system (see SendGrid DPA) | +| **Retention** | Active subscribers: indefinitely while subscribed. Unsubscribed rows: retained with `unsubscribed_at` timestamp for suppression purposes (prevents re-subscription after unsubscribe). To-be-purged after 2 years from `unsubscribed_at` (policy; implement via scheduled job). | +| **Deletion mechanism** | `POST /api/v1/newsletter/gdpr-delete` hard-deletes the subscriber row and all linked audit entries; confirmation token is invalidated. SendGrid suppression list must be cleared separately via SendGrid API. | +| **Data export** | `GET /api/v1/newsletter/gdpr-export?email=…` returns all stored data fields for the subject. | +| **Who has access** | API service (write/read via service account); DB admin via RDS IAM auth; no direct user read-back of other subscribers | + +**Flow:** + +``` +User → POST /subscribe → [validate email] → newsletter_subscribers (PostgreSQL) + → email_jobs queue → SendGrid → confirmation email + → audit_logs (action=subscribe, actor_ip) +``` + +--- + +### 2. Subscription Status + +| Attribute | Detail | +|---|---| +| **Collection point** | Set to `confirmed=false` on subscribe; set to `confirmed=true` on `GET /api/v1/newsletter/confirm?token=…` | +| **Storage** | `newsletter_subscribers.confirmed`, `confirmed_at`, `unsubscribed_at` columns in PostgreSQL | +| **Retention** | Lifetime of the subscription record (see above) | +| **Deletion** | Deleted with the parent subscriber row (GDPR delete endpoint) | +| **Who has access** | API service (internal reads for email eligibility checks) | + +**States:** `pending_confirmation → confirmed → unsubscribed` + +--- + +### 3. Audit Events + +| Attribute | Detail | +|---|---| +| **Collection point** | Written automatically by the audit middleware on every mutating request (`AuditLogger::create_entry`) | +| **Storage** | `audit_logs` table in PostgreSQL | +| **Fields stored** | `action`, `entity_type`, `entity_id`, `actor_email`, `actor_ip`, `reason`, `changes` (JSONB diff), `created_at` | +| **Retention** | Minimum 7 years for financial/compliance events; 2 years for operational events. Retention tiers should be enforced by a scheduled archival job (not yet implemented — see TODO). | +| **Deletion mechanism** | Soft-delete (`deleted_at` column) only. Hard deletes are not performed on audit rows to preserve regulatory trail. GDPR right-to-erasure is satisfied by pseudonymising `actor_email` (replacing with a SHA-256 hash) without deleting the row. | +| **Who has access** | `GET /api/v1/audit/logs` and `GET /api/v1/audit/statistics` — API-key authenticated; API service account (write); DB admin | + +--- + +### 4. Market Data + +| Attribute | Detail | +|---|---| +| **Collection point** | Created via internal admin API or smart contract event; updated by blockchain sync worker | +| **Storage — primary** | `markets` table in PostgreSQL (`id`, `title`, `status`, `outcome_index`, `total_volume`, `ends_at`, `created_at`, `resolved_at`) | +| **Storage — cache** | Market lists and detail views cached in Redis under `market:*` keys with configurable TTL (`REDIS_CACHE_TAG_TTL_SECS`); invalidated on market state changes by the blockchain sync worker | +| **Storage — blockchain** | Market outcomes and bet results are immutably recorded on the Stellar (Soroban) contract; this is the authoritative settlement layer | +| **Retention** | Indefinite — market history is required for audit and dispute resolution | +| **Deletion** | Markets are not deleted; they transition to `resolved` or `cancelled` status | +| **Who has access** | Public read (`GET /api/v1/markets`, `GET /api/v1/blockchain/market/:id`); write restricted to service account and blockchain sync worker | + +**Flow:** + +``` +Admin / Contract event + → POST /api/v1/markets (create) → markets (PostgreSQL) → Redis (cache invalidated) + → Blockchain sync worker polls Stellar RPC + → updates market.status, outcome_index, total_volume in PostgreSQL + → invalidates Redis cache tags (market_list, market_detail) +``` + +--- + +### 5. On-Chain Bets (Stellar / Soroban) + +| Attribute | Detail | +|---|---| +| **Collection point** | User submits a signed Stellar transaction via the frontend; API proxies it to Stellar RPC | +| **Storage** | Stellar blockchain (immutable ledger); bet metadata mirrored in PostgreSQL via `blockchain_user_bets` read from contract state | +| **Retention** | Immutable on-chain — cannot be deleted. PostgreSQL mirror retained indefinitely | +| **Deletion** | Not possible for on-chain data. PostgreSQL mirror rows are not deleted | +| **Who has access** | Public on-chain (permissionless read of Stellar ledger); API via Stellar RPC (`GET /api/v1/blockchain/user-bets/:address`); DB admin | + +--- + +### 6. Email Tracking & Delivery Events + +| Attribute | Detail | +|---|---| +| **Collection point** | SendGrid posts webhook events (sent, delivered, opened, clicked, bounced, complained, unsubscribed) to `POST /api/v1/email/sendgrid-webhook` | +| **Storage** | `email_jobs` (outbound queue), `email_events` (delivery events), `email_suppressions` (bounces/complaints), `email_analytics` (aggregates) — all in PostgreSQL | +| **Retention** | `email_jobs`: 90 days after completion. `email_events`: 1 year (for deliverability analysis). `email_suppressions`: indefinite (required to prevent re-sending to hard-bounced or complaining addresses). `email_analytics`: indefinite (aggregate, non-personal). | +| **Deletion** | Cascades from `email_jobs` on hard delete (`email_events` cascade). `email_suppressions` cleared only when explicitly re-enabling a suppressed address. | +| **Who has access** | API service (write via webhook); `GET /api/v1/email/analytics`, `GET /api/v1/email/queue-stats`, `GET /api/v1/email/dead-letter` — API-key authenticated | + +--- + +### 7. Analytics Events + +| Attribute | Detail | +|---|---| +| **Collection point** | Client-side events written via the frontend; stored in `analytics_events` table | +| **Fields stored** | `event_name`, `user_id` (optional UUID), `session_id`, `page_url`, `referrer`, `properties` (JSONB), `ip_address`, `user_agent` | +| **Storage** | `analytics_events` table in PostgreSQL | +| **Retention** | 2 years from `occurred_at` | +| **Deletion** | Scheduled cleanup job (not yet implemented); on GDPR delete request, rows matching `user_id` are purged; rows with no `user_id` are pseudonymised (IP zeroed, user-agent cleared) | +| **Who has access** | API service (write); internal analytics tooling (read via DB replica or export) | + +--- + +### 8. Transient / Short-Lived Data (Redis) + +| Data | Key pattern | TTL | Purpose | +|---|---|---|---| +| Market cache | `market:list:*`, `market:detail:*` | `REDIS_CACHE_TAG_TTL_SECS` (default 3600 s) | Read-through cache for market listings | +| Rate-limit counters | `rl:{ip}:{window}` | Sliding window (per request) | Newsletter subscription rate limiting | +| Idempotency keys | `idempotency:{key}` | 24 h | Prevent duplicate newsletter confirmations | +| Blockchain circuit-breaker | `bc:circuit:*` | In-memory (process restart resets) | Tracks consecutive Stellar RPC failures | +| Email queue processing | Redis streams / sorted sets | Until consumed | In-flight email job state | + +Redis data is not subject to long-term retention policies — all keys expire automatically. Redis is not the system of record for any user PII. + +--- + +## GDPR Data Subject Rights — Implementation Map + +| Right | Endpoint / Mechanism | +|---|---| +| Right of access | `GET /api/v1/newsletter/gdpr-export?email=…` | +| Right to erasure | `POST /api/v1/newsletter/gdpr-delete` | +| Right to rectification | Not yet implemented — contact data team | +| Right to object (unsubscribe) | `GET /api/v1/newsletter/unsubscribe?token=…` | +| Data portability | GDPR export endpoint returns JSON | + +> **Note:** On-chain data (Stellar bets) is technically impossible to erase. Inform data subjects of this limitation in the privacy policy before they place bets. + +--- + +## Access Control Summary + +| Data store | Access method | Who | +|---|---|---| +| PostgreSQL (RDS) | sqlx connection pool (`DATABASE_URL`) | API service task only; DB admin via RDS IAM auth | +| Redis (ElastiCache) | deadpool-redis (`REDIS_URL`) | API service task only | +| Stellar RPC | HTTPS / JSON-RPC (`BLOCKCHAIN_RPC_URL`) | API service task only | +| SendGrid | HTTPS REST (`SENDGRID_API_KEY`) | API service task only | +| AWS Secrets Manager | IAM role (`ECS_TASK_ROLE`) | ECS task role; no human access in production | + +--- + +## Outstanding Items + +- [ ] Implement scheduled PostgreSQL cleanup jobs for: `email_jobs` > 90 days, `email_events` > 1 year, `analytics_events` > 2 years +- [ ] Implement pseudonymisation of `actor_email` in `audit_logs` for GDPR erasure requests +- [ ] Document `contact_form_submissions` and `waitlist_entries` retention (tables exist in migrations 003/004 but are not yet surfaced in this document) +- [ ] Obtain a signed Data Processing Agreement (DPA) with SendGrid covering the email addresses forwarded for delivery +- [ ] Add `user_id` linkage to `analytics_events` GDPR delete path diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 00000000..772c86ae --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,404 @@ +# PredictIQ — Production Deployment Guide + +This guide covers every step from provisioning infrastructure from scratch to running day-to-day deployments, database migrations, rollbacks, and environment variable management. + +--- + +## Table of Contents + +1. [Prerequisites](#1-prerequisites) +2. [First-time Infrastructure Provisioning](#2-first-time-infrastructure-provisioning) +3. [Environment Variable Management](#3-environment-variable-management) +4. [Normal Deployment Workflow](#4-normal-deployment-workflow) +5. [Database Migration Process](#5-database-migration-process) +6. [Rollback Procedure](#6-rollback-procedure) +7. [Validation Checklist](#7-validation-checklist) + +--- + +## 1. Prerequisites + +Install and configure the following tools before proceeding. + +| Tool | Version | Install | +|---|---|---| +| AWS CLI | ≥ 2.x | `brew install awscli` / [aws.amazon.com/cli](https://aws.amazon.com/cli/) | +| Terraform | ≥ 1.5.0 | `brew install terraform` | +| Docker | ≥ 24 | [docs.docker.com](https://docs.docker.com/get-docker/) | +| Rust / Cargo | stable | `curl https://sh.rustup.rs -sSf \| sh` | +| psql (PostgreSQL client) | ≥ 14 | `brew install libpq` | +| GitHub CLI (`gh`) | ≥ 2.x | `brew install gh` | + +### AWS credentials + +Configure an AWS profile with permission to assume the deployment IAM roles: + +```bash +aws configure --profile predictiq +# Enter: Access Key ID, Secret Access Key, us-east-1, json +``` + +Then verify access: + +```bash +aws sts get-caller-identity --profile predictiq +``` + +### Required GitHub repository secrets + +The deployment workflow reads the following secrets. Set them under **Settings → Secrets → Actions** in the repository: + +| Secret | Description | +|---|---| +| `AWS_ROLE_DEV` | IAM role ARN for the dev environment | +| `AWS_ROLE_STAGING` | IAM role ARN for the staging environment | +| `AWS_ROLE_PROD` | IAM role ARN for the production environment | + +--- + +## 2. First-time Infrastructure Provisioning + +Run this section **once** per environment. Skip it for subsequent deployments. + +### Step 1 — Bootstrap Terraform remote state + +The bootstrap script creates the S3 bucket and DynamoDB table that Terraform uses for remote state storage and locking. + +```bash +cd infrastructure/terraform + +# Bootstrap for production (repeat with dev/staging as needed) +./bootstrap.sh us-east-1 prod +``` + +Expected output: bucket name, DynamoDB table name, and confirmation that versioning and encryption are enabled. + +### Step 2 — Initialise Terraform + +```bash +cd infrastructure/terraform +terraform init -backend-config=environments/production/backend.hcl +``` + +### Step 3 — Plan and review + +```bash +terraform plan -var-file="environments/production/terraform.tfvars" +``` + +Review the plan output carefully. Confirm: +- A new VPC with public and private subnets +- An RDS PostgreSQL instance in the private subnet +- An ElastiCache Redis cluster in the private subnet +- An ECS cluster + Fargate service for the API +- An Application Load Balancer in the public subnet +- A CloudWatch monitoring module + +### Step 4 — Apply + +```bash +terraform apply -var-file="environments/production/terraform.tfvars" +``` + +Confirm with `yes` when prompted. The first apply takes approximately 15–25 minutes. + +### Step 5 — Retrieve outputs + +```bash +terraform output +``` + +Note the following values — you will need them for environment variable configuration: + +| Output | Usage | +|---|---| +| `database_url` | `DATABASE_URL` for the API service | +| `redis_url` | `REDIS_URL` for the API service | +| `alb_dns_name` | Configure DNS CNAME to this value | +| `ecs_cluster_name` | Used for ECS deployment commands | +| `ecs_service_name` | Used for ECS deployment commands | + +### Step 6 — Run database migrations (first time) + +Connect to the RDS instance via the bastion or AWS RDS Proxy (see [Database Migration Process](#5-database-migration-process)). + +```bash +DATABASE_URL="postgres://user:password@:5432/predictiq" \ + bash services/api/scripts/run_migrations.sh +``` + +### Step 7 — Configure AWS Secrets Manager + +Store secrets that the ECS task reads at runtime: + +```bash +# Create the production secret bundle +aws secretsmanager create-secret \ + --name predictiq/prod/api \ + --secret-string '{ + "DATABASE_URL": "postgres://...", + "REDIS_URL": "rediss://...", + "SENDGRID_API_KEY": "SG.xxx", + "HMAC_KEY": "<32-byte-hex>", + "UNSUBSCRIBE_SIGNING_SECRET": "", + "PREDICTIQ_CONTRACT_ID": "", + "API_KEYS": "," + }' \ + --profile predictiq +``` + +--- + +## 3. Environment Variable Management + +The API service is configured entirely via environment variables. Variables are stored in AWS Secrets Manager and injected into ECS task definitions at deploy time. + +### Required variables (production) + +| Variable | Description | Example | +|---|---|---| +| `DATABASE_URL` | PostgreSQL connection string | `postgres://user:pass@host:5432/predictiq` | +| `REDIS_URL` | Redis connection string | `rediss://host:6379` | +| `HMAC_KEY` | 32-byte hex secret for request signing | `deadbeef...` | +| `API_KEYS` | Comma-separated admin API keys | `key1,key2` | +| `SENDGRID_API_KEY` | SendGrid API key | `SG.xxx` | +| `FROM_EMAIL` | Sender address for transactional email | `noreply@predictiq.com` | +| `BASE_URL` | Public base URL of the API | `https://api.predictiq.com` | +| `UNSUBSCRIBE_SIGNING_SECRET` | HMAC secret for unsubscribe tokens | `` | +| `PREDICTIQ_CONTRACT_ID` | Stellar/Soroban contract address | `C...` | +| `BLOCKCHAIN_RPC_URL` | Stellar Horizon / Soroban RPC endpoint | `https://soroban-testnet.stellar.org` | +| `STELLAR_NETWORK_PASSPHRASE` | Stellar network passphrase | `Test SDF Network ; September 2015` | + +### Optional / tuning variables + +| Variable | Default | Description | +|---|---|---| +| `API_BIND_ADDR` | `0.0.0.0:8080` | Bind address | +| `CORS_ALLOWED_ORIGINS` | *(empty — blocks cross-origin)* | Comma-separated origins | +| `DB_POOL_MIN_CONNECTIONS` | `2` | Minimum DB pool connections | +| `DB_POOL_MAX_CONNECTIONS` | `10` | Maximum DB pool connections | +| `FEATURED_LIMIT` | `10` | Max featured markets returned | +| `NEWSLETTER_TOKEN_TTL_SECS` | `86400` | Confirmation token expiry | +| `OTLP_ENDPOINT` | *(none)* | OpenTelemetry collector endpoint | +| `TRACE_SAMPLE_RATE` | `0.1` | Fraction of requests traced (0–1) | +| `SENDGRID_WEBHOOK_SECRET` | *(none)* | SendGrid webhook signature secret | +| `ADMIN_WHITELIST_IPS` | *(none — admin routes unrestricted)* | Comma-separated CIDR allowlist | + +### Updating a secret + +```bash +aws secretsmanager update-secret \ + --secret-id predictiq/prod/api \ + --secret-string '{"SENDGRID_API_KEY": "SG.new-value", ...}' \ + --profile predictiq +``` + +After updating secrets, force a new ECS deployment to pick up the changes (see [Step 4](#step-4--force-ecs-deployment) below). + +--- + +## 4. Normal Deployment Workflow + +A normal deployment pushes a new container image and triggers a rolling ECS update. + +### Step 1 — Merge to main + +All deployments start with merging a PR to `main`. GitHub Actions (`.github/workflows/docker.yml`) automatically builds and pushes a tagged image to GHCR: + +``` +ghcr.io//predictiq: +ghcr.io//predictiq:main +ghcr.io//predictiq:latest +``` + +### Step 2 — Update the image URI in Terraform (if pinning a specific SHA) + +Edit `infrastructure/terraform/environments/production/terraform.tfvars`: + +```hcl +api_image_uri = "ghcr.io//predictiq:" +``` + +Commit and push. The Terraform deployment workflow applies automatically on push to `main`. + +### Step 3 — Monitor the ECS deployment + +```bash +# Watch the rolling deployment +aws ecs describe-services \ + --cluster predictiq-prod \ + --services predictiq-api \ + --profile predictiq \ + --query 'services[0].deployments' + +# Stream ECS task logs +aws logs tail /ecs/predictiq-prod/api --follow --profile predictiq +``` + +### Step 4 — Force ECS deployment (for config-only changes) + +If you changed secrets but not the image, force a redeployment: + +```bash +aws ecs update-service \ + --cluster predictiq-prod \ + --service predictiq-api \ + --force-new-deployment \ + --profile predictiq +``` + +### Step 5 — Verify the deployment + +```bash +curl -s https://api.predictiq.com/health | jq . +``` + +Expected response: +```json +{ + "status": "ok", + "redis": { "circuit_breaker": "closed" }, + "db": { "status": "ok" } +} +``` + +--- + +## 5. Database Migration Process + +Database migrations are SQL files in `services/api/database/migrations/`. Each migration has a corresponding rollback script in `services/api/database/migrations/rollbacks/`. + +### Applying migrations + +Migrations must be applied **before** deploying a new API version that requires them. + +```bash +# Export the production database URL (get from Secrets Manager) +export DATABASE_URL=$(aws secretsmanager get-secret-value \ + --secret-id predictiq/prod/api \ + --profile predictiq \ + --query 'SecretString' \ + --output text | python3 -c "import json,sys; print(json.load(sys.stdin)['DATABASE_URL'])") + +# Apply all pending migrations +bash services/api/scripts/run_migrations.sh +``` + +To apply a single migration manually: + +```bash +psql "$DATABASE_URL" -v ON_ERROR_STOP=1 \ + -f services/api/database/migrations/011_create_markets.sql +``` + +### Checking migration status + +```bash +psql "$DATABASE_URL" -c "SELECT version, applied_at FROM schema_migrations ORDER BY version;" +``` + +### Writing a new migration + +1. Create `services/api/database/migrations/NNN_description.sql` +2. Create `services/api/database/migrations/rollbacks/NNN_description_down.sql` (required — CI validates this) +3. Test the migration and rollback locally against a dev database +4. Include both files in the PR + +--- + +## 6. Rollback Procedure + +### Rolling back the application (code only) + +```bash +# Option A — revert the merge commit and push; CI redeploys the previous image +git revert +git push origin main + +# Option B — force-redeploy a previous image without a code change +aws ecs update-service \ + --cluster predictiq-prod \ + --service predictiq-api \ + --task-definition predictiq-api: \ + --force-new-deployment \ + --profile predictiq +``` + +### Rolling back a database migration + +Each migration has a paired rollback script. Apply in reverse order (highest version first): + +```bash +# Roll back migration 012 +psql "$DATABASE_URL" -v ON_ERROR_STOP=1 \ + -f services/api/database/migrations/rollbacks/012_add_performance_indexes_down.sql + +# Remove the version record so the migration is treated as pending again +psql "$DATABASE_URL" -c "DELETE FROM schema_migrations WHERE version = '012';" +``` + +For more detail, see [`services/api/database/migrations/ROLLBACK.md`](../services/api/database/migrations/ROLLBACK.md). + +### Rolling back infrastructure + +See [`infrastructure/ROLLBACK.md`](../infrastructure/ROLLBACK.md) for full procedures including Terraform state restoration. + +Quick path — revert via git: + +```bash +git revert +git push origin main +# GitHub Actions applies the reverted Terraform plan automatically +``` + +### Rollback timeline targets + +| Scope | Target time | +|---|---| +| Application (ECS rolling update) | < 5 minutes | +| Config-only (Secrets Manager + force redeploy) | < 3 minutes | +| Database migration rollback | 5–15 minutes (depends on data volume) | +| Full infrastructure rollback (Terraform) | 15–30 minutes | + +--- + +## 7. Validation Checklist + +Run these checks after every production deployment: + +```bash +# 1. Health endpoint +curl -sf https://api.predictiq.com/health | jq . + +# 2. Statistics (exercises DB + cache) +curl -sf https://api.predictiq.com/api/v1/statistics | jq . + +# 3. Featured markets (exercises blockchain + cache) +curl -sf https://api.predictiq.com/api/v1/markets/featured | jq . + +# 4. Blockchain health +curl -sf https://api.predictiq.com/api/v1/blockchain/health | jq . + +# 5. CloudWatch alarms — confirm no alarms are in ALARM state +aws cloudwatch describe-alarms \ + --state-value ALARM \ + --profile predictiq \ + --query 'MetricAlarms[].AlarmName' + +# 6. ECS service stabilized +aws ecs describe-services \ + --cluster predictiq-prod \ + --services predictiq-api \ + --profile predictiq \ + --query 'services[0].{desired:desiredCount,running:runningCount,pending:pendingCount}' +``` + +All six checks should pass within 5 minutes of the deployment completing. + +--- + +> **Validate this guide:** After any significant change to the deployment process, have a team member who was not involved in the change follow it end-to-end in the staging environment and record the outcome here. +> +> | Date | Follower | Environment | Issues found | PR | +> |---|---|---|---|---| +> | *(not yet validated)* | | | | | diff --git a/services/api/Cargo.toml b/services/api/Cargo.toml index bab4d7ee..ba4d58af 100644 --- a/services/api/Cargo.toml +++ b/services/api/Cargo.toml @@ -8,6 +8,10 @@ publish = false name = "predictiq-api" path = "src/main.rs" +[[bin]] +name = "generate-openapi" +path = "src/bin/generate_openapi.rs" + [lib] name = "predictiq_api" path = "src/lib.rs" @@ -47,6 +51,7 @@ hex = "0.4" base64 = "0.22" subtle = "2.5" ipnet = "2" +utoipa = { version = "4", features = ["yaml"] } [dev-dependencies] criterion = { version = "0.5", features = ["html_reports"] } diff --git a/services/api/src/bin/generate_openapi.rs b/services/api/src/bin/generate_openapi.rs new file mode 100644 index 00000000..8ead2d5e --- /dev/null +++ b/services/api/src/bin/generate_openapi.rs @@ -0,0 +1,8 @@ +use predictiq_api::openapi_spec::ApiDoc; +use utoipa::OpenApi; + +fn main() { + let doc = ApiDoc::openapi(); + let yaml = doc.to_yaml().expect("Failed to serialize OpenAPI spec as YAML"); + print!("{yaml}"); +} diff --git a/services/api/src/handlers.rs b/services/api/src/handlers.rs index bbea7005..31a068cf 100644 --- a/services/api/src/handlers.rs +++ b/services/api/src/handlers.rs @@ -17,11 +17,12 @@ use validator::ValidateEmail; use crate::{blockchain::HealthStatus, cache::{keys, InvalidationTag}, db::DbError, email::webhook::sendgrid_webhook_handler, pagination::{PaginatedResponse, PaginationQuery}, AppState}; -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct ApiError { pub code: &'static str, pub message: String, #[serde(skip)] + #[schema(ignore)] pub status: StatusCode, } @@ -100,7 +101,7 @@ fn into_api_error(err: anyhow::Error) -> ApiError { ApiError::internal(err) } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct FeaturedMarketView { pub id: i64, pub title: String, @@ -110,6 +111,14 @@ pub struct FeaturedMarketView { pub resolved_outcome: Option, } +#[utoipa::path( + get, + path = "/health", + tag = "health", + responses( + (status = 200, description = "Service is healthy or degraded"), + ) +)] pub async fn health(State(state): State>, headers: HeaderMap) -> impl IntoResponse { use crate::cache::CircuitState; use crate::correlation::REQUEST_ID_HEADER; @@ -163,41 +172,42 @@ pub async fn health(State(state): State>, headers: HeaderMap) -> i (StatusCode::OK, Json(health_status)) } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, utoipa::ToSchema)] pub struct NewsletterSubscribeRequest { pub email: String, pub source: Option, } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, utoipa::ToSchema)] pub struct NewsletterEmailRequest { pub email: String, } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, utoipa::IntoParams)] pub struct NewsletterConfirmQuery { pub token: String, } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, utoipa::IntoParams)] pub struct NewsletterUnsubscribeQuery { pub token: String, } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, utoipa::IntoParams)] pub struct NewsletterExportQuery { pub email: String, } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, utoipa::ToSchema)] pub struct NewsletterResponse { pub success: bool, pub message: String, } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, utoipa::ToSchema)] pub struct NewsletterExportResponse { pub success: bool, + #[schema(value_type = Object)] pub data: crate::db::NewsletterSubscriber, } @@ -221,6 +231,16 @@ fn is_disposable_email(email: &str) -> bool { use crate::security::extract_client_ip_cidrs; +#[utoipa::path( + post, + path = "/api/v1/newsletter/subscribe", + tag = "newsletter", + request_body = NewsletterSubscribeRequest, + responses( + (status = 202, description = "Subscription request accepted", body = NewsletterResponse), + (status = 400, description = "Invalid email address", body = NewsletterResponse), + ) +)] pub async fn newsletter_subscribe( State(state): State>, headers: HeaderMap, @@ -338,6 +358,17 @@ pub async fn newsletter_subscribe( )) } +#[utoipa::path( + get, + path = "/api/v1/newsletter/confirm", + tag = "newsletter", + params(NewsletterConfirmQuery), + responses( + (status = 200, description = "Subscription confirmed", body = NewsletterResponse), + (status = 400, description = "Missing or invalid token", body = NewsletterResponse), + (status = 404, description = "Token not found or expired", body = NewsletterResponse), + ) +)] pub async fn newsletter_confirm( State(state): State>, Query(query): Query, @@ -377,6 +408,16 @@ pub async fn newsletter_confirm( )) } +#[utoipa::path( + get, + path = "/api/v1/newsletter/unsubscribe", + tag = "newsletter", + params(NewsletterUnsubscribeQuery), + responses( + (status = 200, description = "Successfully unsubscribed", body = NewsletterResponse), + (status = 401, description = "Invalid unsubscribe token", body = NewsletterResponse), + ) +)] pub async fn newsletter_unsubscribe( State(state): State>, Query(query): Query, @@ -424,6 +465,18 @@ pub async fn newsletter_unsubscribe( )) } +#[utoipa::path( + get, + path = "/api/v1/newsletter/gdpr/export", + tag = "newsletter", + params(NewsletterExportQuery), + responses( + (status = 200, description = "GDPR data export", body = NewsletterExportResponse), + (status = 400, description = "Invalid email", body = NewsletterResponse), + (status = 404, description = "No record found", body = NewsletterResponse), + (status = 429, description = "Rate limited", body = NewsletterResponse), + ) +)] pub async fn newsletter_gdpr_export( State(state): State>, headers: HeaderMap, @@ -514,6 +567,16 @@ pub async fn newsletter_gdpr_export( .into_response()) } +#[utoipa::path( + delete, + path = "/api/v1/newsletter/gdpr/delete", + tag = "newsletter", + request_body = NewsletterEmailRequest, + responses( + (status = 200, description = "Data deleted", body = NewsletterResponse), + (status = 400, description = "Invalid email", body = NewsletterResponse), + ) +)] pub async fn newsletter_gdpr_delete( State(state): State>, Json(payload): Json, @@ -545,6 +608,14 @@ pub async fn newsletter_gdpr_delete( )) } +#[utoipa::path( + get, + path = "/api/v1/statistics", + tag = "markets", + responses( + (status = 200, description = "Platform statistics"), + ) +)] pub async fn statistics(State(state): State>) -> Result { let start = Instant::now(); let cache_key = keys::api_statistics(); @@ -570,6 +641,15 @@ pub async fn statistics(State(state): State>) -> Result>, Query(query): Query, @@ -638,6 +718,15 @@ pub async fn featured_markets( Ok((StatusCode::OK, Json(paginated))) } +#[utoipa::path( + get, + path = "/api/v1/content", + tag = "markets", + params(PaginationQuery), + responses( + (status = 200, description = "Paginated content items"), + ) +)] pub async fn content( State(state): State>, Query(query): Query, @@ -688,7 +777,7 @@ pub async fn content( Ok((StatusCode::OK, Json(paginated))) } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct InvalidationResult { pub invalidated_keys: usize, } @@ -704,12 +793,27 @@ pub struct InvalidationResult { /// because they are not affected by a single market resolution. /// 4. Cache invalidation only runs after a successful write — a failed DB update /// leaves the cache untouched. -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, utoipa::ToSchema)] pub struct ResolveMarketRequest { /// The winning outcome index (0-based). pub outcome_index: u32, } +#[utoipa::path( + post, + path = "/api/v1/markets/{market_id}/resolve", + tag = "markets", + params( + ("market_id" = i64, Path, description = "Market database ID"), + ), + request_body = ResolveMarketRequest, + responses( + (status = 200, description = "Market resolved and cache invalidated", body = InvalidationResult), + (status = 400, description = "Bad request", body = ApiError), + (status = 500, description = "Internal error", body = ApiError), + ), + security(("api_key" = [])) +)] pub async fn resolve_market( State(state): State>, Path(market_id): Path, @@ -757,6 +861,15 @@ pub async fn metrics(State(state): State>) -> Result>, ) -> Result { @@ -772,6 +885,18 @@ pub async fn blockchain_health( Ok((status_code, Json(data))) } +#[utoipa::path( + get, + path = "/api/v1/blockchain/markets/{market_id}", + tag = "blockchain", + params( + ("market_id" = i64, Path, description = "Market database ID"), + ), + responses( + (status = 200, description = "On-chain market data"), + (status = 500, description = "Blockchain query failed", body = ApiError), + ) +)] pub async fn blockchain_market_data( State(state): State>, Path(market_id): Path, @@ -784,6 +909,15 @@ pub async fn blockchain_market_data( Ok((StatusCode::OK, Json(data))) } +#[utoipa::path( + get, + path = "/api/v1/blockchain/stats", + tag = "blockchain", + responses( + (status = 200, description = "Platform-wide blockchain statistics"), + (status = 500, description = "Blockchain query failed", body = ApiError), + ) +)] pub async fn blockchain_platform_stats( State(state): State>, ) -> Result { @@ -795,6 +929,19 @@ pub async fn blockchain_platform_stats( Ok((StatusCode::OK, Json(data))) } +#[utoipa::path( + get, + path = "/api/v1/blockchain/users/{user}/bets", + tag = "blockchain", + params( + ("user" = String, Path, description = "Stellar account address"), + PaginationQuery, + ), + responses( + (status = 200, description = "Paginated list of user bets"), + (status = 500, description = "Blockchain query failed", body = ApiError), + ) +)] pub async fn blockchain_user_bets( State(state): State>, Path(user): Path, @@ -832,6 +979,18 @@ pub async fn blockchain_user_bets( Ok((StatusCode::OK, Json(paginated))) } +#[utoipa::path( + get, + path = "/api/v1/blockchain/oracle/{market_id}", + tag = "blockchain", + params( + ("market_id" = i64, Path, description = "Market database ID"), + ), + responses( + (status = 200, description = "Oracle resolution result for the market"), + (status = 500, description = "Blockchain query failed", body = ApiError), + ) +)] pub async fn blockchain_oracle_result( State(state): State>, Path(market_id): Path, @@ -844,6 +1003,18 @@ pub async fn blockchain_oracle_result( Ok((StatusCode::OK, Json(data))) } +#[utoipa::path( + get, + path = "/api/v1/blockchain/tx/{tx_hash}", + tag = "blockchain", + params( + ("tx_hash" = String, Path, description = "Stellar transaction hash"), + ), + responses( + (status = 200, description = "Transaction status"), + (status = 500, description = "Blockchain query failed", body = ApiError), + ) +)] pub async fn blockchain_tx_status( State(state): State>, Path(tx_hash): Path, @@ -857,6 +1028,16 @@ pub async fn blockchain_tx_status( Ok((StatusCode::OK, Json(data))) } +#[utoipa::path( + post, + path = "/api/blockchain/replay", + tag = "blockchain", + responses( + (status = 200, description = "Replay progress"), + (status = 500, description = "Replay failed", body = ApiError), + ), + security(("api_key" = [])) +)] pub async fn blockchain_replay( State(state): State>, Json(payload): Json, @@ -897,18 +1078,31 @@ pub async fn warm_critical_caches(state: Arc) -> anyhow::Result<()> { // Email service handlers -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, utoipa::ToSchema)] pub struct EmailTestRequest { pub recipient: String, pub template_name: String, } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, utoipa::IntoParams)] pub struct EmailAnalyticsQuery { pub template_name: Option, pub days: Option, } +#[utoipa::path( + get, + path = "/api/v1/email/preview/{template_name}", + tag = "email", + params( + ("template_name" = String, Path, description = "Email template name"), + ), + responses( + (status = 200, description = "Rendered email HTML preview"), + (status = 500, description = "Template render error", body = ApiError), + ), + security(("api_key" = [])) +)] pub async fn email_preview( State(state): State>, Path(template_name): Path, @@ -943,6 +1137,17 @@ pub async fn email_preview( Ok((StatusCode::OK, Json(preview))) } +#[utoipa::path( + post, + path = "/api/v1/email/test", + tag = "email", + request_body = EmailTestRequest, + responses( + (status = 200, description = "Test email sent"), + (status = 500, description = "Send failed", body = ApiError), + ), + security(("api_key" = [])) +)] pub async fn email_send_test( State(state): State>, Json(payload): Json, @@ -963,6 +1168,17 @@ pub async fn email_send_test( )) } +#[utoipa::path( + get, + path = "/api/v1/email/analytics", + tag = "email", + params(EmailAnalyticsQuery), + responses( + (status = 200, description = "Email delivery analytics"), + (status = 500, description = "Query failed", body = ApiError), + ), + security(("api_key" = [])) +)] pub async fn email_analytics( State(state): State>, Query(query): Query, @@ -977,6 +1193,16 @@ pub async fn email_analytics( Ok((StatusCode::OK, Json(analytics))) } +#[utoipa::path( + get, + path = "/api/v1/email/queue/stats", + tag = "email", + responses( + (status = 200, description = "Email queue statistics"), + (status = 500, description = "Query failed", body = ApiError), + ), + security(("api_key" = [])) +)] pub async fn email_queue_stats( State(state): State>, ) -> Result { @@ -991,6 +1217,16 @@ pub async fn email_queue_stats( Ok((StatusCode::OK, Json(stats))) } +#[utoipa::path( + get, + path = "/api/v1/email/queue/dead-letter", + tag = "email", + responses( + (status = 200, description = "List of dead-letter email job IDs"), + (status = 500, description = "Query failed", body = ApiError), + ), + security(("api_key" = [])) +)] pub async fn email_dead_letter_list( State(state): State>, ) -> Result { @@ -1003,6 +1239,20 @@ pub async fn email_dead_letter_list( Ok((StatusCode::OK, Json(serde_json::json!({ "jobs": ids, "count": ids.len() })))) } +#[utoipa::path( + post, + path = "/api/v1/email/queue/dead-letter/{job_id}/requeue", + tag = "email", + params( + ("job_id" = String, Path, description = "Dead-letter job UUID"), + ), + responses( + (status = 200, description = "Job requeued"), + (status = 404, description = "Job not found in dead-letter set", body = ApiError), + (status = 500, description = "Requeue failed", body = ApiError), + ), + security(("api_key" = [])) +)] pub async fn email_dead_letter_requeue( State(state): State>, Path(job_id): Path, @@ -1020,6 +1270,15 @@ pub async fn email_dead_letter_requeue( } } +#[utoipa::path( + post, + path = "/webhooks/sendgrid", + tag = "webhooks", + responses( + (status = 200, description = "Events processed"), + (status = 400, description = "Invalid signature or payload", body = ApiError), + ) +)] pub async fn sendgrid_webhook( State(state): State>, headers: HeaderMap, @@ -1034,7 +1293,17 @@ pub async fn sendgrid_webhook( }) } -/// Query audit logs with filters +#[utoipa::path( + get, + path = "/api/v1/audit/logs", + tag = "audit", + params(AuditLogsQuery), + responses( + (status = 200, description = "Audit log entries"), + (status = 500, description = "Query failed", body = ApiError), + ), + security(("api_key" = [])) +)] pub async fn audit_logs( State(state): State>, Query(params): Query, @@ -1063,7 +1332,17 @@ pub async fn audit_logs( Ok((StatusCode::OK, Json(logs))) } -/// Get audit log statistics +#[utoipa::path( + get, + path = "/api/v1/audit/statistics", + tag = "audit", + params(AuditStatisticsQuery), + responses( + (status = 200, description = "Audit log statistics for the requested period"), + (status = 500, description = "Query failed", body = ApiError), + ), + security(("api_key" = [])) +)] pub async fn audit_statistics( State(state): State>, Query(params): Query, @@ -1089,7 +1368,7 @@ pub async fn audit_statistics( Ok((StatusCode::OK, Json(stats))) } -#[derive(Debug, serde::Deserialize)] +#[derive(Debug, serde::Deserialize, utoipa::IntoParams)] pub struct AuditLogsQuery { pub actor: Option, pub action: Option, @@ -1100,7 +1379,7 @@ pub struct AuditLogsQuery { pub offset: Option, } -#[derive(Debug, serde::Deserialize)] +#[derive(Debug, serde::Deserialize, utoipa::IntoParams)] pub struct AuditStatisticsQuery { pub from: Option, pub to: Option, diff --git a/services/api/src/lib.rs b/services/api/src/lib.rs index fbcc23bd..ef2bb5c2 100644 --- a/services/api/src/lib.rs +++ b/services/api/src/lib.rs @@ -21,6 +21,7 @@ pub mod shutdown; pub mod tracing_config; pub mod validation; pub mod versioning; +pub mod openapi_spec; // Re-export AppState so integration tests can construct it. pub use crate::app_state::AppState; diff --git a/services/api/src/openapi_spec.rs b/services/api/src/openapi_spec.rs new file mode 100644 index 00000000..02a1eb72 --- /dev/null +++ b/services/api/src/openapi_spec.rs @@ -0,0 +1,79 @@ +use utoipa::OpenApi; + +use crate::handlers::{ + ApiError, AuditLogsQuery, AuditStatisticsQuery, EmailAnalyticsQuery, EmailTestRequest, + FeaturedMarketView, InvalidationResult, NewsletterEmailRequest, NewsletterExportResponse, + NewsletterResponse, NewsletterSubscribeRequest, ResolveMarketRequest, + NewsletterConfirmQuery, NewsletterUnsubscribeQuery, NewsletterExportQuery, +}; +use crate::pagination::PaginationQuery; + +#[derive(OpenApi)] +#[openapi( + info( + title = "PredictIQ API", + version = "1.0.0", + description = "REST API for the PredictIQ prediction markets platform.\n\ + \n\ + ## API Versioning\n\ + The API uses URL path versioning (`/api/v1/`). The current stable version is **v1**.\n\ + \n\ + ## Deprecation Policy\n\ + When a version is deprecated, responses include a `Deprecation` header. \ + Deprecated versions are supported for a minimum of 12 months.", + ), + paths( + crate::handlers::health, + crate::handlers::newsletter_subscribe, + crate::handlers::newsletter_confirm, + crate::handlers::newsletter_unsubscribe, + crate::handlers::newsletter_gdpr_export, + crate::handlers::newsletter_gdpr_delete, + crate::handlers::statistics, + crate::handlers::featured_markets, + crate::handlers::content, + crate::handlers::resolve_market, + crate::handlers::blockchain_health, + crate::handlers::blockchain_market_data, + crate::handlers::blockchain_platform_stats, + crate::handlers::blockchain_user_bets, + crate::handlers::blockchain_oracle_result, + crate::handlers::blockchain_tx_status, + crate::handlers::blockchain_replay, + crate::handlers::email_preview, + crate::handlers::email_send_test, + crate::handlers::email_analytics, + crate::handlers::email_queue_stats, + crate::handlers::email_dead_letter_list, + crate::handlers::email_dead_letter_requeue, + crate::handlers::sendgrid_webhook, + crate::handlers::audit_logs, + crate::handlers::audit_statistics, + ), + components( + schemas( + ApiError, + FeaturedMarketView, + InvalidationResult, + NewsletterSubscribeRequest, + NewsletterEmailRequest, + NewsletterResponse, + NewsletterExportResponse, + ResolveMarketRequest, + EmailTestRequest, + ) + ), + tags( + (name = "health", description = "Health check"), + (name = "newsletter", description = "Newsletter subscription management"), + (name = "markets", description = "Market data and resolution"), + (name = "blockchain", description = "Stellar blockchain integration"), + (name = "email", description = "Email service management (admin)"), + (name = "webhooks", description = "Incoming provider webhooks"), + (name = "audit", description = "Audit log access (admin)"), + ), + security( + ("api_key" = []) + ) +)] +pub struct ApiDoc; diff --git a/services/api/src/pagination.rs b/services/api/src/pagination.rs index 1e43466d..a82e9d37 100644 --- a/services/api/src/pagination.rs +++ b/services/api/src/pagination.rs @@ -77,6 +77,25 @@ pub fn validate_pagination(params: PaginationParams) -> Result, + pub cursor: Option, +} + +impl PaginationQuery { + pub fn limit(&self) -> i64 { + self.limit.unwrap_or(DEFAULT_LIMIT as i64).max(1).min(MAX_PAGE_LIMIT as i64) + } + + pub fn cursor(&self) -> Option { + self.cursor.clone() + } +} + #[axum::async_trait] impl axum::extract::FromRequestParts for ValidatedPaginationQuery where