From a514abfb9ddc1b764b83815d08d42b472948dfc0 Mon Sep 17 00:00:00 2001 From: roman pery Date: Sat, 27 Jun 2026 13:40:56 -0600 Subject: [PATCH] feat(docs): add OpenAPI 3.0 specification (#26) --- .github/workflows/ci.yml | 3 + openapi.yaml | 842 +++++++++++++++++++++++++++++++++++++++ package-lock.json | 78 +++- package.json | 9 +- src/index.js | 2 + src/routes/apiDocs.js | 26 ++ test/api-docs.test.js | 97 +++++ 7 files changed, 1039 insertions(+), 18 deletions(-) create mode 100644 openapi.yaml create mode 100644 src/routes/apiDocs.js create mode 100644 test/api-docs.test.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3fe04da..2177edc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,6 +44,9 @@ jobs: - name: Install dependencies run: npm ci + - name: Lint OpenAPI spec + run: npx @redocly/cli lint openapi.yaml + - name: Run tests run: npm test diff --git a/openapi.yaml b/openapi.yaml new file mode 100644 index 0000000..dbadba4 --- /dev/null +++ b/openapi.yaml @@ -0,0 +1,842 @@ +openapi: 3.0.3 +info: + title: SmartDrop API + description: | + SmartDrop backend services — price oracle, webhook delivery, health monitoring, and indexing. + + This specification covers all current REST endpoints and planned future endpoints. + version: 0.1.0 + license: + name: MIT + url: https://opensource.org/licenses/MIT + contact: + name: SmartDrop Labs + url: https://github.com/SmartDropLabs + +servers: + - url: http://localhost:4000 + description: Local development + - url: https://api.smartdrop.app + description: Production (planned) + +security: [] + +paths: + /health: + get: + operationId: healthCheck + summary: Service health check + description: Returns the current health status of the API server and its Redis connection. + tags: + - Health + responses: + '200': + description: Service is healthy + content: + application/json: + schema: + $ref: '#/components/schemas/HealthResponse' + example: + status: ok + timestamp: '2026-06-27T12:00:00.000Z' + redis_connected: true + redis_unavailable: false + '503': + description: Service is not healthy + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + error: Service unavailable + message: Redis connection failed + '500': + $ref: '#/components/responses/InternalError' + + /api/v1/prices/{asset_code}: + get: + operationId: getAssetPrice + summary: Get asset price + description: | + Returns the current USD price for a Stellar asset. + If no issuer is provided, native asset (XLM) is assumed. + tags: + - Prices + parameters: + - name: asset_code + in: path + required: true + schema: + $ref: '#/components/schemas/AssetCode' + description: Stellar asset code (1–12 uppercase alphanumeric characters) + example: XLM + - name: issuer + in: query + required: false + schema: + $ref: '#/components/schemas/StellarAddress' + description: Stellar issuer public key (G…) + example: GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335AX2OBFLDTQLNUEHRGPTM6RIA + x-rate-limit: + window: 60s + max: 30 + responses: + '200': + description: Price data for the requested asset + content: + application/json: + schema: + $ref: '#/components/schemas/PriceResponse' + example: + asset_code: XLM + issuer: null + price_usd: 0.1234 + source: stellar_dex + fetched_at: '2026-06-27T12:00:00.000Z' + is_stale: false + stale_warning: null + sources_attempted: + - stellar_dex + - coingecko + redis_unavailable: false + '400': + $ref: '#/components/responses/ValidationError' + '404': + description: Price data is not available for the requested asset + content: + application/json: + schema: + $ref: '#/components/schemas/PriceNotFoundResponse' + example: + error: Price not available + message: 'No price data found for UNKNOWN' + asset_code: UNKNOWN + issuer: null + price_usd: null + source: unavailable + fetched_at: '2026-06-27T12:00:00.000Z' + is_stale: true + stale_warning: 'No price data available from any source' + sources_attempted: [] + redis_unavailable: false + '500': + $ref: '#/components/responses/InternalError' + + /api/v1/prices/batch: + post: + operationId: batchGetPrices + summary: Get prices for multiple assets (planned) + description: | + **⚠️ Planned — not yet implemented.** + Accepts a list of asset identifiers and returns prices for all of them + in a single request. + tags: + - Prices + x-draft: true + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BatchPriceRequest' + example: + assets: + - asset_code: XLM + issuer: null + - asset_code: USDC + issuer: GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335AX2OBFLDTQLNUEHRGPTM6RIA + - asset_code: BTC + issuer: GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335AX2OBFLDTQLNUEHRGPTM6RIA + responses: + '200': + description: Batch price results + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/PriceResponse' + '400': + $ref: '#/components/responses/ValidationError' + '422': + $ref: '#/components/responses/UnprocessableEntity' + '500': + $ref: '#/components/responses/InternalError' + + /api/v1/webhooks: + post: + operationId: createWebhookEndpoint + summary: Register a webhook endpoint + description: | + Creates a new webhook endpoint that will receive signed POST requests + when SmartDrop lifecycle events occur. + tags: + - Webhooks + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateWebhookRequest' + example: + url: https://example.com/webhooks/smartdrop + events: + - airdrop.completed + - recipient.claimed + secret: whsec_myverys3cretkey + x-rate-limit: + window: 60s + max: 20 + responses: + '201': + description: Webhook endpoint created + content: + application/json: + schema: + $ref: '#/components/schemas/WebhookEndpoint' + example: + id: wh_a1b2c3d4e5f6g7h8 + url: https://example.com/webhooks/smartdrop + events: + - airdrop.completed + - recipient.claimed + active: true + secret_preview: whse...key + created_at: '2026-06-27T12:00:00.000Z' + updated_at: '2026-06-27T12:00:00.000Z' + '400': + $ref: '#/components/responses/ValidationError' + '429': + $ref: '#/components/responses/RateLimited' + '500': + $ref: '#/components/responses/InternalError' + get: + operationId: listWebhookEndpoints + summary: List all registered webhook endpoints + description: Returns all webhook endpoints (secrets are excluded; only a preview is shown). + tags: + - Webhooks + responses: + '200': + description: List of webhook endpoints + content: + application/json: + schema: + type: object + properties: + webhooks: + type: array + items: + $ref: '#/components/schemas/WebhookEndpoint' + example: + webhooks: + - id: wh_a1b2c3d4e5f6g7h8 + url: https://example.com/webhooks/smartdrop + events: + - airdrop.completed + active: true + secret_preview: whse...key + created_at: '2026-06-27T12:00:00.000Z' + updated_at: '2026-06-27T12:00:00.000Z' + '500': + $ref: '#/components/responses/InternalError' + + /api/v1/webhooks/{id}: + delete: + operationId: deleteWebhookEndpoint + summary: Remove a webhook endpoint + description: Soft-deletes a webhook endpoint by marking it inactive. + tags: + - Webhooks + parameters: + - name: id + in: path + required: true + schema: + $ref: '#/components/schemas/WebhookId' + description: Webhook endpoint ID + example: wh_a1b2c3d4e5f6g7h8 + responses: + '200': + description: Webhook endpoint removed + content: + application/json: + schema: + type: object + properties: + deleted: + type: boolean + enum: + - true + webhook: + $ref: '#/components/schemas/WebhookEndpoint' + example: + deleted: true + webhook: + id: wh_a1b2c3d4e5f6g7h8 + url: https://example.com/webhooks/smartdrop + events: + - airdrop.completed + active: false + secret_preview: whse...key + created_at: '2026-06-27T12:00:00.000Z' + updated_at: '2026-06-27T12:00:01.000Z' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalError' + + /api/v1/webhooks/{id}/test: + post: + operationId: testWebhookEndpoint + summary: Send a test ping to a webhook endpoint + description: | + Queues a test `ping` event delivery to the specified webhook endpoint. + The delivery is processed asynchronously. + tags: + - Webhooks + parameters: + - name: id + in: path + required: true + schema: + $ref: '#/components/schemas/WebhookId' + example: wh_a1b2c3d4e5f6g7h8 + x-rate-limit: + window: 60s + max: 10 + responses: + '202': + description: Test ping queued for delivery + content: + application/json: + schema: + type: object + properties: + delivery: + $ref: '#/components/schemas/WebhookDelivery' + example: + delivery: + id: dlv_x1y2z3 + endpoint_id: wh_a1b2c3d4e5f6g7h8 + event: ping + payload: + event: ping + timestamp: '2026-06-27T12:00:00.000Z' + status: pending + attempt_count: 0 + attempts: [] + next_retry_at: null + created_at: '2026-06-27T12:00:00.000Z' + updated_at: '2026-06-27T12:00:00.000Z' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/RateLimited' + '500': + $ref: '#/components/responses/InternalError' + + /api/v1/indexer/status: + get: + operationId: getIndexerStatus + summary: Indexer service status (planned) + description: | + **⚠️ Planned — not yet implemented.** + Returns the current status and ledger height of the on-chain indexer service. + tags: + - Indexer + x-draft: true + responses: + '200': + description: Indexer status + content: + application/json: + schema: + type: object + properties: + status: + type: string + enum: + - syncing + - synced + - error + current_ledger: + type: integer + latest_ledger: + type: integer + last_indexed_at: + type: string + format: date-time + example: + status: synced + current_ledger: 52489123 + latest_ledger: 52489123 + last_indexed_at: '2026-06-27T12:00:00.000Z' + '503': + description: Indexer is not responding + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + error: Indexer unavailable + message: Indexer service is not responding + '500': + $ref: '#/components/responses/InternalError' + + /ws: + get: + operationId: websocketEndpoint + summary: Real-time event stream (planned) + description: | + **⚠️ Planned — not yet implemented.** + WebSocket endpoint for streaming real-time SmartDrop events + (price updates, airdrop status changes, webhook delivery logs). + tags: + - WebSocket + x-draft: true + x-websocket: true + responses: + '101': + description: WebSocket protocol upgrade (future) + '400': + $ref: '#/components/responses/ValidationError' + +components: + securitySchemes: + BearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + description: | + API key authentication. Provide your API key as a Bearer token in the + `Authorization` header. Keys are created via `POST /api/v1/keys` and + can be scoped to specific resources. + + schemas: + StellarAddress: + type: string + pattern: ^G[A-Z0-9]{55}$ + description: Stellar public key (ed25519) starting with G + example: GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335AX2OBFLDTQLNUEHRGPTM6RIA + + StellarAddressNullable: + type: string + nullable: true + pattern: ^G[A-Z0-9]{55}$ + description: Stellar public key (ed25519) starting with G, or null for native assets + example: GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335AX2OBFLDTQLNUEHRGPTM6RIA + + AssetCode: + type: string + minLength: 1 + maxLength: 12 + pattern: ^[A-Z0-9]+$ + description: Stellar asset code (1–12 uppercase alphanumeric) + example: USDC + + WebhookId: + type: string + pattern: ^wh_ + description: Webhook endpoint ID (prefixed with `wh_`) + example: wh_a1b2c3d4e5f6g7h8 + + HealthResponse: + type: object + properties: + status: + type: string + enum: + - ok + - degraded + description: Service health status + timestamp: + type: string + format: date-time + description: Current server time in ISO 8601 + redis_connected: + type: boolean + description: Whether Redis is connected + redis_unavailable: + type: boolean + description: Inverse of redis_connected (for legacy monitoring) + required: + - status + - timestamp + - redis_connected + - redis_unavailable + + PriceResponse: + type: object + properties: + asset_code: + $ref: '#/components/schemas/AssetCode' + issuer: + $ref: '#/components/schemas/StellarAddressNullable' + price_usd: + type: number + nullable: true + description: Current USD price (null if unavailable) + source: + type: string + enum: + - stellar_dex + - coingecko + - coinmarketcap + - aggregated + - unavailable + description: Primary data source + fetched_at: + type: string + format: date-time + description: When the price was last fetched + is_stale: + type: boolean + description: Whether the cached price exceeds the stale threshold + stale_warning: + type: string + nullable: true + description: Human-readable stale warning message + sources_attempted: + type: array + items: + type: string + description: List of data sources that were queried + redis_unavailable: + type: boolean + description: Whether Redis was unavailable during this request + required: + - asset_code + - issuer + - price_usd + - source + - fetched_at + - is_stale + - stale_warning + - sources_attempted + - redis_unavailable + + PriceNotFoundResponse: + allOf: + - $ref: '#/components/schemas/ErrorResponse' + - type: object + properties: + asset_code: + $ref: '#/components/schemas/AssetCode' + issuer: + $ref: '#/components/schemas/StellarAddressNullable' + price_usd: + type: number + nullable: true + source: + type: string + example: unavailable + fetched_at: + type: string + format: date-time + is_stale: + type: boolean + example: true + stale_warning: + type: string + sources_attempted: + type: array + items: + type: string + redis_unavailable: + type: boolean + + BatchPriceRequest: + type: object + properties: + assets: + type: array + minItems: 1 + maxItems: 100 + items: + type: object + properties: + asset_code: + $ref: '#/components/schemas/AssetCode' + issuer: + $ref: '#/components/schemas/StellarAddressNullable' + required: + - asset_code + required: + - assets + + CreateWebhookRequest: + type: object + properties: + url: + type: string + format: uri + description: Subscriber endpoint URL (HTTP or HTTPS) + example: https://example.com/webhooks/smartdrop + events: + type: array + minItems: 1 + items: + $ref: '#/components/schemas/WebhookEvent' + description: Events the endpoint wishes to subscribe to + secret: + type: string + minLength: 8 + description: Shared secret used for HMAC-SHA256 signature verification + example: whsec_myverys3cretkey + required: + - url + - events + - secret + + WebhookEndpoint: + type: object + properties: + id: + $ref: '#/components/schemas/WebhookId' + url: + type: string + format: uri + events: + type: array + items: + $ref: '#/components/schemas/WebhookEvent' + active: + type: boolean + description: Whether the endpoint is currently active + secret_preview: + type: string + nullable: true + description: First 4 and last 4 characters of the secret + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + required: + - id + - url + - events + - active + - secret_preview + - created_at + - updated_at + + WebhookEvent: + type: string + enum: + - airdrop.created + - airdrop.executing + - airdrop.completed + - airdrop.failed + - recipient.claimed + - ping + description: SmartDrop lifecycle events + + WebhookDelivery: + type: object + properties: + id: + type: string + pattern: ^dlv_ + endpoint_id: + $ref: '#/components/schemas/WebhookId' + event: + $ref: '#/components/schemas/WebhookEvent' + payload: + type: object + status: + type: string + enum: + - pending + - delivered + - failed + - dead_letter + attempt_count: + type: integer + minimum: 0 + attempts: + type: array + items: + $ref: '#/components/schemas/DeliveryAttempt' + next_retry_at: + type: string + format: date-time + nullable: true + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + required: + - id + - endpoint_id + - event + - payload + - status + - attempt_count + - attempts + - created_at + - updated_at + + DeliveryAttempt: + type: object + properties: + attempt: + type: integer + description: Attempt number (1-based) + ok: + type: boolean + status: + type: string + enum: + - delivered + - failed + response_code: + type: integer + nullable: true + error: + type: string + nullable: true + duration_ms: + type: integer + nullable: true + created_at: + type: string + format: date-time + next_retry_at: + type: string + format: date-time + nullable: true + required: + - attempt + - ok + - status + - response_code + - error + - duration_ms + - created_at + - next_retry_at + + ErrorResponse: + type: object + properties: + error: + type: string + description: Machine-readable error type + message: + type: string + description: Human-readable error description + required: + - error + - message + + ValidationErrorDetail: + type: object + properties: + error: + type: string + example: Validation error + message: + type: string + example: url must be a valid HTTP or HTTPS URL + details: + type: array + items: + type: object + properties: + field: + type: string + issue: + type: string + required: + - error + - message + + RateLimitInfo: + type: object + properties: + error: + type: string + example: Too many requests + message: + type: string + example: Rate limit exceeded. Try again in 42 seconds. + retry_after_seconds: + type: integer + required: + - error + - message + + responses: + ValidationError: + description: Request validation failed + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationErrorDetail' + Unauthorized: + description: Missing or invalid authentication + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + error: Missing or invalid API key + message: Missing or invalid API key + NotFound: + description: Resource not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + error: Webhook endpoint not found + message: Webhook endpoint not found + UnprocessableEntity: + description: Request body is semantically invalid + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + error: Unprocessable entity + message: One or more field values are invalid + RateLimited: + description: Rate limit exceeded + content: + application/json: + schema: + $ref: '#/components/schemas/RateLimitInfo' + example: + error: Too many requests + message: Rate limit exceeded. Try again in 42 seconds. + retry_after_seconds: 42 + headers: + Retry-After: + schema: + type: integer + description: Seconds to wait before retrying + X-RateLimit-Limit: + schema: + type: integer + description: Maximum requests per window + X-RateLimit-Remaining: + schema: + type: integer + description: Remaining requests in current window + X-RateLimit-Reset: + schema: + type: integer + description: Unix timestamp when the window resets + InternalError: + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + error: Internal server error + message: An unexpected error occurred diff --git a/package-lock.json b/package-lock.json index 95a9cf4..47bc973 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,11 +19,14 @@ "multer": "^2.2.0", "node-cron": "^3.0.3", "stellar-sdk": "^11.3.0", + "swagger-ui-express": "^5.0.1", "winston": "^3.14.0", "winston-daily-rotate-file": "^5.0.0", + "yamljs": "^0.3.0", "zod": "^4.4.3" }, "devDependencies": { + "@redocly/cli": "^2.35.1", "jest": "^29.7.0", "supertest": "^7.2.2" }, @@ -62,7 +65,6 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -996,6 +998,28 @@ "@noble/hashes": "^1.1.5" } }, + "node_modules/@redocly/cli": { + "version": "2.35.1", + "resolved": "https://registry.npmjs.org/@redocly/cli/-/cli-2.35.1.tgz", + "integrity": "sha512-8XcUIR6bCI4KmVg6RJyzL3peZhld/tu7oO8WGVaHp43byhcds6ProHlfqEFa+dZA+qA+dUMebgRELVOe5AW4Lg==", + "dev": true, + "license": "MIT", + "bin": { + "openapi": "bin/cli.js", + "redocly": "bin/cli.js" + }, + "engines": { + "node": ">=22.12.0 || >=20.19.0 <21.0.0", + "npm": ">=10" + } + }, + "node_modules/@scarf/scarf": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", + "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", + "hasInstallScript": true, + "license": "Apache-2.0" + }, "node_modules/@sinclair/typebox": { "version": "0.27.10", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", @@ -1293,7 +1317,6 @@ "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, "license": "MIT", "dependencies": { "sprintf-js": "~1.0.2" @@ -1471,7 +1494,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, "license": "MIT" }, "node_modules/bare-addon-resolve": { @@ -1597,7 +1619,6 @@ "version": "1.1.15", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", - "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -1637,7 +1658,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.38", "caniuse-lite": "^1.0.30001799", @@ -1993,7 +2013,6 @@ "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, "license": "MIT" }, "node_modules/concat-stream": { @@ -2722,7 +2741,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true, "license": "ISC" }, "node_modules/fsevents": { @@ -2834,7 +2852,6 @@ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", @@ -3083,7 +3100,6 @@ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, "license": "ISC", "dependencies": { "once": "^1.3.0", @@ -4236,7 +4252,6 @@ "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -4393,7 +4408,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -4521,7 +4535,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -5139,7 +5152,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "dev": true, "license": "BSD-3-Clause" }, "node_modules/stack-trace": { @@ -5397,6 +5409,30 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/swagger-ui-dist": { + "version": "5.32.8", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.8.tgz", + "integrity": "sha512-dgMdWXIgnI4zX4OPhKEdWnlDODbgm8W3AX0Ivn/BBqcUh6xZsBxhZMnvk6DJyRz1BTrj8dPxtarmEGgkz30oyA==", + "license": "Apache-2.0", + "dependencies": { + "@scarf/scarf": "=1.4.0" + } + }, + "node_modules/swagger-ui-express": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-5.0.1.tgz", + "integrity": "sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==", + "license": "MIT", + "dependencies": { + "swagger-ui-dist": ">=5.0.0" + }, + "engines": { + "node": ">= v0.10.32" + }, + "peerDependencies": { + "express": ">=4.0.0 || >=5.0.0-beta" + } + }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -5698,7 +5734,6 @@ "resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz", "integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==", "license": "MIT", - "peer": true, "dependencies": { "@colors/colors": "^1.6.0", "@dabh/diagnostics": "^2.0.8", @@ -5770,7 +5805,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, "license": "ISC" }, "node_modules/write-file-atomic": { @@ -5804,6 +5838,20 @@ "dev": true, "license": "ISC" }, + "node_modules/yamljs": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/yamljs/-/yamljs-0.3.0.tgz", + "integrity": "sha512-C/FsVVhht4iPQYXOInoxUM/1ELSf9EsgKH34FofQOp6hwCPrW4vG4w5++TED3xRUo8gD7l0P1J1dLlDYzODsTQ==", + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "glob": "^7.0.5" + }, + "bin": { + "json2yaml": "bin/json2yaml", + "yaml2json": "bin/yaml2json" + } + }, "node_modules/yargs": { "version": "17.7.3", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", diff --git a/package.json b/package.json index 24f39a7..bf3bf32 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,8 @@ "dependencies": { "axios": "^1.7.0", "cors": "^2.8.5", - "dotenv": "^16.4.5", "csv-parser": "^3.2.1", + "dotenv": "^16.4.5", "envalid": "^8.2.0", "express": "^4.21.0", "helmet": "^8.2.0", @@ -23,11 +23,14 @@ "multer": "^2.2.0", "node-cron": "^3.0.3", "stellar-sdk": "^11.3.0", + "swagger-ui-express": "^5.0.1", "winston": "^3.14.0", - "zod": "^4.4.3", - "winston-daily-rotate-file": "^5.0.0" + "winston-daily-rotate-file": "^5.0.0", + "yamljs": "^0.3.0", + "zod": "^4.4.3" }, "devDependencies": { + "@redocly/cli": "^2.35.1", "jest": "^29.7.0", "supertest": "^7.2.2" } diff --git a/src/index.js b/src/index.js index fbbf102..660e00a 100644 --- a/src/index.js +++ b/src/index.js @@ -12,6 +12,7 @@ const alertsRouter = require('./routes/alerts'); const keysRouter = require('./routes/keys'); const webhooksRouter = require('./routes/webhooks'); const airdropsRouter = require('./routes/airdrops'); +const apiDocsRouter = require('./routes/apiDocs'); const app = express(); let server; @@ -37,6 +38,7 @@ app.use('/api/v1/alerts', requireApiKey()); app.use('/api/v1', alertsRouter); app.use('/api/v1', webhooksRouter); app.use('/api/v1', airdropsRouter); +app.use('/api-docs', apiDocsRouter); app.use((err, req, res, _next) => { const status = err.status || 500; diff --git a/src/routes/apiDocs.js b/src/routes/apiDocs.js new file mode 100644 index 0000000..d660052 --- /dev/null +++ b/src/routes/apiDocs.js @@ -0,0 +1,26 @@ +const express = require('express'); +const path = require('path'); +const YAML = require('yamljs'); +const swaggerUi = require('swagger-ui-express'); +const config = require('../config'); + +const router = express.Router(); + +const openApiDocument = YAML.load(path.join(__dirname, '../../openapi.yaml')); + +router.get('/openapi.yaml', (_req, res) => { + res.type('yaml').sendFile(path.join(__dirname, '../../openapi.yaml')); +}); + +if (config.nodeEnv === 'development') { + router.use('/', swaggerUi.serve, swaggerUi.setup(openApiDocument, { + explorer: true, + customSiteTitle: 'SmartDrop API Docs', + })); +} else { + router.get('/', (_req, res) => { + res.redirect('/api-docs/openapi.yaml'); + }); +} + +module.exports = router; diff --git a/test/api-docs.test.js b/test/api-docs.test.js new file mode 100644 index 0000000..ff5767c --- /dev/null +++ b/test/api-docs.test.js @@ -0,0 +1,97 @@ +'use strict'; + +const path = require('path'); +const fs = require('fs'); +const express = require('express'); +const request = require('supertest'); + +describe('OpenAPI specification', () => { + test('openapi.yaml exists and is valid YAML', () => { + const specPath = path.join(__dirname, '..', 'openapi.yaml'); + expect(fs.existsSync(specPath)).toBe(true); + + const content = fs.readFileSync(specPath, 'utf8'); + expect(content).toContain('openapi: 3.0.3'); + expect(content).toContain('SmartDrop API'); + }); + + test('spec defines all required endpoints from the issue', () => { + const specPath = path.join(__dirname, '..', 'openapi.yaml'); + const content = fs.readFileSync(specPath, 'utf8'); + + expect(content).toContain('/health'); + expect(content).toContain('/api/v1/prices/{asset_code}'); + expect(content).toContain('/api/v1/prices/batch'); + expect(content).toContain('/api/v1/webhooks'); + expect(content).toContain('/api/v1/indexer/status'); + expect(content).toContain('/ws'); + expect(content).toContain('x-draft: true'); + }); + + test('spec includes Bearer security scheme', () => { + const specPath = path.join(__dirname, '..', 'openapi.yaml'); + const content = fs.readFileSync(specPath, 'utf8'); + + expect(content).toContain('BearerAuth'); + expect(content).toContain('bearer'); + }); + + test('spec includes all required error responses', () => { + const specPath = path.join(__dirname, '..', 'openapi.yaml'); + const content = fs.readFileSync(specPath, 'utf8'); + + expect(content).toContain('ValidationError'); + expect(content).toContain('Unauthorized'); + expect(content).toContain('NotFound'); + expect(content).toContain('UnprocessableEntity'); + expect(content).toContain('RateLimited'); + expect(content).toContain('InternalError'); + }); +}); + +describe('Swagger UI', () => { + let app; + + beforeAll(() => { + jest.isolateModules(() => { + const apiDocsRouter = require('../src/routes/apiDocs'); + app = express(); + app.use('/api-docs', apiDocsRouter); + }); + }); + + test('GET /api-docs/openapi.yaml serves the spec file', async () => { + const res = await request(app).get('/api-docs/openapi.yaml'); + + expect(res.status).toBe(200); + expect(res.headers['content-type']).toMatch(/yaml/); + expect(res.text).toContain('openapi: 3.0.3'); + }); + + test('GET /api-docs/openapi.yaml matches the file on disk', async () => { + const res = await request(app).get('/api-docs/openapi.yaml'); + const specPath = path.join(__dirname, '..', 'openapi.yaml'); + const fileContent = fs.readFileSync(specPath, 'utf8'); + + expect(res.text).toBe(fileContent); + }); + + test('Swagger UI HTML is served at /api-docs in development mode', () => { + const NODE_ENV = process.env.NODE_ENV; + process.env.NODE_ENV = 'development'; + + jest.resetModules(); + const devRouter = require('../src/routes/apiDocs'); + const devApp = express(); + devApp.use('/api-docs', devRouter); + + return request(devApp) + .get('/api-docs/') + .expect(200) + .then((res) => { + expect(res.text).toContain('swagger-ui'); + expect(res.text).toContain('SmartDrop API Docs'); + process.env.NODE_ENV = NODE_ENV; + }); + }); +});