From 110524f301a6ccfc6f7ba0f83645c06e06eaa840 Mon Sep 17 00:00:00 2001 From: Miracle656 Date: Sat, 29 Aug 2026 04:57:58 +0100 Subject: [PATCH] test(api): contract test proving OpenAPI matches every implemented route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spec had drifted badly: 13 live REST operations (all eight webhook endpoints, the admin contracts CRUD, PATCH /v1/api-keys/{id}, GET /v1/admin/keys/{id}/usage, POST /v1/contracts/{id}/call) had no spec entry at all, and nothing could catch the next one — route registration was inline in main() with live dependencies, so no test could enumerate the router, and Go's ServeMux cannot list its own patterns. Route registration now lives in routes.go as a single table of (route, lazily-bound handler) pairs: main() registers from it, and the new inventory test reads it through routeInventory() without touching a handler. The same literal is simultaneously the registration source of truth and the test inventory, so route<->spec drift is structurally impossible to reintroduce. Every route is either documented in api/openapi.yaml or carries an explicit exemption reason (/internal/ status, /ws, /graphql — non-REST surfaces documented elsewhere); there is no third state, and a route added without deciding fails the test. Two tests run in the ordinary go test job on every change: - TestEveryRouteIsDocumentedOrExempted fails when a route exists without a spec entry or a spec entry without a route, in either direction. - TestEveryOperationDocumentsStatusCodesAndErrorEnvelope fails when an operation documents no success or no error status, or when a JSON error body is not the canonical ErrorResponse envelope — status codes and error envelopes, not just paths. Deliberate exceptions (the readiness 503 returns check detail; three operations with no error contract by design) are explicit allowlists with reasons. The 13 missing operations are now documented with their real shapes, verbatim from the handlers: webhook camelCase bodies (and the replay endpoint's snake_case), plain-text error responses where that is what the handler emits, nullable list responses, the contract-call endpoint's three success shapes, and admin auth via X-Admin-Key. The admin-contracts handlers moved from a legacy {"error":{"message"}} shape to the canonical envelope as part of being documented; the unmounted usage handlers keep the legacy helper with a note. SDK models are regenerated from the spec (all four generated files); spectral is clean apart from the pre-existing orphaned TokenMetadataResponse schema, and the new Webhooks/Contracts tags are declared. The live e2e suite's operation-coverage assertion defers the 14 newly documented operations via an explicit burn-down list (following its existing getAdminDbStats precedent) — they need stateful fixtures the compose stack does not seed yet; their spec agreement and error contracts are enforced by the static tests above. Closes #513 --- api/openapi.yaml | 1100 ++++++++++++++++- sdk/go/openapi/models_gen.go | 143 +++ .../src/trident_indexer/openapi_models_gen.py | 476 ++++++- sdk/rust/src/openapi_models_gen.rs | 218 ++++ sdk/typescript/src/api-types.gen.ts | 1081 +++++++++++++++- services/api/handlers/admin.go | 6 +- services/api/handlers/contract_test.go | 128 +- services/api/handlers/contracts.go | 35 +- services/api/handlers/usage.go | 11 + .../api/internal/contracttest/live_test.go | 28 + services/api/main.go | 91 +- services/api/routes.go | 236 ++++ services/api/routes_inventory_test.go | 155 +++ services/api/webhooks.go | 60 +- 14 files changed, 3464 insertions(+), 304 deletions(-) create mode 100644 services/api/routes.go create mode 100644 services/api/routes_inventory_test.go diff --git a/api/openapi.yaml b/api/openapi.yaml index b2967447..a9e80ad3 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -776,87 +776,808 @@ paths: $ref: "#/components/responses/TooManyRequestsIPOnly" /v1/api-keys/{id}: + patch: + summary: Update an API key + description: >- + Update an API key's label and/or rate-limit tier (admin only). At + least one of the two fields must be present. A tier change takes + effect immediately — the shared tier cache is invalidated on success. + Only active (non-revoked) keys can be updated. + operationId: updateApiKey + tags: + - API Keys + security: + - AdminKey: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + description: API key ID + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: false + properties: + label: + type: string + description: New display label for the key + rate_limit_tier: + type: string + description: New rate-limit tier name + responses: + "200": + description: The updated API key + content: + application/json: + schema: + $ref: "#/components/schemas/APIKeyResponse" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + description: Admin API key is not configured on the server + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "404": + description: API key not found (or already revoked) + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "413": + description: Request body exceeds the 1 MiB limit + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "429": + $ref: "#/components/responses/TooManyRequestsIPOnly" + "503": + $ref: "#/components/responses/ServiceUnavailable" delete: summary: Delete an API key description: Revoke and delete an API key (admin only) operationId: deleteApiKey tags: - - API Keys - security: - - AdminKey: [] + - API Keys + security: + - AdminKey: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + description: API key ID + responses: + "204": + description: API key deleted + "401": + $ref: "#/components/responses/Unauthorized" + "404": + description: API key not found + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "429": + $ref: "#/components/responses/TooManyRequestsIPOnly" + + /v1/admin/db: + get: + summary: Database admin statistics + description: Returns database and connection pool statistics (admin only) + operationId: getAdminDbStats + tags: + - Admin + security: + - AdminKey: [] + responses: + "200": + description: Database statistics + content: + application/json: + schema: + type: object + additionalProperties: false + required: [pools, stats] + properties: + pools: + type: array + items: + type: object + additionalProperties: true + stats: + type: array + items: + type: object + additionalProperties: true + "401": + $ref: "#/components/responses/Unauthorized" + "429": + $ref: "#/components/responses/TooManyRequestsIPOnly" + "502": + description: >- + The PgBouncer admin console could not be read. It is the upstream + here, so a failure to reach it is a bad-gateway condition rather + than an error in this service (see handlers/admin.go). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "503": + description: >- + The endpoint is not configured — ADMIN_API_KEY or + PGBOUNCER_ADMIN_URL is unset, so no stats source exists. Deploying + without PgBouncer is a supported configuration. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + + /v1/admin/keys/{id}/usage: + get: + summary: API key usage report + description: >- + Per-key usage over a time window (admin only), aggregated from the + audit log. Both `from` and `to` are required RFC 3339 timestamps; the + window may not exceed 31 days. Unknown query parameters are rejected. + operationId: getAdminKeyUsage + tags: + - Admin + security: + - AdminKey: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + description: API key ID + - name: from + in: query + required: true + schema: + type: string + format: date-time + description: Window start (RFC 3339) + - name: to + in: query + required: true + schema: + type: string + format: date-time + description: Window end (RFC 3339); must be >= from, window <= 31 days + responses: + "200": + description: Usage aggregates for the key over the window + content: + application/json: + schema: + $ref: "#/components/schemas/AdminKeyUsageResponse" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "429": + $ref: "#/components/responses/TooManyRequestsIPOnly" + "500": + description: Usage query failed + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "503": + $ref: "#/components/responses/ServiceUnavailable" + + /v1/admin/contracts: + post: + summary: Register a contract for indexing + description: >- + Register (or re-register) a contract in the indexing allowlist (admin + only). Upserts on (contract_id, network) — re-registering an existing + contract updates its label and index_from and still returns 201. + operationId: createAdminContract + tags: + - Admin + security: + - AdminKey: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ContractRegistrationRequest" + responses: + "201": + description: Contract registered (created or updated) + content: + application/json: + schema: + $ref: "#/components/schemas/ContractResponse" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "413": + description: Request body exceeds the 1 MiB limit + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "429": + $ref: "#/components/responses/TooManyRequestsIPOnly" + "500": + description: Registration failed + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "503": + $ref: "#/components/responses/ServiceUnavailable" + get: + summary: List registered contracts + description: >- + Keyset-paginated list of contracts registered for indexing (admin + only). `limit` outside 1..200 silently falls back to the default of + 100; unknown query parameters are ignored. + operationId: listAdminContracts + tags: + - Admin + security: + - AdminKey: [] + parameters: + - name: limit + in: query + required: false + schema: + type: integer + minimum: 1 + maximum: 200 + default: 100 + description: Page size + - name: cursor + in: query + required: false + schema: + type: string + description: Opaque pagination cursor from a previous page's next_cursor + responses: + "200": + description: One page of registered contracts + content: + application/json: + schema: + $ref: "#/components/schemas/ListContractsResponse" + "401": + $ref: "#/components/responses/Unauthorized" + "429": + $ref: "#/components/responses/TooManyRequestsIPOnly" + "500": + description: Listing failed (including a malformed cursor) + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "503": + $ref: "#/components/responses/ServiceUnavailable" + + /v1/admin/contracts/{id}: + delete: + summary: Unregister a contract + description: >- + Remove a contract registration by its registration ID (the `id` + returned at registration, not the contract address). Idempotent — + deleting a non-existent registration still returns 204. + operationId: deleteAdminContract + tags: + - Admin + security: + - AdminKey: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + description: Contract registration ID + responses: + "204": + description: Registration removed (or never existed) + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "429": + $ref: "#/components/responses/TooManyRequestsIPOnly" + "500": + description: Delete failed (including a malformed registration ID) + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "503": + $ref: "#/components/responses/ServiceUnavailable" + + /v1/contracts/{id}/call: + post: + summary: Simulate a read-only contract call + description: >- + Simulate a contract function invocation via Soroban RPC and return + the decoded result. Nothing is submitted to the network. Note the + three success shapes: a simulation-level failure still returns HTTP + 200 with `success: false` and `error` set; a result that cannot be + decoded returns `success: true` with only `raw_xdr`. + operationId: callContract + tags: + - Contracts + parameters: + - name: id + in: path + required: true + schema: + type: string + pattern: "^C[A-Z2-7]{55}$" + description: Contract address (C... strkey, 56 characters) + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ContractCallRequest" + responses: + "200": + description: >- + Simulation completed (including simulation-level failures, which + report success=false with an error message) + content: + application/json: + schema: + $ref: "#/components/schemas/ContractCallResponse" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "413": + description: Request body exceeds the 1 MiB limit + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "429": + $ref: "#/components/responses/RateLimitExceeded" + "502": + description: Soroban RPC call failed or returned no result + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "503": + $ref: "#/components/responses/ServiceUnavailable" + + /v1/webhooks: + get: + summary: List webhook subscriptions + description: >- + Webhook subscriptions owned by the calling API key. Includes each + subscription's signing secret. Returns JSON null (not an empty + array) when the key owns no subscriptions. Webhook endpoints are not + yet part of the frozen v1 surface; some of their error responses are + plain text rather than the canonical error envelope. + operationId: listWebhooks + tags: + - Webhooks + responses: + "200": + description: Subscriptions owned by the calling key (null when none) + content: + application/json: + schema: + type: array + nullable: true + items: + $ref: "#/components/schemas/WebhookSubscription" + "401": + $ref: "#/components/responses/Unauthorized" + "429": + $ref: "#/components/responses/RateLimitExceeded" + "500": + description: Listing failed (plain-text body) + content: + text/plain: + schema: + type: string + "503": + description: Database unavailable (plain-text body) + content: + text/plain: + schema: + type: string + post: + summary: Create a webhook subscription + description: >- + Subscribe a target URL to events from a contract. Target URLs must + be https, resolve publicly, and not point at private, loopback, + link-local, or metadata addresses. The returned secret signs every + delivery. Note the camelCase field names — webhook endpoints predate + the snake_case convention and are not yet part of the frozen v1 + surface. + operationId: createWebhook + tags: + - Webhooks + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/WebhookCreateRequest" + responses: + "201": + description: Subscription created + content: + application/json: + schema: + $ref: "#/components/schemas/WebhookCreateResponse" + "400": + description: Invalid body or target URL (plain-text body) + content: + text/plain: + schema: + type: string + "401": + $ref: "#/components/responses/Unauthorized" + "413": + description: Request body exceeds the 1 MiB limit + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "429": + $ref: "#/components/responses/RateLimitExceeded" + "500": + description: Creation failed (plain-text body) + content: + text/plain: + schema: + type: string + "503": + description: Database unavailable (plain-text body) + content: + text/plain: + schema: + type: string + + /v1/webhooks/{id}: + delete: + summary: Delete a webhook subscription + description: >- + Permanently delete a webhook subscription and stop its deliveries. + operationId: deleteWebhook + tags: + - Webhooks + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + description: Subscription ID + responses: + "204": + description: Subscription deleted + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + description: Subscription not found (plain-text body) + content: + text/plain: + schema: + type: string + "429": + $ref: "#/components/responses/RateLimitExceeded" + "500": + description: Delete failed (plain-text body) + content: + text/plain: + schema: + type: string + "503": + $ref: "#/components/responses/ServiceUnavailable" + + /v1/webhooks/{id}/rotate-secret: + post: + summary: Rotate a webhook signing secret + description: >- + Generate a new primary signing secret for the subscription, demoting + the current one to secondary in the same statement so in-flight + deliveries signed with the old secret still verify. Scoped to the + caller's API key: rotating another tenant's subscription returns 404. + Requires a database-backed API key; legacy env-hash authentication + carries no key identity to scope ownership to. + operationId: rotateWebhookSecret + tags: + - Webhooks + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + description: Subscription ID + responses: + "200": + description: Secret rotated + content: + application/json: + schema: + $ref: "#/components/schemas/WebhookRotateSecretResponse" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + description: >- + No subscription with this ID belongs to the caller's API key + (plain-text body) + content: + text/plain: + schema: + type: string + "429": + $ref: "#/components/responses/RateLimitExceeded" + "500": + description: Rotation failed (plain-text body) + content: + text/plain: + schema: + type: string + "503": + $ref: "#/components/responses/ServiceUnavailable" + + /v1/webhooks/{id}/pause: + patch: + summary: Pause webhook deliveries + description: >- + Pause deliveries for a subscription. Returns the paused status even + when the ID matches no subscription (no existence check). + operationId: pauseWebhook + tags: + - Webhooks + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + description: Subscription ID + responses: + "200": + description: Deliveries paused + content: + application/json: + schema: + $ref: "#/components/schemas/WebhookStatusResponse" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "429": + $ref: "#/components/responses/RateLimitExceeded" + "500": + description: Update failed (plain-text body) + content: + text/plain: + schema: + type: string + "503": + $ref: "#/components/responses/ServiceUnavailable" + + /v1/webhooks/{id}/resume: + patch: + summary: Resume webhook deliveries + description: >- + Resume deliveries for a paused subscription. Returns the resumed + status even when the ID matches no subscription (no existence + check). + operationId: resumeWebhook + tags: + - Webhooks + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + description: Subscription ID + responses: + "200": + description: Deliveries resumed + content: + application/json: + schema: + $ref: "#/components/schemas/WebhookStatusResponse" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "429": + $ref: "#/components/responses/RateLimitExceeded" + "500": + description: Update failed (plain-text body) + content: + text/plain: + schema: + type: string + "503": + $ref: "#/components/responses/ServiceUnavailable" + + /v1/webhooks/{id}/deliveries: + get: + summary: List recent webhook deliveries + description: >- + The 100 most recent delivery attempts for a subscription, newest + first. Returns JSON null (not an empty array) when there are none. + operationId: listWebhookDeliveries + tags: + - Webhooks + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + description: Subscription ID + responses: + "200": + description: Recent delivery attempts (null when none) + content: + application/json: + schema: + type: array + nullable: true + items: + $ref: "#/components/schemas/WebhookDelivery" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "429": + $ref: "#/components/responses/RateLimitExceeded" + "500": + description: Listing failed (plain-text body) + content: + text/plain: + schema: + type: string + "503": + $ref: "#/components/responses/ServiceUnavailable" + + /v1/webhooks/{id}/dead-letters: + get: + summary: List dead-lettered deliveries + description: >- + Deliveries that exhausted their retries (status dead_lettered), up + to 200, newest first. Always an array — empty when there are none. + operationId: listWebhookDeadLetters + tags: + - Webhooks parameters: - name: id in: path required: true schema: type: string - format: uuid - description: API key ID + description: Subscription ID responses: - "204": - description: API key deleted - "401": - $ref: "#/components/responses/Unauthorized" - "404": - description: API key not found + "200": + description: Dead-lettered delivery attempts content: application/json: schema: - $ref: "#/components/schemas/ErrorResponse" + type: array + items: + $ref: "#/components/schemas/WebhookDelivery" + "400": + description: Missing webhook ID (plain-text body) + content: + text/plain: + schema: + type: string + "401": + $ref: "#/components/responses/Unauthorized" "429": - $ref: "#/components/responses/TooManyRequestsIPOnly" + $ref: "#/components/responses/RateLimitExceeded" + "500": + description: Listing failed (plain-text body) + content: + text/plain: + schema: + type: string + "503": + description: Database unavailable (plain-text body) + content: + text/plain: + schema: + type: string - /v1/admin/db: - get: - summary: Database admin statistics - description: Returns database and connection pool statistics (admin only) - operationId: getAdminDbStats + /v1/webhooks/{id}/dead-letters/{deliveryId}/replay: + post: + summary: Replay a dead-lettered delivery + description: >- + Re-attempt one dead-lettered delivery and record the outcome. The + response reports the replay result in snake_case (unlike the other + webhook endpoints). + operationId: replayWebhookDeadLetter tags: - - Admin - security: - - AdminKey: [] + - Webhooks + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + description: Subscription ID + - name: deliveryId + in: path + required: true + schema: + type: string + description: Numeric delivery ID from the dead-letters listing responses: "200": - description: Database statistics + description: Replay attempted; success reflects the delivery outcome content: application/json: schema: - type: object - additionalProperties: false - required: [pools, stats] - properties: - pools: - type: array - items: - type: object - additionalProperties: true - stats: - type: array - items: - type: object - additionalProperties: true + $ref: "#/components/schemas/WebhookReplayResponse" + "400": + description: Missing webhook or delivery ID (plain-text body) + content: + text/plain: + schema: + type: string "401": $ref: "#/components/responses/Unauthorized" + "404": + description: No matching dead-lettered delivery (plain-text body) + content: + text/plain: + schema: + type: string "429": - $ref: "#/components/responses/TooManyRequestsIPOnly" - "502": - description: >- - The PgBouncer admin console could not be read. It is the upstream - here, so a failure to reach it is a bad-gateway condition rather - than an error in this service (see handlers/admin.go). + $ref: "#/components/responses/RateLimitExceeded" + "500": + description: Replay failed to record (plain-text body) content: - application/json: + text/plain: schema: - $ref: "#/components/schemas/ErrorResponse" + type: string "503": - description: >- - The endpoint is not configured — ADMIN_API_KEY or - PGBOUNCER_ADMIN_URL is unset, so no stats source exists. Deploying - without PgBouncer is a supported configuration. + description: Database unavailable (plain-text body) content: - application/json: + text/plain: schema: - $ref: "#/components/schemas/ErrorResponse" + type: string /metrics: get: @@ -1514,6 +2235,73 @@ components: nullable: true description: Opaque cursor for the next page (null if has_more is false). + AdminKeyUsageResponse: + type: object + additionalProperties: false + required: + - api_key_id + - from + - to + - total_requests + - successful_requests + - by_endpoint + properties: + api_key_id: + type: string + format: uuid + from: + type: string + format: date-time + to: + type: string + format: date-time + total_requests: + type: integer + format: int64 + successful_requests: + type: integer + format: int64 + description: Requests with status code < 400 + by_endpoint: + type: array + description: Per-endpoint breakdown; empty when the window has no requests + items: + $ref: "#/components/schemas/EndpointUsage" + EndpointUsage: + type: object + additionalProperties: false + required: + - endpoint + - requests + - avg_duration_ms + properties: + endpoint: + type: string + requests: + type: integer + format: int64 + avg_duration_ms: + type: number + ContractRegistrationRequest: + type: object + additionalProperties: false + required: + - contract_id + properties: + contract_id: + type: string + description: Contract address (C... strkey, 56 characters) + network: + type: string + description: Network scope; omitted or empty means all networks + label: + type: string + description: Human-readable label + index_from: + type: integer + format: int64 + default: 0 + description: Ledger sequence to start indexing from ContractResponse: type: object additionalProperties: false @@ -1543,6 +2331,214 @@ components: type: string format: date-time + ContractCallRequest: + type: object + additionalProperties: false + required: + - function + properties: + function: + type: string + description: Contract function name to invoke + args: + type: array + maxItems: 32 + description: Base64-encoded XDR ScVal arguments, in order + items: + type: string + ContractCallResponse: + type: object + additionalProperties: false + required: + - success + properties: + success: + type: boolean + description: >- + False when the simulation itself reported a failure (still HTTP + 200) + result: + description: Decoded return value; omitted when undecodable or failed + raw_xdr: + type: string + description: Raw base64 XDR of the return value; omitted on failure + error: + type: string + description: Simulation error message; present only when success=false + WebhookSubscription: + type: object + additionalProperties: false + required: + - id + - contractId + - targetUrl + - createdAt + - network + properties: + id: + type: string + format: uuid + apiKeyId: + type: string + description: Omitted when empty + contractId: + type: string + topic0: + type: string + nullable: true + description: Topic filter; omitted when unfiltered + targetUrl: + type: string + secret: + type: string + description: HMAC signing secret for deliveries; omitted when empty + createdAt: + type: string + format: date-time + pausedAt: + type: string + format: date-time + nullable: true + description: Present while deliveries are paused + network: + type: string + WebhookCreateRequest: + type: object + additionalProperties: false + required: + - contractId + - targetUrl + properties: + contractId: + type: string + topic0: + type: string + nullable: true + description: Optional topic filter + targetUrl: + type: string + description: >- + Delivery target; must be https with a publicly resolvable, + non-private host + network: + type: string + default: testnet + WebhookCreateResponse: + type: object + additionalProperties: false + required: + - id + - secret + - targetUrl + - contractId + - network + properties: + id: + type: string + format: uuid + secret: + type: string + description: HMAC signing secret — shown here and in the listing + targetUrl: + type: string + contractId: + type: string + network: + type: string + WebhookDelivery: + type: object + additionalProperties: false + required: + - id + - subscriptionId + - eventId + - attempt + - attempts + - status + - deliveredAt + - success + properties: + id: + type: integer + format: int64 + subscriptionId: + type: string + format: uuid + eventId: + type: string + attempt: + type: integer + attempts: + type: integer + status: + type: string + statusCode: + type: integer + nullable: true + description: HTTP status of the delivery attempt; omitted when none occurred + responseBody: + type: string + description: Omitted when empty + deliveredAt: + type: string + format: date-time + success: + type: boolean + WebhookRotateSecretResponse: + type: object + additionalProperties: false + required: + - id + - secret + - previousSecret + properties: + id: + type: string + format: uuid + secret: + type: string + description: The new primary signing secret (whsec_ prefixed) + previousSecret: + type: string + description: >- + The demoted secret, now serving as secondary during the overlap + window + WebhookStatusResponse: + type: object + additionalProperties: false + required: + - status + properties: + status: + type: string + enum: + - paused + - resumed + WebhookReplayResponse: + type: object + additionalProperties: false + required: + - success + - status + - attempt + - status_code + - response_body + properties: + success: + type: boolean + status: + type: string + enum: + - success + - failed + attempt: + type: integer + status_code: + type: integer + description: 0 when no HTTP response occurred + response_body: + type: string + description: Truncated to 500 characters ErrorResponse: type: object additionalProperties: false @@ -1754,3 +2750,7 @@ tags: description: Admin endpoints - name: Metrics description: Monitoring and metrics + - name: Contracts + description: Contract schema, spec, storage, and simulation endpoints + - name: Webhooks + description: Webhook subscriptions and delivery management (not yet part of the frozen v1 surface) diff --git a/sdk/go/openapi/models_gen.go b/sdk/go/openapi/models_gen.go index ad678af4..c41d167e 100644 --- a/sdk/go/openapi/models_gen.go +++ b/sdk/go/openapi/models_gen.go @@ -21,10 +21,14 @@ func (r *OpenAPIModels) Marshal() ([]byte, error) { } type OpenAPIModels struct { + AdminKeyUsageResponse *AdminKeyUsageResponse `json:"AdminKeyUsageResponse,omitempty"` APIKeyResponse *APIKeyResponse `json:"APIKeyResponse,omitempty"` + ContractCallRequest *ContractCallRequest `json:"ContractCallRequest,omitempty"` + ContractCallResponse *ContractCallResponse `json:"ContractCallResponse,omitempty"` ContractEventFieldSchema *ContractEventFieldSchema `json:"ContractEventFieldSchema,omitempty"` ContractEventSchema *ContractEventSchema `json:"ContractEventSchema,omitempty"` ContractEventSchemaResponse *ContractEventSchemaResponse `json:"ContractEventSchemaResponse,omitempty"` + ContractRegistrationRequest *ContractRegistrationRequest `json:"ContractRegistrationRequest,omitempty"` ContractResponse *ContractResponse `json:"ContractResponse,omitempty"` ContractSpecFunction *ContractSpecFunction `json:"ContractSpecFunction,omitempty"` ContractSpecResponse *ContractSpecResponse `json:"ContractSpecResponse,omitempty"` @@ -33,6 +37,7 @@ type OpenAPIModels struct { ContractStorageHistoryResponse *ContractStorageHistoryResponse `json:"ContractStorageHistoryResponse,omitempty"` ContractStorageResponse *ContractStorageResponse `json:"ContractStorageResponse,omitempty"` ContractStorageValue *ContractStorageValue `json:"ContractStorageValue,omitempty"` + EndpointUsage *EndpointUsage `json:"EndpointUsage,omitempty"` ErrorResponse *ErrorResponse `json:"ErrorResponse,omitempty"` EventListResponse *EventListResponse `json:"EventListResponse,omitempty"` IndexerStatsResponse *IndexerStatsResponse `json:"IndexerStatsResponse,omitempty"` @@ -44,6 +49,13 @@ type OpenAPIModels struct { SorobanEvent *SorobanEvent `json:"SorobanEvent,omitempty"` TokenMetadataResponse *TokenMetadataResponse `json:"TokenMetadataResponse,omitempty"` VersionResponse *VersionResponse `json:"VersionResponse,omitempty"` + WebhookCreateRequest *WebhookCreateRequest `json:"WebhookCreateRequest,omitempty"` + WebhookCreateResponse *WebhookCreateResponse `json:"WebhookCreateResponse,omitempty"` + WebhookDelivery *WebhookDelivery `json:"WebhookDelivery,omitempty"` + WebhookReplayResponse *WebhookReplayResponse `json:"WebhookReplayResponse,omitempty"` + WebhookRotateSecretResponse *WebhookRotateSecretResponse `json:"WebhookRotateSecretResponse,omitempty"` + WebhookStatusResponse *WebhookStatusResponse `json:"WebhookStatusResponse,omitempty"` + WebhookSubscription *WebhookSubscription `json:"WebhookSubscription,omitempty"` } type APIKeyResponse struct { @@ -61,6 +73,41 @@ type APIKeyResponse struct { RevokedAt *time.Time `json:"revoked_at,omitempty"` } +type AdminKeyUsageResponse struct { + APIKeyID string `json:"api_key_id"` + // Per-endpoint breakdown; empty when the window has no requests + ByEndpoint []EndpointUsage `json:"by_endpoint"` + From time.Time `json:"from"` + // Requests with status code < 400 + SuccessfulRequests int64 `json:"successful_requests"` + To time.Time `json:"to"` + TotalRequests int64 `json:"total_requests"` +} + +type EndpointUsage struct { + AvgDurationMS float64 `json:"avg_duration_ms"` + Endpoint string `json:"endpoint"` + Requests int64 `json:"requests"` +} + +type ContractCallRequest struct { + // Base64-encoded XDR ScVal arguments, in order + Args []string `json:"args,omitempty"` + // Contract function name to invoke + Function string `json:"function"` +} + +type ContractCallResponse struct { + // Simulation error message; present only when success=false + Error *string `json:"error,omitempty"` + // Raw base64 XDR of the return value; omitted on failure + RawXdr *string `json:"raw_xdr,omitempty"` + // Decoded return value; omitted when undecodable or failed + Result interface{} `json:"result"` + // False when the simulation itself reported a failure (still HTTP 200) + Success bool `json:"success"` +} + type ContractEventFieldSchema struct { // Stable field name for this event payload position or property Name string `json:"name"` @@ -86,6 +133,17 @@ type ContractEventSchemaResponse struct { Network Network `json:"network"` } +type ContractRegistrationRequest struct { + // Contract address (C... strkey, 56 characters) + ContractID string `json:"contract_id"` + // Ledger sequence to start indexing from + IndexFrom *int64 `json:"index_from,omitempty"` + // Human-readable label + Label *string `json:"label,omitempty"` + // Network scope; omitted or empty means all networks + Network *string `json:"network,omitempty"` +} + type ContractResponse struct { // Stellar contract id (C... strkey). ContractID string `json:"contract_id"` @@ -334,6 +392,77 @@ type VersionResponse struct { Version string `json:"version"` } +type WebhookCreateRequest struct { + ContractID string `json:"contractId"` + Network *string `json:"network,omitempty"` + // Delivery target; must be https with a publicly resolvable, non-private host + TargetURL string `json:"targetUrl"` + // Optional topic filter + Topic0 *string `json:"topic0,omitempty"` +} + +type WebhookCreateResponse struct { + ContractID string `json:"contractId"` + ID string `json:"id"` + Network string `json:"network"` + // HMAC signing secret — shown here and in the listing + Secret string `json:"secret"` + TargetURL string `json:"targetUrl"` +} + +type WebhookDelivery struct { + Attempt int64 `json:"attempt"` + Attempts int64 `json:"attempts"` + DeliveredAt time.Time `json:"deliveredAt"` + EventID string `json:"eventId"` + ID int64 `json:"id"` + // Omitted when empty + ResponseBody *string `json:"responseBody,omitempty"` + Status string `json:"status"` + // HTTP status of the delivery attempt; omitted when none occurred + StatusCode *int64 `json:"statusCode,omitempty"` + SubscriptionID string `json:"subscriptionId"` + Success bool `json:"success"` +} + +type WebhookReplayResponse struct { + Attempt int64 `json:"attempt"` + // Truncated to 500 characters + ResponseBody string `json:"response_body"` + Status WebhookReplayResponseStatus `json:"status"` + // 0 when no HTTP response occurred + StatusCode int64 `json:"status_code"` + Success bool `json:"success"` +} + +type WebhookRotateSecretResponse struct { + ID string `json:"id"` + // The demoted secret, now serving as secondary during the overlap window + PreviousSecret string `json:"previousSecret"` + // The new primary signing secret (whsec_ prefixed) + Secret string `json:"secret"` +} + +type WebhookStatusResponse struct { + Status WebhookStatusResponseStatus `json:"status"` +} + +type WebhookSubscription struct { + // Omitted when empty + APIKeyID *string `json:"apiKeyId,omitempty"` + ContractID string `json:"contractId"` + CreatedAt time.Time `json:"createdAt"` + ID string `json:"id"` + Network string `json:"network"` + // Present while deliveries are paused + PausedAt *time.Time `json:"pausedAt,omitempty"` + // HMAC signing secret for deliveries; omitted when empty + Secret *string `json:"secret,omitempty"` + TargetURL string `json:"targetUrl"` + // Topic filter; omitted when unfiltered + Topic0 *string `json:"topic0,omitempty"` +} + // Network queried type Network string @@ -374,3 +503,17 @@ const ( Degraded ReadyResponseStatus = "degraded" FluffyOk ReadyResponseStatus = "ok" ) + +type WebhookReplayResponseStatus string + +const ( + Failed WebhookReplayResponseStatus = "failed" + Success WebhookReplayResponseStatus = "success" +) + +type WebhookStatusResponseStatus string + +const ( + Paused WebhookStatusResponseStatus = "paused" + Resumed WebhookStatusResponseStatus = "resumed" +) diff --git a/sdk/python/src/trident_indexer/openapi_models_gen.py b/sdk/python/src/trident_indexer/openapi_models_gen.py index 2290970c..430a5981 100644 --- a/sdk/python/src/trident_indexer/openapi_models_gen.py +++ b/sdk/python/src/trident_indexer/openapi_models_gen.py @@ -1,13 +1,18 @@ -from enum import Enum from dataclasses import dataclass +from typing import Any, TypeVar, Callable, Type, cast from uuid import UUID -from typing import Any, TypeVar, Type, Callable, cast +from enum import Enum T = TypeVar("T") EnumT = TypeVar("EnumT", bound=Enum) +def from_float(x: Any) -> float: + assert isinstance(x, (float, int)) and not isinstance(x, bool) + return float(x) + + def from_str(x: Any) -> str: assert isinstance(x, str) return x @@ -18,6 +23,21 @@ def from_int(x: Any) -> int: return x +def to_float(x: Any) -> float: + assert isinstance(x, (int, float)) + return x + + +def from_list(f: Callable[[Any], T], x: Any) -> list[T]: + assert isinstance(x, list) + return [f(y) for y in x] + + +def to_class(c: Type[T], x: Any) -> dict: + assert isinstance(x, c) + return cast(Any, x).to_dict() + + def from_none(x: Any) -> Any: assert x is None return x @@ -37,29 +57,66 @@ def to_enum(c: Type[EnumT], x: Any) -> EnumT: return x.value -def from_list(f: Callable[[Any], T], x: Any) -> list[T]: - assert isinstance(x, list) - return [f(y) for y in x] +def from_bool(x: Any) -> bool: + assert isinstance(x, bool) + return x -def to_class(c: Type[T], x: Any) -> dict: - assert isinstance(x, c) - return cast(Any, x).to_dict() +@dataclass +class EndpointUsage: + avg_duration_ms: float + endpoint: str + requests: int + @staticmethod + def from_dict(obj: Any) -> 'EndpointUsage': + assert isinstance(obj, dict) + avg_duration_ms = from_float(obj.get("avg_duration_ms")) + endpoint = from_str(obj.get("endpoint")) + requests = from_int(obj.get("requests")) + return EndpointUsage(avg_duration_ms, endpoint, requests) -def from_bool(x: Any) -> bool: - assert isinstance(x, bool) - return x + def to_dict(self) -> dict: + result: dict = {} + result["avg_duration_ms"] = to_float(self.avg_duration_ms) + result["endpoint"] = from_str(self.endpoint) + result["requests"] = from_int(self.requests) + return result -def from_float(x: Any) -> float: - assert isinstance(x, (float, int)) and not isinstance(x, bool) - return float(x) +@dataclass +class AdminKeyUsageResponse: + api_key_id: UUID + by_endpoint: list[EndpointUsage] + """Per-endpoint breakdown; empty when the window has no requests""" + admin_key_usage_response_from: str + successful_requests: int + """Requests with status code < 400""" -def to_float(x: Any) -> float: - assert isinstance(x, (int, float)) - return x + to: str + total_requests: int + + @staticmethod + def from_dict(obj: Any) -> 'AdminKeyUsageResponse': + assert isinstance(obj, dict) + api_key_id = UUID(obj.get("api_key_id")) + by_endpoint = from_list(EndpointUsage.from_dict, obj.get("by_endpoint")) + admin_key_usage_response_from = from_str(obj.get("from")) + successful_requests = from_int(obj.get("successful_requests")) + to = from_str(obj.get("to")) + total_requests = from_int(obj.get("total_requests")) + return AdminKeyUsageResponse(api_key_id, by_endpoint, admin_key_usage_response_from, successful_requests, to, total_requests) + + def to_dict(self) -> dict: + result: dict = {} + result["api_key_id"] = str(self.api_key_id) + result["by_endpoint"] = from_list(lambda x: to_class(EndpointUsage, x), self.by_endpoint) + result["from"] = from_str(self.admin_key_usage_response_from) + result["successful_requests"] = from_int(self.successful_requests) + result["to"] = from_str(self.to) + result["total_requests"] = from_int(self.total_requests) + return result class Network(Enum): @@ -120,6 +177,64 @@ def to_dict(self) -> dict: return result +@dataclass +class ContractCallRequest: + function: str + """Contract function name to invoke""" + + args: list[str] | None = None + """Base64-encoded XDR ScVal arguments, in order""" + + @staticmethod + def from_dict(obj: Any) -> 'ContractCallRequest': + assert isinstance(obj, dict) + function = from_str(obj.get("function")) + args = from_union([lambda x: from_list(from_str, x), from_none], obj.get("args")) + return ContractCallRequest(function, args) + + def to_dict(self) -> dict: + result: dict = {} + result["function"] = from_str(self.function) + if self.args is not None: + result["args"] = from_union([lambda x: from_list(from_str, x), from_none], self.args) + return result + + +@dataclass +class ContractCallResponse: + success: bool + """False when the simulation itself reported a failure (still HTTP 200)""" + + error: str | None = None + """Simulation error message; present only when success=false""" + + raw_xdr: str | None = None + """Raw base64 XDR of the return value; omitted on failure""" + + result: Any = None + """Decoded return value; omitted when undecodable or failed""" + + @staticmethod + def from_dict(obj: Any) -> 'ContractCallResponse': + assert isinstance(obj, dict) + success = from_bool(obj.get("success")) + error = from_union([from_str, from_none], obj.get("error")) + raw_xdr = from_union([from_str, from_none], obj.get("raw_xdr")) + result = obj.get("result") + return ContractCallResponse(success, error, raw_xdr, result) + + def to_dict(self) -> dict: + result: dict = {} + result["success"] = from_bool(self.success) + if self.error is not None: + result["error"] = from_union([from_str, from_none], self.error) + if self.raw_xdr is not None: + result["raw_xdr"] = from_union([from_str, from_none], self.raw_xdr) + if self.result is not None: + result["result"] = self.result + return result + + @dataclass class ContractEventFieldSchema: name: str @@ -196,6 +311,41 @@ def to_dict(self) -> dict: return result +@dataclass +class ContractRegistrationRequest: + contract_id: str + """Contract address (C... strkey, 56 characters)""" + + index_from: int | None = None + """Ledger sequence to start indexing from""" + + label: str | None = None + """Human-readable label""" + + network: str | None = None + """Network scope; omitted or empty means all networks""" + + @staticmethod + def from_dict(obj: Any) -> 'ContractRegistrationRequest': + assert isinstance(obj, dict) + contract_id = from_str(obj.get("contract_id")) + index_from = from_union([from_int, from_none], obj.get("index_from")) + label = from_union([from_str, from_none], obj.get("label")) + network = from_union([from_str, from_none], obj.get("network")) + return ContractRegistrationRequest(contract_id, index_from, label, network) + + def to_dict(self) -> dict: + result: dict = {} + result["contract_id"] = from_str(self.contract_id) + if self.index_from is not None: + result["index_from"] = from_union([from_int, from_none], self.index_from) + if self.label is not None: + result["label"] = from_union([from_str, from_none], self.label) + if self.network is not None: + result["network"] = from_union([from_str, from_none], self.network) + return result + + @dataclass class ContractResponse: contract_id: str @@ -940,12 +1090,258 @@ def to_dict(self) -> dict: return result +@dataclass +class WebhookCreateRequest: + contract_id: str + target_url: str + """Delivery target; must be https with a publicly resolvable, non-private host""" + + network: str | None = None + topic0: str | None = None + """Optional topic filter""" + + @staticmethod + def from_dict(obj: Any) -> 'WebhookCreateRequest': + assert isinstance(obj, dict) + contract_id = from_str(obj.get("contractId")) + target_url = from_str(obj.get("targetUrl")) + network = from_union([from_str, from_none], obj.get("network")) + topic0 = from_union([from_str, from_none], obj.get("topic0")) + return WebhookCreateRequest(contract_id, target_url, network, topic0) + + def to_dict(self) -> dict: + result: dict = {} + result["contractId"] = from_str(self.contract_id) + result["targetUrl"] = from_str(self.target_url) + if self.network is not None: + result["network"] = from_union([from_str, from_none], self.network) + if self.topic0 is not None: + result["topic0"] = from_union([from_str, from_none], self.topic0) + return result + + +@dataclass +class WebhookCreateResponse: + contract_id: str + id: UUID + network: str + secret: str + """HMAC signing secret — shown here and in the listing""" + + target_url: str + + @staticmethod + def from_dict(obj: Any) -> 'WebhookCreateResponse': + assert isinstance(obj, dict) + contract_id = from_str(obj.get("contractId")) + id = UUID(obj.get("id")) + network = from_str(obj.get("network")) + secret = from_str(obj.get("secret")) + target_url = from_str(obj.get("targetUrl")) + return WebhookCreateResponse(contract_id, id, network, secret, target_url) + + def to_dict(self) -> dict: + result: dict = {} + result["contractId"] = from_str(self.contract_id) + result["id"] = str(self.id) + result["network"] = from_str(self.network) + result["secret"] = from_str(self.secret) + result["targetUrl"] = from_str(self.target_url) + return result + + +@dataclass +class WebhookDelivery: + attempt: int + attempts: int + delivered_at: str + event_id: str + id: int + status: str + subscription_id: UUID + success: bool + response_body: str | None = None + """Omitted when empty""" + + status_code: int | None = None + """HTTP status of the delivery attempt; omitted when none occurred""" + + @staticmethod + def from_dict(obj: Any) -> 'WebhookDelivery': + assert isinstance(obj, dict) + attempt = from_int(obj.get("attempt")) + attempts = from_int(obj.get("attempts")) + delivered_at = from_str(obj.get("deliveredAt")) + event_id = from_str(obj.get("eventId")) + id = from_int(obj.get("id")) + status = from_str(obj.get("status")) + subscription_id = UUID(obj.get("subscriptionId")) + success = from_bool(obj.get("success")) + response_body = from_union([from_str, from_none], obj.get("responseBody")) + status_code = from_union([from_int, from_none], obj.get("statusCode")) + return WebhookDelivery(attempt, attempts, delivered_at, event_id, id, status, subscription_id, success, response_body, status_code) + + def to_dict(self) -> dict: + result: dict = {} + result["attempt"] = from_int(self.attempt) + result["attempts"] = from_int(self.attempts) + result["deliveredAt"] = from_str(self.delivered_at) + result["eventId"] = from_str(self.event_id) + result["id"] = from_int(self.id) + result["status"] = from_str(self.status) + result["subscriptionId"] = str(self.subscription_id) + result["success"] = from_bool(self.success) + if self.response_body is not None: + result["responseBody"] = from_union([from_str, from_none], self.response_body) + if self.status_code is not None: + result["statusCode"] = from_union([from_int, from_none], self.status_code) + return result + + +class WebhookReplayResponseStatus(Enum): + FAILED = "failed" + SUCCESS = "success" + + +@dataclass +class WebhookReplayResponse: + attempt: int + response_body: str + """Truncated to 500 characters""" + + status: WebhookReplayResponseStatus + status_code: int + """0 when no HTTP response occurred""" + + success: bool + + @staticmethod + def from_dict(obj: Any) -> 'WebhookReplayResponse': + assert isinstance(obj, dict) + attempt = from_int(obj.get("attempt")) + response_body = from_str(obj.get("response_body")) + status = WebhookReplayResponseStatus(obj.get("status")) + status_code = from_int(obj.get("status_code")) + success = from_bool(obj.get("success")) + return WebhookReplayResponse(attempt, response_body, status, status_code, success) + + def to_dict(self) -> dict: + result: dict = {} + result["attempt"] = from_int(self.attempt) + result["response_body"] = from_str(self.response_body) + result["status"] = to_enum(WebhookReplayResponseStatus, self.status) + result["status_code"] = from_int(self.status_code) + result["success"] = from_bool(self.success) + return result + + +@dataclass +class WebhookRotateSecretResponse: + id: UUID + previous_secret: str + """The demoted secret, now serving as secondary during the overlap window""" + + secret: str + """The new primary signing secret (whsec_ prefixed)""" + + @staticmethod + def from_dict(obj: Any) -> 'WebhookRotateSecretResponse': + assert isinstance(obj, dict) + id = UUID(obj.get("id")) + previous_secret = from_str(obj.get("previousSecret")) + secret = from_str(obj.get("secret")) + return WebhookRotateSecretResponse(id, previous_secret, secret) + + def to_dict(self) -> dict: + result: dict = {} + result["id"] = str(self.id) + result["previousSecret"] = from_str(self.previous_secret) + result["secret"] = from_str(self.secret) + return result + + +class WebhookStatusResponseStatus(Enum): + PAUSED = "paused" + RESUMED = "resumed" + + +@dataclass +class WebhookStatusResponse: + status: WebhookStatusResponseStatus + + @staticmethod + def from_dict(obj: Any) -> 'WebhookStatusResponse': + assert isinstance(obj, dict) + status = WebhookStatusResponseStatus(obj.get("status")) + return WebhookStatusResponse(status) + + def to_dict(self) -> dict: + result: dict = {} + result["status"] = to_enum(WebhookStatusResponseStatus, self.status) + return result + + +@dataclass +class WebhookSubscription: + contract_id: str + created_at: str + id: UUID + network: str + target_url: str + api_key_id: str | None = None + """Omitted when empty""" + + paused_at: str | None = None + """Present while deliveries are paused""" + + secret: str | None = None + """HMAC signing secret for deliveries; omitted when empty""" + + topic0: str | None = None + """Topic filter; omitted when unfiltered""" + + @staticmethod + def from_dict(obj: Any) -> 'WebhookSubscription': + assert isinstance(obj, dict) + contract_id = from_str(obj.get("contractId")) + created_at = from_str(obj.get("createdAt")) + id = UUID(obj.get("id")) + network = from_str(obj.get("network")) + target_url = from_str(obj.get("targetUrl")) + api_key_id = from_union([from_str, from_none], obj.get("apiKeyId")) + paused_at = from_union([from_str, from_none], obj.get("pausedAt")) + secret = from_union([from_str, from_none], obj.get("secret")) + topic0 = from_union([from_str, from_none], obj.get("topic0")) + return WebhookSubscription(contract_id, created_at, id, network, target_url, api_key_id, paused_at, secret, topic0) + + def to_dict(self) -> dict: + result: dict = {} + result["contractId"] = from_str(self.contract_id) + result["createdAt"] = from_str(self.created_at) + result["id"] = str(self.id) + result["network"] = from_str(self.network) + result["targetUrl"] = from_str(self.target_url) + if self.api_key_id is not None: + result["apiKeyId"] = from_union([from_str, from_none], self.api_key_id) + if self.paused_at is not None: + result["pausedAt"] = from_union([from_str, from_none], self.paused_at) + if self.secret is not None: + result["secret"] = from_union([from_str, from_none], self.secret) + if self.topic0 is not None: + result["topic0"] = from_union([from_str, from_none], self.topic0) + return result + + @dataclass class OpenAPIModels: + admin_key_usage_response: AdminKeyUsageResponse | None = None api_key_response: APIKeyResponse | None = None + contract_call_request: ContractCallRequest | None = None + contract_call_response: ContractCallResponse | None = None contract_event_field_schema: ContractEventFieldSchema | None = None contract_event_schema: ContractEventSchema | None = None contract_event_schema_response: ContractEventSchemaResponse | None = None + contract_registration_request: ContractRegistrationRequest | None = None contract_response: ContractResponse | None = None contract_spec_function: ContractSpecFunction | None = None contract_spec_response: ContractSpecResponse | None = None @@ -954,6 +1350,7 @@ class OpenAPIModels: contract_storage_history_response: ContractStorageHistoryResponse | None = None contract_storage_response: ContractStorageResponse | None = None contract_storage_value: ContractStorageValue | None = None + endpoint_usage: EndpointUsage | None = None error_response: ErrorResponse | None = None event_list_response: EventListResponse | None = None indexer_stats_response: IndexerStatsResponse | None = None @@ -965,14 +1362,25 @@ class OpenAPIModels: soroban_event: SorobanEvent | None = None token_metadata_response: TokenMetadataResponse | None = None version_response: VersionResponse | None = None + webhook_create_request: WebhookCreateRequest | None = None + webhook_create_response: WebhookCreateResponse | None = None + webhook_delivery: WebhookDelivery | None = None + webhook_replay_response: WebhookReplayResponse | None = None + webhook_rotate_secret_response: WebhookRotateSecretResponse | None = None + webhook_status_response: WebhookStatusResponse | None = None + webhook_subscription: WebhookSubscription | None = None @staticmethod def from_dict(obj: Any) -> 'OpenAPIModels': assert isinstance(obj, dict) + admin_key_usage_response = from_union([AdminKeyUsageResponse.from_dict, from_none], obj.get("AdminKeyUsageResponse")) api_key_response = from_union([APIKeyResponse.from_dict, from_none], obj.get("APIKeyResponse")) + contract_call_request = from_union([ContractCallRequest.from_dict, from_none], obj.get("ContractCallRequest")) + contract_call_response = from_union([ContractCallResponse.from_dict, from_none], obj.get("ContractCallResponse")) contract_event_field_schema = from_union([ContractEventFieldSchema.from_dict, from_none], obj.get("ContractEventFieldSchema")) contract_event_schema = from_union([ContractEventSchema.from_dict, from_none], obj.get("ContractEventSchema")) contract_event_schema_response = from_union([ContractEventSchemaResponse.from_dict, from_none], obj.get("ContractEventSchemaResponse")) + contract_registration_request = from_union([ContractRegistrationRequest.from_dict, from_none], obj.get("ContractRegistrationRequest")) contract_response = from_union([ContractResponse.from_dict, from_none], obj.get("ContractResponse")) contract_spec_function = from_union([ContractSpecFunction.from_dict, from_none], obj.get("ContractSpecFunction")) contract_spec_response = from_union([ContractSpecResponse.from_dict, from_none], obj.get("ContractSpecResponse")) @@ -981,6 +1389,7 @@ def from_dict(obj: Any) -> 'OpenAPIModels': contract_storage_history_response = from_union([ContractStorageHistoryResponse.from_dict, from_none], obj.get("ContractStorageHistoryResponse")) contract_storage_response = from_union([ContractStorageResponse.from_dict, from_none], obj.get("ContractStorageResponse")) contract_storage_value = from_union([ContractStorageValue.from_dict, from_none], obj.get("ContractStorageValue")) + endpoint_usage = from_union([EndpointUsage.from_dict, from_none], obj.get("EndpointUsage")) error_response = from_union([ErrorResponse.from_dict, from_none], obj.get("ErrorResponse")) event_list_response = from_union([EventListResponse.from_dict, from_none], obj.get("EventListResponse")) indexer_stats_response = from_union([IndexerStatsResponse.from_dict, from_none], obj.get("IndexerStatsResponse")) @@ -992,18 +1401,33 @@ def from_dict(obj: Any) -> 'OpenAPIModels': soroban_event = from_union([SorobanEvent.from_dict, from_none], obj.get("SorobanEvent")) token_metadata_response = from_union([TokenMetadataResponse.from_dict, from_none], obj.get("TokenMetadataResponse")) version_response = from_union([VersionResponse.from_dict, from_none], obj.get("VersionResponse")) - return OpenAPIModels(api_key_response, contract_event_field_schema, contract_event_schema, contract_event_schema_response, contract_response, contract_spec_function, contract_spec_response, contract_stats, contract_stats_response, contract_storage_history_response, contract_storage_response, contract_storage_value, error_response, event_list_response, indexer_stats_response, list_api_keys_response, list_contracts_response, liveness_response, ready_checks, ready_response, soroban_event, token_metadata_response, version_response) + webhook_create_request = from_union([WebhookCreateRequest.from_dict, from_none], obj.get("WebhookCreateRequest")) + webhook_create_response = from_union([WebhookCreateResponse.from_dict, from_none], obj.get("WebhookCreateResponse")) + webhook_delivery = from_union([WebhookDelivery.from_dict, from_none], obj.get("WebhookDelivery")) + webhook_replay_response = from_union([WebhookReplayResponse.from_dict, from_none], obj.get("WebhookReplayResponse")) + webhook_rotate_secret_response = from_union([WebhookRotateSecretResponse.from_dict, from_none], obj.get("WebhookRotateSecretResponse")) + webhook_status_response = from_union([WebhookStatusResponse.from_dict, from_none], obj.get("WebhookStatusResponse")) + webhook_subscription = from_union([WebhookSubscription.from_dict, from_none], obj.get("WebhookSubscription")) + return OpenAPIModels(admin_key_usage_response, api_key_response, contract_call_request, contract_call_response, contract_event_field_schema, contract_event_schema, contract_event_schema_response, contract_registration_request, contract_response, contract_spec_function, contract_spec_response, contract_stats, contract_stats_response, contract_storage_history_response, contract_storage_response, contract_storage_value, endpoint_usage, error_response, event_list_response, indexer_stats_response, list_api_keys_response, list_contracts_response, liveness_response, ready_checks, ready_response, soroban_event, token_metadata_response, version_response, webhook_create_request, webhook_create_response, webhook_delivery, webhook_replay_response, webhook_rotate_secret_response, webhook_status_response, webhook_subscription) def to_dict(self) -> dict: result: dict = {} + if self.admin_key_usage_response is not None: + result["AdminKeyUsageResponse"] = from_union([lambda x: to_class(AdminKeyUsageResponse, x), from_none], self.admin_key_usage_response) if self.api_key_response is not None: result["APIKeyResponse"] = from_union([lambda x: to_class(APIKeyResponse, x), from_none], self.api_key_response) + if self.contract_call_request is not None: + result["ContractCallRequest"] = from_union([lambda x: to_class(ContractCallRequest, x), from_none], self.contract_call_request) + if self.contract_call_response is not None: + result["ContractCallResponse"] = from_union([lambda x: to_class(ContractCallResponse, x), from_none], self.contract_call_response) if self.contract_event_field_schema is not None: result["ContractEventFieldSchema"] = from_union([lambda x: to_class(ContractEventFieldSchema, x), from_none], self.contract_event_field_schema) if self.contract_event_schema is not None: result["ContractEventSchema"] = from_union([lambda x: to_class(ContractEventSchema, x), from_none], self.contract_event_schema) if self.contract_event_schema_response is not None: result["ContractEventSchemaResponse"] = from_union([lambda x: to_class(ContractEventSchemaResponse, x), from_none], self.contract_event_schema_response) + if self.contract_registration_request is not None: + result["ContractRegistrationRequest"] = from_union([lambda x: to_class(ContractRegistrationRequest, x), from_none], self.contract_registration_request) if self.contract_response is not None: result["ContractResponse"] = from_union([lambda x: to_class(ContractResponse, x), from_none], self.contract_response) if self.contract_spec_function is not None: @@ -1020,6 +1444,8 @@ def to_dict(self) -> dict: result["ContractStorageResponse"] = from_union([lambda x: to_class(ContractStorageResponse, x), from_none], self.contract_storage_response) if self.contract_storage_value is not None: result["ContractStorageValue"] = from_union([lambda x: to_class(ContractStorageValue, x), from_none], self.contract_storage_value) + if self.endpoint_usage is not None: + result["EndpointUsage"] = from_union([lambda x: to_class(EndpointUsage, x), from_none], self.endpoint_usage) if self.error_response is not None: result["ErrorResponse"] = from_union([lambda x: to_class(ErrorResponse, x), from_none], self.error_response) if self.event_list_response is not None: @@ -1042,6 +1468,20 @@ def to_dict(self) -> dict: result["TokenMetadataResponse"] = from_union([lambda x: to_class(TokenMetadataResponse, x), from_none], self.token_metadata_response) if self.version_response is not None: result["VersionResponse"] = from_union([lambda x: to_class(VersionResponse, x), from_none], self.version_response) + if self.webhook_create_request is not None: + result["WebhookCreateRequest"] = from_union([lambda x: to_class(WebhookCreateRequest, x), from_none], self.webhook_create_request) + if self.webhook_create_response is not None: + result["WebhookCreateResponse"] = from_union([lambda x: to_class(WebhookCreateResponse, x), from_none], self.webhook_create_response) + if self.webhook_delivery is not None: + result["WebhookDelivery"] = from_union([lambda x: to_class(WebhookDelivery, x), from_none], self.webhook_delivery) + if self.webhook_replay_response is not None: + result["WebhookReplayResponse"] = from_union([lambda x: to_class(WebhookReplayResponse, x), from_none], self.webhook_replay_response) + if self.webhook_rotate_secret_response is not None: + result["WebhookRotateSecretResponse"] = from_union([lambda x: to_class(WebhookRotateSecretResponse, x), from_none], self.webhook_rotate_secret_response) + if self.webhook_status_response is not None: + result["WebhookStatusResponse"] = from_union([lambda x: to_class(WebhookStatusResponse, x), from_none], self.webhook_status_response) + if self.webhook_subscription is not None: + result["WebhookSubscription"] = from_union([lambda x: to_class(WebhookSubscription, x), from_none], self.webhook_subscription) return result diff --git a/sdk/rust/src/openapi_models_gen.rs b/sdk/rust/src/openapi_models_gen.rs index 67669a2f..69d34abd 100644 --- a/sdk/rust/src/openapi_models_gen.rs +++ b/sdk/rust/src/openapi_models_gen.rs @@ -16,15 +16,23 @@ use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "PascalCase")] pub struct OpenApiModels { + pub admin_key_usage_response: Option, + #[serde(rename = "APIKeyResponse")] pub api_key_response: Option, + pub contract_call_request: Option, + + pub contract_call_response: Option, + pub contract_event_field_schema: Option, pub contract_event_schema: Option, pub contract_event_schema_response: Option, + pub contract_registration_request: Option, + pub contract_response: Option, pub contract_spec_function: Option, @@ -41,6 +49,8 @@ pub struct OpenApiModels { pub contract_storage_value: Option, + pub endpoint_usage: Option, + pub error_response: Option, pub event_list_response: Option, @@ -63,6 +73,46 @@ pub struct OpenApiModels { pub token_metadata_response: Option, pub version_response: Option, + + pub webhook_create_request: Option, + + pub webhook_create_response: Option, + + pub webhook_delivery: Option, + + pub webhook_replay_response: Option, + + pub webhook_rotate_secret_response: Option, + + pub webhook_status_response: Option, + + pub webhook_subscription: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AdminKeyUsageResponse { + pub api_key_id: String, + + /// Per-endpoint breakdown; empty when the window has no requests + pub by_endpoint: Vec, + + pub from: String, + + /// Requests with status code < 400 + pub successful_requests: i64, + + pub to: String, + + pub total_requests: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EndpointUsage { + pub avg_duration_ms: f64, + + pub endpoint: String, + + pub requests: i64, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -100,6 +150,30 @@ pub enum Network { Testnet, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ContractCallRequest { + /// Base64-encoded XDR ScVal arguments, in order + pub args: Option>, + + /// Contract function name to invoke + pub function: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ContractCallResponse { + /// Simulation error message; present only when success=false + pub error: Option, + + /// Raw base64 XDR of the return value; omitted on failure + pub raw_xdr: Option, + + /// Decoded return value; omitted when undecodable or failed + pub result: Option, + + /// False when the simulation itself reported a failure (still HTTP 200) + pub success: bool, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ContractEventFieldSchema { /// Stable field name for this event payload position or property @@ -134,6 +208,21 @@ pub struct ContractEventSchemaResponse { pub network: Network, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ContractRegistrationRequest { + /// Contract address (C... strkey, 56 characters) + pub contract_id: String, + + /// Ledger sequence to start indexing from + pub index_from: Option, + + /// Human-readable label + pub label: Option, + + /// Network scope; omitted or empty means all networks + pub network: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ContractResponse { /// Stellar contract id (C... strkey). @@ -517,3 +606,132 @@ pub struct VersionResponse { /// ldflags. pub version: String, } + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WebhookCreateRequest { + pub contract_id: String, + + pub network: Option, + + /// Delivery target; must be https with a publicly resolvable, non-private host + pub target_url: String, + + /// Optional topic filter + pub topic0: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WebhookCreateResponse { + pub contract_id: String, + + pub id: String, + + pub network: String, + + /// HMAC signing secret — shown here and in the listing + pub secret: String, + + pub target_url: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WebhookDelivery { + pub attempt: i64, + + pub attempts: i64, + + pub delivered_at: String, + + pub event_id: String, + + pub id: i64, + + /// Omitted when empty + pub response_body: Option, + + pub status: String, + + /// HTTP status of the delivery attempt; omitted when none occurred + pub status_code: Option, + + pub subscription_id: String, + + pub success: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WebhookReplayResponse { + pub attempt: i64, + + /// Truncated to 500 characters + pub response_body: String, + + pub status: WebhookReplayResponseStatus, + + /// 0 when no HTTP response occurred + pub status_code: i64, + + pub success: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WebhookReplayResponseStatus { + Failed, + + Success, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WebhookRotateSecretResponse { + pub id: String, + + /// The demoted secret, now serving as secondary during the overlap window + pub previous_secret: String, + + /// The new primary signing secret (whsec_ prefixed) + pub secret: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WebhookStatusResponse { + pub status: WebhookStatusResponseStatus, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WebhookStatusResponseStatus { + Paused, + + Resumed, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WebhookSubscription { + /// Omitted when empty + pub api_key_id: Option, + + pub contract_id: String, + + pub created_at: String, + + pub id: String, + + pub network: String, + + /// Present while deliveries are paused + pub paused_at: Option, + + /// HMAC signing secret for deliveries; omitted when empty + pub secret: Option, + + pub target_url: String, + + /// Topic filter; omitted when unfiltered + pub topic0: Option, +} diff --git a/sdk/typescript/src/api-types.gen.ts b/sdk/typescript/src/api-types.gen.ts index c05e828b..908c5f83 100644 --- a/sdk/typescript/src/api-types.gen.ts +++ b/sdk/typescript/src/api-types.gen.ts @@ -316,7 +316,11 @@ export interface paths { delete: operations["deleteApiKey"]; options?: never; head?: never; - patch?: never; + /** + * Update an API key + * @description Update an API key's label and/or rate-limit tier (admin only). At least one of the two fields must be present. A tier change takes effect immediately — the shared tier cache is invalidated on success. Only active (non-revoked) keys can be updated. + */ + patch: operations["updateApiKey"]; trace?: never; }; "/v1/admin/db": { @@ -339,6 +343,254 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/admin/keys/{id}/usage": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * API key usage report + * @description Per-key usage over a time window (admin only), aggregated from the audit log. Both `from` and `to` are required RFC 3339 timestamps; the window may not exceed 31 days. Unknown query parameters are rejected. + */ + get: operations["getAdminKeyUsage"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/admin/contracts": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List registered contracts + * @description Keyset-paginated list of contracts registered for indexing (admin only). `limit` outside 1..200 silently falls back to the default of 100; unknown query parameters are ignored. + */ + get: operations["listAdminContracts"]; + put?: never; + /** + * Register a contract for indexing + * @description Register (or re-register) a contract in the indexing allowlist (admin only). Upserts on (contract_id, network) — re-registering an existing contract updates its label and index_from and still returns 201. + */ + post: operations["createAdminContract"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/admin/contracts/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** + * Unregister a contract + * @description Remove a contract registration by its registration ID (the `id` returned at registration, not the contract address). Idempotent — deleting a non-existent registration still returns 204. + */ + delete: operations["deleteAdminContract"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/contracts/{id}/call": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Simulate a read-only contract call + * @description Simulate a contract function invocation via Soroban RPC and return the decoded result. Nothing is submitted to the network. Note the three success shapes: a simulation-level failure still returns HTTP 200 with `success: false` and `error` set; a result that cannot be decoded returns `success: true` with only `raw_xdr`. + */ + post: operations["callContract"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/webhooks": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List webhook subscriptions + * @description Webhook subscriptions owned by the calling API key. Includes each subscription's signing secret. Returns JSON null (not an empty array) when the key owns no subscriptions. Webhook endpoints are not yet part of the frozen v1 surface; some of their error responses are plain text rather than the canonical error envelope. + */ + get: operations["listWebhooks"]; + put?: never; + /** + * Create a webhook subscription + * @description Subscribe a target URL to events from a contract. Target URLs must be https, resolve publicly, and not point at private, loopback, link-local, or metadata addresses. The returned secret signs every delivery. Note the camelCase field names — webhook endpoints predate the snake_case convention and are not yet part of the frozen v1 surface. + */ + post: operations["createWebhook"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/webhooks/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** + * Delete a webhook subscription + * @description Permanently delete a webhook subscription and stop its deliveries. + */ + delete: operations["deleteWebhook"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/webhooks/{id}/rotate-secret": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Rotate a webhook signing secret + * @description Generate a new primary signing secret for the subscription, demoting the current one to secondary in the same statement so in-flight deliveries signed with the old secret still verify. Scoped to the caller's API key: rotating another tenant's subscription returns 404. Requires a database-backed API key; legacy env-hash authentication carries no key identity to scope ownership to. + */ + post: operations["rotateWebhookSecret"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/webhooks/{id}/pause": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** + * Pause webhook deliveries + * @description Pause deliveries for a subscription. Returns the paused status even when the ID matches no subscription (no existence check). + */ + patch: operations["pauseWebhook"]; + trace?: never; + }; + "/v1/webhooks/{id}/resume": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** + * Resume webhook deliveries + * @description Resume deliveries for a paused subscription. Returns the resumed status even when the ID matches no subscription (no existence check). + */ + patch: operations["resumeWebhook"]; + trace?: never; + }; + "/v1/webhooks/{id}/deliveries": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List recent webhook deliveries + * @description The 100 most recent delivery attempts for a subscription, newest first. Returns JSON null (not an empty array) when there are none. + */ + get: operations["listWebhookDeliveries"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/webhooks/{id}/dead-letters": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List dead-lettered deliveries + * @description Deliveries that exhausted their retries (status dead_lettered), up to 200, newest first. Always an array — empty when there are none. + */ + get: operations["listWebhookDeadLetters"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/webhooks/{id}/dead-letters/{deliveryId}/replay": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Replay a dead-lettered delivery + * @description Re-attempt one dead-lettered delivery and record the outcome. The response reports the replay result in snake_case (unlike the other webhook endpoints). + */ + post: operations["replayWebhookDeadLetter"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/metrics": { parameters: { query?: never; @@ -716,6 +968,43 @@ export interface components { /** @description Opaque cursor for the next page (null if has_more is false). */ next_cursor: string | null; }; + AdminKeyUsageResponse: { + /** Format: uuid */ + api_key_id: string; + /** Format: date-time */ + from: string; + /** Format: date-time */ + to: string; + /** Format: int64 */ + total_requests: number; + /** + * Format: int64 + * @description Requests with status code < 400 + */ + successful_requests: number; + /** @description Per-endpoint breakdown; empty when the window has no requests */ + by_endpoint: components["schemas"]["EndpointUsage"][]; + }; + EndpointUsage: { + endpoint: string; + /** Format: int64 */ + requests: number; + avg_duration_ms: number; + }; + ContractRegistrationRequest: { + /** @description Contract address (C... strkey, 56 characters) */ + contract_id: string; + /** @description Network scope; omitted or empty means all networks */ + network?: string; + /** @description Human-readable label */ + label?: string; + /** + * Format: int64 + * @description Ledger sequence to start indexing from + * @default 0 + */ + index_from: number; + }; ContractResponse: { /** Format: uuid */ id: string; @@ -731,6 +1020,99 @@ export interface components { /** Format: date-time */ created_at: string; }; + ContractCallRequest: { + /** @description Contract function name to invoke */ + function: string; + /** @description Base64-encoded XDR ScVal arguments, in order */ + args?: string[]; + }; + ContractCallResponse: { + /** @description False when the simulation itself reported a failure (still HTTP 200) */ + success: boolean; + /** @description Decoded return value; omitted when undecodable or failed */ + result?: unknown; + /** @description Raw base64 XDR of the return value; omitted on failure */ + raw_xdr?: string; + /** @description Simulation error message; present only when success=false */ + error?: string; + }; + WebhookSubscription: { + /** Format: uuid */ + id: string; + /** @description Omitted when empty */ + apiKeyId?: string; + contractId: string; + /** @description Topic filter; omitted when unfiltered */ + topic0?: string | null; + targetUrl: string; + /** @description HMAC signing secret for deliveries; omitted when empty */ + secret?: string; + /** Format: date-time */ + createdAt: string; + /** + * Format: date-time + * @description Present while deliveries are paused + */ + pausedAt?: string | null; + network: string; + }; + WebhookCreateRequest: { + contractId: string; + /** @description Optional topic filter */ + topic0?: string | null; + /** @description Delivery target; must be https with a publicly resolvable, non-private host */ + targetUrl: string; + /** @default testnet */ + network: string; + }; + WebhookCreateResponse: { + /** Format: uuid */ + id: string; + /** @description HMAC signing secret — shown here and in the listing */ + secret: string; + targetUrl: string; + contractId: string; + network: string; + }; + WebhookDelivery: { + /** Format: int64 */ + id: number; + /** Format: uuid */ + subscriptionId: string; + eventId: string; + attempt: number; + attempts: number; + status: string; + /** @description HTTP status of the delivery attempt; omitted when none occurred */ + statusCode?: number | null; + /** @description Omitted when empty */ + responseBody?: string; + /** Format: date-time */ + deliveredAt: string; + success: boolean; + }; + WebhookRotateSecretResponse: { + /** Format: uuid */ + id: string; + /** @description The new primary signing secret (whsec_ prefixed) */ + secret: string; + /** @description The demoted secret, now serving as secondary during the overlap window */ + previousSecret: string; + }; + WebhookStatusResponse: { + /** @enum {string} */ + status: "paused" | "resumed"; + }; + WebhookReplayResponse: { + success: boolean; + /** @enum {string} */ + status: "success" | "failed"; + attempt: number; + /** @description 0 when no HTTP response occurred */ + status_code: number; + /** @description Truncated to 500 characters */ + response_body: string; + }; ErrorResponse: { error: { /** @description Error code (e.g., INVALID_ARGUMENT, INTERNAL, UNAVAILABLE, CONFLICT) */ @@ -1395,24 +1777,87 @@ export interface operations { 429: components["responses"]["TooManyRequestsIPOnly"]; }; }; - getAdminDbStats: { + updateApiKey: { parameters: { query?: never; header?: never; - path?: never; + path: { + /** @description API key ID */ + id: string; + }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": { + /** @description New display label for the key */ + label?: string; + /** @description New rate-limit tier name */ + rate_limit_tier?: string; + }; + }; + }; responses: { - /** @description Database statistics */ + /** @description The updated API key */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - pools: { - [key: string]: unknown; + "application/json": components["schemas"]["APIKeyResponse"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + /** @description Admin API key is not configured on the server */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description API key not found (or already revoked) */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Request body exceeds the 1 MiB limit */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + 429: components["responses"]["TooManyRequestsIPOnly"]; + 503: components["responses"]["ServiceUnavailable"]; + }; + }; + getAdminDbStats: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Database statistics */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + pools: { + [key: string]: unknown; }[]; stats: { [key: string]: unknown; @@ -1442,6 +1887,626 @@ export interface operations { }; }; }; + getAdminKeyUsage: { + parameters: { + query: { + /** @description Window start (RFC 3339) */ + from: string; + /** @description Window end (RFC 3339); must be >= from, window <= 31 days */ + to: string; + }; + header?: never; + path: { + /** @description API key ID */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Usage aggregates for the key over the window */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AdminKeyUsageResponse"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 429: components["responses"]["TooManyRequestsIPOnly"]; + /** @description Usage query failed */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + 503: components["responses"]["ServiceUnavailable"]; + }; + }; + listAdminContracts: { + parameters: { + query?: { + /** @description Page size */ + limit?: number; + /** @description Opaque pagination cursor from a previous page's next_cursor */ + cursor?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description One page of registered contracts */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListContractsResponse"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 429: components["responses"]["TooManyRequestsIPOnly"]; + /** @description Listing failed (including a malformed cursor) */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + 503: components["responses"]["ServiceUnavailable"]; + }; + }; + createAdminContract: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ContractRegistrationRequest"]; + }; + }; + responses: { + /** @description Contract registered (created or updated) */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ContractResponse"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + /** @description Request body exceeds the 1 MiB limit */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + 429: components["responses"]["TooManyRequestsIPOnly"]; + /** @description Registration failed */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + 503: components["responses"]["ServiceUnavailable"]; + }; + }; + deleteAdminContract: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Contract registration ID */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Registration removed (or never existed) */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 429: components["responses"]["TooManyRequestsIPOnly"]; + /** @description Delete failed (including a malformed registration ID) */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + 503: components["responses"]["ServiceUnavailable"]; + }; + }; + callContract: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Contract address (C... strkey, 56 characters) */ + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ContractCallRequest"]; + }; + }; + responses: { + /** @description Simulation completed (including simulation-level failures, which report success=false with an error message) */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ContractCallResponse"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + /** @description Request body exceeds the 1 MiB limit */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + 429: components["responses"]["RateLimitExceeded"]; + /** @description Soroban RPC call failed or returned no result */ + 502: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + 503: components["responses"]["ServiceUnavailable"]; + }; + }; + listWebhooks: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Subscriptions owned by the calling key (null when none) */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WebhookSubscription"][] | null; + }; + }; + 401: components["responses"]["Unauthorized"]; + 429: components["responses"]["RateLimitExceeded"]; + /** @description Listing failed (plain-text body) */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "text/plain": string; + }; + }; + /** @description Database unavailable (plain-text body) */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "text/plain": string; + }; + }; + }; + }; + createWebhook: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["WebhookCreateRequest"]; + }; + }; + responses: { + /** @description Subscription created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WebhookCreateResponse"]; + }; + }; + /** @description Invalid body or target URL (plain-text body) */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "text/plain": string; + }; + }; + 401: components["responses"]["Unauthorized"]; + /** @description Request body exceeds the 1 MiB limit */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + 429: components["responses"]["RateLimitExceeded"]; + /** @description Creation failed (plain-text body) */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "text/plain": string; + }; + }; + /** @description Database unavailable (plain-text body) */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "text/plain": string; + }; + }; + }; + }; + deleteWebhook: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Subscription ID */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Subscription deleted */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + /** @description Subscription not found (plain-text body) */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "text/plain": string; + }; + }; + 429: components["responses"]["RateLimitExceeded"]; + /** @description Delete failed (plain-text body) */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "text/plain": string; + }; + }; + 503: components["responses"]["ServiceUnavailable"]; + }; + }; + rotateWebhookSecret: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Subscription ID */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Secret rotated */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WebhookRotateSecretResponse"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + /** @description No subscription with this ID belongs to the caller's API key (plain-text body) */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "text/plain": string; + }; + }; + 429: components["responses"]["RateLimitExceeded"]; + /** @description Rotation failed (plain-text body) */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "text/plain": string; + }; + }; + 503: components["responses"]["ServiceUnavailable"]; + }; + }; + pauseWebhook: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Subscription ID */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Deliveries paused */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WebhookStatusResponse"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 429: components["responses"]["RateLimitExceeded"]; + /** @description Update failed (plain-text body) */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "text/plain": string; + }; + }; + 503: components["responses"]["ServiceUnavailable"]; + }; + }; + resumeWebhook: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Subscription ID */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Deliveries resumed */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WebhookStatusResponse"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 429: components["responses"]["RateLimitExceeded"]; + /** @description Update failed (plain-text body) */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "text/plain": string; + }; + }; + 503: components["responses"]["ServiceUnavailable"]; + }; + }; + listWebhookDeliveries: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Subscription ID */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Recent delivery attempts (null when none) */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WebhookDelivery"][] | null; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 429: components["responses"]["RateLimitExceeded"]; + /** @description Listing failed (plain-text body) */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "text/plain": string; + }; + }; + 503: components["responses"]["ServiceUnavailable"]; + }; + }; + listWebhookDeadLetters: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Subscription ID */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Dead-lettered delivery attempts */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WebhookDelivery"][]; + }; + }; + /** @description Missing webhook ID (plain-text body) */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "text/plain": string; + }; + }; + 401: components["responses"]["Unauthorized"]; + 429: components["responses"]["RateLimitExceeded"]; + /** @description Listing failed (plain-text body) */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "text/plain": string; + }; + }; + /** @description Database unavailable (plain-text body) */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "text/plain": string; + }; + }; + }; + }; + replayWebhookDeadLetter: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Subscription ID */ + id: string; + /** @description Numeric delivery ID from the dead-letters listing */ + deliveryId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Replay attempted; success reflects the delivery outcome */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WebhookReplayResponse"]; + }; + }; + /** @description Missing webhook or delivery ID (plain-text body) */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "text/plain": string; + }; + }; + 401: components["responses"]["Unauthorized"]; + /** @description No matching dead-lettered delivery (plain-text body) */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "text/plain": string; + }; + }; + 429: components["responses"]["RateLimitExceeded"]; + /** @description Replay failed to record (plain-text body) */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "text/plain": string; + }; + }; + /** @description Database unavailable (plain-text body) */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "text/plain": string; + }; + }; + }; + }; getMetrics: { parameters: { query?: never; diff --git a/services/api/handlers/admin.go b/services/api/handlers/admin.go index 2d1a88fc..7d4e4c82 100644 --- a/services/api/handlers/admin.go +++ b/services/api/handlers/admin.go @@ -174,7 +174,11 @@ func AdminKeyUsage(cfg AdminConfig) http.HandlerFunc { } defer rows.Close() - var byEndpoint []AdminEndpointUsage + // Initialized non-nil so an empty window serializes as [] rather than + // null — the generated SDK models (Rust Vec, Python list) reject null + // for this field, and "no requests" is an empty breakdown, not an + // absent one (issue #513). + byEndpoint := []AdminEndpointUsage{} for rows.Next() { var eu AdminEndpointUsage if err := rows.Scan(&eu.Endpoint, &eu.Requests, &eu.AvgDurationMs); err != nil { diff --git a/services/api/handlers/contract_test.go b/services/api/handlers/contract_test.go index c6ef6c2a..fe555648 100644 --- a/services/api/handlers/contract_test.go +++ b/services/api/handlers/contract_test.go @@ -13,7 +13,6 @@ import ( "github.com/Depo-dev/trident/services/api/gen" "github.com/Depo-dev/trident/services/api/handlers" "github.com/getkin/kin-openapi/openapi3" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "google.golang.org/grpc" "google.golang.org/grpc/codes" @@ -208,123 +207,10 @@ func TestContract_ErrorResponseValidation(t *testing.T) { }) } -// TestContract_RouteParity verifies that every route registered in main.go -// is documented in the OpenAPI spec and vice versa -func TestContract_RouteParity(t *testing.T) { - doc := loadOpenAPISpec(t) - - // Extract documented routes from OpenAPI spec - documentedRoutes := make(map[string]bool) - for path, pathItem := range doc.Paths.Map() { - if pathItem.Get != nil { - documentedRoutes["GET "+path] = true - } - if pathItem.Post != nil { - documentedRoutes["POST "+path] = true - } - if pathItem.Put != nil { - documentedRoutes["PUT "+path] = true - } - if pathItem.Patch != nil { - documentedRoutes["PATCH "+path] = true - } - if pathItem.Delete != nil { - documentedRoutes["DELETE "+path] = true - } - } - - // Expected routes from main.go (this list should be kept in sync with main.go) - // Note: Some routes like /metrics, /internal/status, /ws, /graphql are intentionally - // excluded from the OpenAPI spec as they are internal or use different protocols - // Note: Admin routes and webhook routes are also currently not documented in OpenAPI - expectedRoutes := map[string]bool{ - "GET /v1/health": true, - "GET /v1/ready": true, - "GET /v1/version": true, - "GET /v1/events": true, - "POST /v1/events/batch": true, - "GET /v1/events/{id}": true, - "GET /v1/events/stream": true, - "GET /v1/admin/db": true, - "GET /v1/admin/keys/{id}/usage": true, - "POST /v1/admin/contracts": true, - "GET /v1/admin/contracts": true, - "DELETE /v1/admin/contracts/{id}": true, - "POST /v1/api-keys": true, - "GET /v1/api-keys": true, - "PATCH /v1/api-keys/{id}": true, - "DELETE /v1/api-keys/{id}": true, - "GET /v1/stats/indexer": true, - "GET /v1/contracts/{id}/events/schema": true, - "GET /v1/contracts/{id}/spec": true, - "GET /v1/contracts/{id}/storage": true, - "GET /v1/contracts/{id}/storage/history": true, - "GET /v1/stats/contracts": true, - "POST /v1/contracts/{id}/call": true, - // Webhook routes (not yet documented in OpenAPI) - "GET /v1/webhooks": true, - "POST /v1/webhooks": true, - "DELETE /v1/webhooks/{id}": true, - "PATCH /v1/webhooks/{id}/pause": true, - "PATCH /v1/webhooks/{id}/resume": true, - "GET /v1/webhooks/{id}/deliveries": true, - "GET /v1/webhooks/{id}/dead-letters": true, - "POST /v1/webhooks/{id}/dead-letters/{deliveryId}/replay": true, - } - - // Routes that are intentionally excluded from OpenAPI documentation - // (internal routes, admin routes, webhook routes, etc.) - excludedFromOpenAPI := map[string]bool{ - "GET /metrics": true, - "GET /internal/status": true, - "GET /ws": true, - "GET /graphql": true, - "GET /v1/admin/db": true, - "GET /v1/admin/keys/{id}/usage": true, - "POST /v1/admin/contracts": true, - "GET /v1/admin/contracts": true, - "DELETE /v1/admin/contracts/{id}": true, - "POST /v1/api-keys": true, - "GET /v1/api-keys": true, - "PATCH /v1/api-keys/{id}": true, - "DELETE /v1/api-keys/{id}": true, - "POST /v1/contracts/{id}/call": true, // Contract call endpoint (not yet documented) - "GET /v1/webhooks": true, - "POST /v1/webhooks": true, - "DELETE /v1/webhooks/{id}": true, - "PATCH /v1/webhooks/{id}/pause": true, - "PATCH /v1/webhooks/{id}/resume": true, - "GET /v1/webhooks/{id}/deliveries": true, - "GET /v1/webhooks/{id}/dead-letters": true, - "POST /v1/webhooks/{id}/dead-letters/{deliveryId}/replay": true, - } - - // Check for undocumented routes (excluding intentionally excluded ones) - var undocumented []string - for route := range expectedRoutes { - if !documentedRoutes[route] && !excludedFromOpenAPI[route] { - undocumented = append(undocumented, route) - } - } - - // Check for documented but not implemented routes (excluding intentionally excluded ones) - var unimplemented []string - for route := range documentedRoutes { - if !expectedRoutes[route] && !excludedFromOpenAPI[route] { - unimplemented = append(unimplemented, route) - } - } - - if len(undocumented) > 0 { - t.Errorf("Routes registered in main.go but not documented in OpenAPI spec:\n%s", - strings.Join(undocumented, "\n")) - } - - if len(unimplemented) > 0 { - t.Errorf("Routes documented in OpenAPI spec but not registered in main.go:\n%s", - strings.Join(unimplemented, "\n")) - } - - assert.Empty(t, undocumented, "all registered routes should be documented (or intentionally excluded)") - assert.Empty(t, unimplemented, "all documented routes should be registered (or intentionally excluded)") -} +// Route<->spec parity is enforced by TestEveryRouteIsDocumentedOrExempted and +// TestSpecHasNoPhantomOperations (services/api/routes_inventory_test.go), +// which derive the implemented-route set from the live registration table in +// routes.go rather than from a hand-maintained list. The previous +// TestContract_RouteParity kept exactly such a list here ("should be kept in +// sync with main.go") — the drift this suite exists to make structurally +// impossible — and is superseded by the table-driven tests (issue #513). diff --git a/services/api/handlers/contracts.go b/services/api/handlers/contracts.go index fae11d73..1caf9a43 100644 --- a/services/api/handlers/contracts.go +++ b/services/api/handlers/contracts.go @@ -9,6 +9,7 @@ import ( "time" "github.com/Depo-dev/trident/services/api/cursor" + "github.com/Depo-dev/trident/services/api/internal/httputil" "github.com/Depo-dev/trident/services/api/middleware" "github.com/jackc/pgx/v5/pgxpool" ) @@ -19,12 +20,6 @@ type ContractConfig struct { DB *pgxpool.Pool } -// errorBody builds the {"error":{"message":...}} envelope used by this -// file's writeJSON error responses. -func errorBody(message string) map[string]any { - return map[string]any{"error": map[string]any{"message": message}} -} - // ContractResponse is the JSON representation of an indexed_contracts row. type ContractResponse struct { ID string `json:"id"` @@ -65,12 +60,12 @@ type ListContractsResponse struct { func CreateContract(cfg ContractConfig) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if cfg.AdminKey == "" || cfg.DB == nil { - writeJSON(w, http.StatusServiceUnavailable, errorBody("admin contracts endpoint is not configured")) + httputil.WriteErrorCtx(r.Context(), w, http.StatusServiceUnavailable, httputil.UNAVAILABLE, "admin contracts endpoint is not configured") return } if !validAdminKey(cfg.AdminKey, r.Header.Get("X-Admin-Key")) { - writeJSON(w, http.StatusUnauthorized, errorBody("invalid or missing admin key")) + httputil.WriteErrorCtx(r.Context(), w, http.StatusUnauthorized, httputil.UNAUTHORIZED, "invalid or missing admin key") return } @@ -80,18 +75,18 @@ func CreateContract(cfg ContractConfig) http.HandlerFunc { middleware.WriteBodyTooLarge(w, r) return } - writeJSON(w, http.StatusBadRequest, errorBody("invalid request body")) + httputil.WriteErrorCtx(r.Context(), w, http.StatusBadRequest, httputil.INVALID_ARGUMENT, "invalid request body") return } if req.ContractID == "" { - writeJSON(w, http.StatusBadRequest, errorBody("contract_id is required")) + httputil.WriteErrorCtx(r.Context(), w, http.StatusBadRequest, httputil.INVALID_ARGUMENT, "contract_id is required") return } // Validate strkey format: must start with C and be 56 chars. if len(req.ContractID) != 56 || req.ContractID[0] != 'C' { - writeJSON(w, http.StatusBadRequest, errorBody("contract_id must be a valid 56-character strkey starting with C")) + httputil.WriteErrorCtx(r.Context(), w, http.StatusBadRequest, httputil.INVALID_ARGUMENT, "contract_id must be a valid 56-character strkey starting with C") return } @@ -112,7 +107,7 @@ func CreateContract(cfg ContractConfig) http.HandlerFunc { if err != nil { slog.ErrorContext(r.Context(), "failed to create contract", "err", err) - writeJSON(w, http.StatusInternalServerError, errorBody("failed to register contract")) + httputil.WriteErrorCtx(r.Context(), w, http.StatusInternalServerError, httputil.INTERNAL, "failed to register contract") return } @@ -132,12 +127,12 @@ func CreateContract(cfg ContractConfig) http.HandlerFunc { func ListContracts(cfg ContractConfig) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if cfg.AdminKey == "" || cfg.DB == nil { - writeJSON(w, http.StatusServiceUnavailable, errorBody("admin contracts endpoint is not configured")) + httputil.WriteErrorCtx(r.Context(), w, http.StatusServiceUnavailable, httputil.UNAVAILABLE, "admin contracts endpoint is not configured") return } if !validAdminKey(cfg.AdminKey, r.Header.Get("X-Admin-Key")) { - writeJSON(w, http.StatusUnauthorized, errorBody("invalid or missing admin key")) + httputil.WriteErrorCtx(r.Context(), w, http.StatusUnauthorized, httputil.UNAUTHORIZED, "invalid or missing admin key") return } @@ -175,7 +170,7 @@ func ListContracts(cfg ContractConfig) http.HandlerFunc { rows, err := cfg.DB.Query(ctx, query, cursorID, limit+1) if err != nil { slog.ErrorContext(r.Context(), "failed to list contracts", "err", err) - writeJSON(w, http.StatusInternalServerError, errorBody("failed to list contracts")) + httputil.WriteErrorCtx(r.Context(), w, http.StatusInternalServerError, httputil.INTERNAL, "failed to list contracts") return } defer rows.Close() @@ -191,7 +186,7 @@ func ListContracts(cfg ContractConfig) http.HandlerFunc { var createdAt time.Time if err := rows.Scan(&c.ID, &c.ContractID, &c.Network, &c.Label, &c.IndexFrom, &createdAt); err != nil { slog.ErrorContext(r.Context(), "failed to scan contract row", "err", err) - writeJSON(w, http.StatusInternalServerError, errorBody("scan error")) + httputil.WriteErrorCtx(r.Context(), w, http.StatusInternalServerError, httputil.INTERNAL, "scan error") return } c.CreatedAt = createdAt.UTC().Format(time.RFC3339) @@ -219,18 +214,18 @@ func ListContracts(cfg ContractConfig) http.HandlerFunc { func DeleteContract(cfg ContractConfig) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if cfg.AdminKey == "" || cfg.DB == nil { - writeJSON(w, http.StatusServiceUnavailable, errorBody("admin contracts endpoint is not configured")) + httputil.WriteErrorCtx(r.Context(), w, http.StatusServiceUnavailable, httputil.UNAVAILABLE, "admin contracts endpoint is not configured") return } if !validAdminKey(cfg.AdminKey, r.Header.Get("X-Admin-Key")) { - writeJSON(w, http.StatusUnauthorized, errorBody("invalid or missing admin key")) + httputil.WriteErrorCtx(r.Context(), w, http.StatusUnauthorized, httputil.UNAUTHORIZED, "invalid or missing admin key") return } id := r.PathValue("id") if id == "" { - writeJSON(w, http.StatusBadRequest, errorBody("missing contract id")) + httputil.WriteErrorCtx(r.Context(), w, http.StatusBadRequest, httputil.INVALID_ARGUMENT, "missing contract id") return } @@ -240,7 +235,7 @@ func DeleteContract(cfg ContractConfig) http.HandlerFunc { tag, err := cfg.DB.Exec(ctx, `DELETE FROM indexed_contracts WHERE id = $1`, id) if err != nil { slog.ErrorContext(r.Context(), "failed to delete contract", "err", err) - writeJSON(w, http.StatusInternalServerError, errorBody("failed to delete contract")) + httputil.WriteErrorCtx(r.Context(), w, http.StatusInternalServerError, httputil.INTERNAL, "failed to delete contract") return } diff --git a/services/api/handlers/usage.go b/services/api/handlers/usage.go index 192d23c0..82e19844 100644 --- a/services/api/handlers/usage.go +++ b/services/api/handlers/usage.go @@ -48,6 +48,17 @@ type UsageResponse struct { // (which fills in as the day progresses) and catches any audit_log rows that // arrived late relative to the previous run, since the audit writer batches // asynchronously. + +// errorBody builds the legacy {"error":{"message":...}} envelope. Only the +// (currently unmounted) usage handlers in this file still use it — the +// mounted admin-contract handlers moved to the canonical httputil envelope +// when they were documented in the OpenAPI spec (issue #513). If these +// handlers are ever mounted, migrate them to httputil.WriteErrorCtx and +// delete this. +func errorBody(message string) map[string]any { + return map[string]any{"error": map[string]any{"message": message}} +} + func RollupUsage(ctx context.Context, db *pgxpool.Pool, since time.Time) error { _, err := db.Exec(ctx, ` INSERT INTO usage_rollup (api_key_id, period_start, period_end, request_count, error_count, avg_duration_ms, updated_at) diff --git a/services/api/internal/contracttest/live_test.go b/services/api/internal/contracttest/live_test.go index 437ae353..af81d4d8 100644 --- a/services/api/internal/contracttest/live_test.go +++ b/services/api/internal/contracttest/live_test.go @@ -324,6 +324,31 @@ func (s *liveSuite) validate(req *http.Request, status int, header http.Header, s.covered[route.Operation.OperationID][kind] = true } +// liveCoverageDeferred lists operations documented in the spec (issue #513 +// brought every implemented route under it) that the live suite does not +// exercise yet. The static inventory tests in services/api +// (routes_inventory_test.go) still enforce their route<->spec agreement and +// error contracts; what is deferred here is only the LIVE request/response +// exercise, which needs stateful fixtures (webhook subscriptions, admin +// contract registrations, dead-lettered deliveries) the compose stack does +// not seed today. Burn this list down — new operations must not be added. +var liveCoverageDeferred = map[string]bool{ + "updateApiKey": true, + "getAdminKeyUsage": true, + "createAdminContract": true, + "listAdminContracts": true, + "deleteAdminContract": true, + "callContract": true, + "listWebhooks": true, + "createWebhook": true, + "deleteWebhook": true, + "pauseWebhook": true, + "resumeWebhook": true, + "listWebhookDeliveries": true, + "listWebhookDeadLetters": true, + "replayWebhookDeadLetter": true, +} + func assertOperationCoverage(t *testing.T, doc *openapi3.T, covered map[string]map[string]bool) { t.Helper() for _, pathItem := range doc.Paths.Map() { @@ -331,6 +356,9 @@ func assertOperationCoverage(t *testing.T, doc *openapi3.T, covered map[string]m if operation == nil { continue } + if liveCoverageDeferred[operation.OperationID] { + continue + } // getAdminDbStats' success case needs a reachable PgBouncer admin // console, which CI does not provide (see the eventuallyAny call // above). Its error cases are still required, so the operation is diff --git a/services/api/main.go b/services/api/main.go index cc33d6ae..aee975d4 100644 --- a/services/api/main.go +++ b/services/api/main.go @@ -290,42 +290,10 @@ func main() { handlers.SetInternalStatusDeps(pool, redisClient, hub) mux := http.NewServeMux() - mux.HandleFunc("GET /v1/health", handlers.Health()) - mux.HandleFunc("GET /v1/ready", handlers.Ready(healthDB, redisClient, grpcClient)) - mux.HandleFunc("GET /v1/version", handlers.VersionHandler(pool)) - mux.HandleFunc("GET /v1/events", handlers.ListEvents) - mux.HandleFunc("POST /v1/events/batch", handlers.BatchGetEvents) - mux.HandleFunc("GET /v1/events/{id}", handlers.GetEvent) - mux.HandleFunc("GET /v1/events/stream", handlers.Stream(redisClient)) - mux.HandleFunc("GET /v1/admin/db", handlers.AdminDB(adminCfg)) - mux.HandleFunc("GET /v1/admin/keys/{id}/usage", handlers.AdminKeyUsage(adminCfg)) - // Admin contract registration CRUD (issue #230) - contractCfg := handlers.ContractConfig{AdminKey: os.Getenv("ADMIN_API_KEY"), DB: pool} - mux.HandleFunc("POST /v1/admin/contracts", handlers.CreateContract(contractCfg)) - mux.HandleFunc("GET /v1/admin/contracts", handlers.ListContracts(contractCfg)) - mux.HandleFunc("DELETE /v1/admin/contracts/{id}", handlers.DeleteContract(contractCfg)) - // API key management (admin-only via X-Admin-Key header). Idempotency - // wraps only the create route (issue #225): a retried creation with the - // same Idempotency-Key + body replays the original response instead of - // minting a second key. - mux.Handle("POST /v1/api-keys", middleware.Idempotency(redisClient, middleware.DefaultIdempotencyTTL)(handlers.CreateAPIKey(apiKeyCfg))) - mux.HandleFunc("GET /v1/api-keys", handlers.ListAPIKeys(apiKeyCfg)) - mux.HandleFunc("PATCH /v1/api-keys/{id}", handlers.UpdateAPIKey(apiKeyCfg)) - mux.HandleFunc("DELETE /v1/api-keys/{id}", handlers.DeleteAPIKey(apiKeyCfg)) - mux.HandleFunc("GET /v1/stats/indexer", handlers.IndexerStats(healthDB)) - // Contract spec/schema change only when a contract is redeployed — rare, - // read-only, no side effects — so they're cached (issue #221) with a - // TTL well above the 60s used for stats/contracts below, and are - // invalidated immediately on a new event for that contract rather than - // waiting out the TTL (see StartCacheInvalidator). - const contractMetadataCacheTTL = 5 * time.Minute - mux.Handle("GET /v1/contracts/{id}/events/schema", - middleware.ResponseCache(redisClient, contractMetadataCacheTTL, middleware.DefaultCacheKey)(handlers.ContractEventSchemas(schemaRegistryDB))) - mux.Handle("GET /v1/contracts/{id}/spec", - middleware.ResponseCache(redisClient, contractMetadataCacheTTL, middleware.DefaultCacheKey)(handlers.ContractSpec(schemaRegistryDB))) - mux.HandleFunc("GET /v1/contracts/{id}/storage", handlers.ContractStorageLatest(schemaRegistryDB)) - mux.HandleFunc("GET /v1/contracts/{id}/storage/history", handlers.ContractStorageHistory(schemaRegistryDB)) - mux.HandleFunc("GET /v1/stats/contracts", handlers.ContractsStats(pool, redisClient)) + // Route registration lives in routes.go as a single table shared with the + // OpenAPI inventory contract test (issue #513): every route is either + // documented in api/openapi.yaml or carries an explicit exemption, and the + // test fails on any drift in either direction. // nil (untyped) when STELLAR_RPC_URL is unset, so CallContract's `rpc == // nil` check reports 503 rather than a typed-nil interface slipping // through and panicking on first use. @@ -333,25 +301,6 @@ func main() { if rpcURL := os.Getenv("STELLAR_RPC_URL"); rpcURL != "" { sorobanCaller = sorobanrpc.NewClient(rpcURL) } - mux.HandleFunc("POST /v1/contracts/{id}/call", handlers.CallContract(sorobanCaller)) - mux.HandleFunc("GET /v1/webhooks", listWebhooksHandler(webhookDB)) - // Idempotency (issue #225): a retried subscription creation with the same - // Idempotency-Key + body replays the original response instead of - // creating a second subscription (and a second webhook secret). - mux.Handle("POST /v1/webhooks", middleware.Idempotency(redisClient, middleware.DefaultIdempotencyTTL)(createWebhookHandler(webhookDB))) - mux.HandleFunc("DELETE /v1/webhooks/{id}", deleteWebhookHandler(webhookDB)) - mux.HandleFunc("PATCH /v1/webhooks/{id}/pause", pauseWebhookHandler(webhookDB)) - mux.HandleFunc("PATCH /v1/webhooks/{id}/resume", resumeWebhookHandler(webhookDB)) - mux.HandleFunc("GET /v1/webhooks/{id}/deliveries", deliveriesWebhookHandler(webhookDB)) - mux.HandleFunc("GET /v1/webhooks/{id}/dead-letters", deadLettersWebhookHandler(webhookDB)) - mux.HandleFunc("POST /v1/webhooks/{id}/dead-letters/{deliveryId}/replay", replayDeadLetterHandler(webhookDB)) - mux.HandleFunc("POST /v1/webhooks/{id}/rotate-secret", rotateWebhookSecretHandler(webhookDB)) - mux.HandleFunc("GET /metrics", handlers.MetricsHandler(pool, redisClient)) - mux.HandleFunc("GET /internal/status", handlers.InternalStatus()) - mux.Handle("/ws", middleware.WSConnectionLimit(ws.Handler(hub))) - - _ = usageTrack // passed to middleware in future; declared for shutdown ordering - var rlDB middleware.TierDB if pool != nil { rlDB = pool @@ -365,20 +314,24 @@ func main() { } authDB.Redis = redisClient - // GraphQL/WS is registered after rlCfg and authDB exist because it must - // reuse them (issue #223). The HTTP middlewares cannot cover this - // endpoint on their own: NewDBAuth skips any path that is neither /v1/* - // nor /ws, and TieredRateLimit keys off the X-API-Key header, which a - // WebSocket client never sends — it authenticates in the connection_init - // payload instead. Passing the same auth config and rate-limit config in - // here gives GraphQL the same api_keys lookup and the same per-key - // sliding window REST gets, rather than the legacy env-var key set and no - // limit at all. - mux.Handle("/graphql", middleware.WSConnectionLimit(ws.GraphQLHandler(hub, ws.GraphQLDeps{ - Auth: middleware.GraphQLDBAuth(authDB), - RateLimiter: middleware.GraphQLRateLimiter(rlCfg), - Backend: handlers.NewGraphQLBackend(pool), - }))) + registerRoutes(mux, routeDeps{ + rlCfg: rlCfg, + authDB: authDB, + pool: pool, + healthDB: healthDB, + schemaRegistryDB: schemaRegistryDB, + redisClient: redisClient, + grpcClient: grpcClient, + adminCfg: adminCfg, + contractCfg: handlers.ContractConfig{AdminKey: os.Getenv("ADMIN_API_KEY"), DB: pool}, + apiKeyCfg: apiKeyCfg, + sorobanCaller: sorobanCaller, + webhookDB: webhookDB, + hub: hub, + keyValidator: middleware.Validator(middleware.ParseKeyHashes(os.Getenv("API_KEY_HASHES"))), + }) + + _ = usageTrack // passed to middleware in future; declared for shutdown ordering handler := middleware.NewBodySizeLimitFromEnv()(mux) handler = middleware.TieredRateLimit(rlCfg)(handler) diff --git a/services/api/routes.go b/services/api/routes.go new file mode 100644 index 00000000..1087f7aa --- /dev/null +++ b/services/api/routes.go @@ -0,0 +1,236 @@ +package main + +import ( + "time" + + "database/sql" + "net/http" + + "github.com/Depo-dev/trident/services/api/grpc" + "github.com/Depo-dev/trident/services/api/handlers" + "github.com/Depo-dev/trident/services/api/middleware" + "github.com/Depo-dev/trident/services/api/ws" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/redis/go-redis/v9" +) + +// routeDeps carries everything the route handlers need. Built once in main(); +// the OpenAPI inventory contract test never constructs one — it reads the +// route table through routeInventory(), which touches no handler (issue #513). +type routeDeps struct { + pool *pgxpool.Pool + healthDB handlers.DBPool + schemaRegistryDB handlers.SchemaRegistryDB + redisClient *redis.Client + grpcClient *grpc.Client + adminCfg handlers.AdminConfig + contractCfg handlers.ContractConfig + apiKeyCfg handlers.APIKeyConfig + sorobanCaller handlers.SorobanRPCCaller + webhookDB *sql.DB + hub *ws.Hub + keyValidator func(string) bool + rlCfg middleware.RateLimitConfig + authDB middleware.DBAuthConfig +} + +const contractMetadataCacheTTL = 5 * time.Minute + +// RegisteredRoute describes one mux registration, for the OpenAPI inventory +// contract test (issue #513): every route is either documented in +// api/openapi.yaml or carries an explicit exemption reason. There is no third +// state — a new route added without deciding gets caught by the test. +type RegisteredRoute struct { + // Method is empty for registrations that accept any method (/ws, /graphql). + Method string + Path string + // Documented routes must have a matching operation in api/openapi.yaml; + // undocumented routes must state why they are excluded from the public + // v1 surface. + Documented bool + ExemptionReason string +} + +// routeBinding pairs a route's inventory entry with a lazily constructed +// handler. The closure is only invoked by registerRoutes with real +// dependencies, so enumerating the table (routeInventory) is side-effect +// free — the single slice literal below is simultaneously the registration +// source of truth and the contract-test inventory, which is what makes +// route↔spec drift structurally impossible to reintroduce. +type routeBinding struct { + route RegisteredRoute + handler func(d routeDeps) http.Handler +} + +func documented(method, path string, h func(d routeDeps) http.Handler) routeBinding { + return routeBinding{ + route: RegisteredRoute{Method: method, Path: path, Documented: true}, + handler: h, + } +} + +func internalOnly(method, path, reason string, h func(d routeDeps) http.Handler) routeBinding { + return routeBinding{ + route: RegisteredRoute{ + Method: method, + Path: path, + Documented: false, + ExemptionReason: reason, + }, + handler: h, + } +} + +func routeBindings() []routeBinding { + return []routeBinding{ + documented("GET", "/v1/health", func(d routeDeps) http.Handler { return handlers.Health() }), + documented("GET", "/v1/ready", func(d routeDeps) http.Handler { + return handlers.Ready(d.healthDB, d.redisClient, d.grpcClient) + }), + documented("GET", "/v1/version", func(d routeDeps) http.Handler { + return handlers.VersionHandler(d.pool) + }), + documented("GET", "/v1/events", func(d routeDeps) http.Handler { + return http.HandlerFunc(handlers.ListEvents) + }), + documented("POST", "/v1/events/batch", func(d routeDeps) http.Handler { + return http.HandlerFunc(handlers.BatchGetEvents) + }), + documented("GET", "/v1/events/{id}", func(d routeDeps) http.Handler { + return http.HandlerFunc(handlers.GetEvent) + }), + documented("GET", "/v1/events/stream", func(d routeDeps) http.Handler { + return handlers.Stream(d.redisClient) + }), + documented("GET", "/v1/admin/db", func(d routeDeps) http.Handler { + return handlers.AdminDB(d.adminCfg) + }), + documented("GET", "/v1/admin/keys/{id}/usage", func(d routeDeps) http.Handler { + return handlers.AdminKeyUsage(d.adminCfg) + }), + // Admin contract registration CRUD (issue #230) + documented("POST", "/v1/admin/contracts", func(d routeDeps) http.Handler { + return handlers.CreateContract(d.contractCfg) + }), + documented("GET", "/v1/admin/contracts", func(d routeDeps) http.Handler { + return handlers.ListContracts(d.contractCfg) + }), + documented("DELETE", "/v1/admin/contracts/{id}", func(d routeDeps) http.Handler { + return handlers.DeleteContract(d.contractCfg) + }), + // API key management (admin-only via X-Admin-Key header) + documented("POST", "/v1/api-keys", func(d routeDeps) http.Handler { + // Idempotency wraps only the create route (issue #225). + return middleware.Idempotency(d.redisClient, middleware.DefaultIdempotencyTTL)( + handlers.CreateAPIKey(d.apiKeyCfg)) + }), + documented("GET", "/v1/api-keys", func(d routeDeps) http.Handler { + return handlers.ListAPIKeys(d.apiKeyCfg) + }), + documented("PATCH", "/v1/api-keys/{id}", func(d routeDeps) http.Handler { + return handlers.UpdateAPIKey(d.apiKeyCfg) + }), + documented("DELETE", "/v1/api-keys/{id}", func(d routeDeps) http.Handler { + return handlers.DeleteAPIKey(d.apiKeyCfg) + }), + documented("GET", "/v1/stats/indexer", func(d routeDeps) http.Handler { + return handlers.IndexerStats(d.healthDB) + }), + // Contract spec/schema change only when a contract is redeployed — + // rare, read-only — so they're cached (issue #221) with a TTL well + // above the 60s used for stats, invalidated immediately on a new + // event for that contract (see StartCacheInvalidator). + documented("GET", "/v1/contracts/{id}/events/schema", func(d routeDeps) http.Handler { + return middleware.ResponseCache(d.redisClient, contractMetadataCacheTTL, + middleware.DefaultCacheKey)(handlers.ContractEventSchemas(d.schemaRegistryDB)) + }), + documented("GET", "/v1/contracts/{id}/spec", func(d routeDeps) http.Handler { + return middleware.ResponseCache(d.redisClient, contractMetadataCacheTTL, + middleware.DefaultCacheKey)(handlers.ContractSpec(d.schemaRegistryDB)) + }), + documented("GET", "/v1/contracts/{id}/storage", func(d routeDeps) http.Handler { + return handlers.ContractStorageLatest(d.schemaRegistryDB) + }), + documented("GET", "/v1/contracts/{id}/storage/history", func(d routeDeps) http.Handler { + return handlers.ContractStorageHistory(d.schemaRegistryDB) + }), + documented("GET", "/v1/stats/contracts", func(d routeDeps) http.Handler { + return handlers.ContractsStats(d.pool, d.redisClient) + }), + documented("POST", "/v1/contracts/{id}/call", func(d routeDeps) http.Handler { + return handlers.CallContract(d.sorobanCaller) + }), + documented("GET", "/v1/webhooks", func(d routeDeps) http.Handler { + return listWebhooksHandler(d.webhookDB) + }), + documented("POST", "/v1/webhooks", func(d routeDeps) http.Handler { + // Idempotency wraps only the create route (issue #225). + return middleware.Idempotency(d.redisClient, middleware.DefaultIdempotencyTTL)( + createWebhookHandler(d.webhookDB)) + }), + documented("POST", "/v1/webhooks/{id}/rotate-secret", func(d routeDeps) http.Handler { + return rotateWebhookSecretHandler(d.webhookDB) + }), + documented("DELETE", "/v1/webhooks/{id}", func(d routeDeps) http.Handler { + return deleteWebhookHandler(d.webhookDB) + }), + documented("PATCH", "/v1/webhooks/{id}/pause", func(d routeDeps) http.Handler { + return pauseWebhookHandler(d.webhookDB) + }), + documented("PATCH", "/v1/webhooks/{id}/resume", func(d routeDeps) http.Handler { + return resumeWebhookHandler(d.webhookDB) + }), + documented("GET", "/v1/webhooks/{id}/deliveries", func(d routeDeps) http.Handler { + return deliveriesWebhookHandler(d.webhookDB) + }), + documented("GET", "/v1/webhooks/{id}/dead-letters", func(d routeDeps) http.Handler { + return deadLettersWebhookHandler(d.webhookDB) + }), + documented("POST", "/v1/webhooks/{id}/dead-letters/{deliveryId}/replay", func(d routeDeps) http.Handler { + return replayDeadLetterHandler(d.webhookDB) + }), + documented("GET", "/metrics", func(d routeDeps) http.Handler { + return handlers.MetricsHandler(d.pool, d.redisClient) + }), + internalOnly("GET", "/internal/status", "operator-facing internals, not part of the public v1 surface", + func(d routeDeps) http.Handler { return handlers.InternalStatus() }), + internalOnly("", "/ws", "WebSocket upgrade endpoint; documented in the WebSocket guide, not representable as an OpenAPI operation", + func(d routeDeps) http.Handler { return middleware.WSConnectionLimit(ws.Handler(d.hub)) }), + internalOnly("", "/graphql", "GraphQL-over-WebSocket endpoint; carries its own schema, documented in the GraphQL guide", + func(d routeDeps) http.Handler { + // GraphQL reuses the REST surface's auth and rate-limit + // config (issue #223): the HTTP middlewares cannot cover + // this endpoint on their own — NewDBAuth skips any path + // that is neither /v1/* nor /ws, and TieredRateLimit keys + // off the X-API-Key header, which a WebSocket client never + // sends (it authenticates in the connection_init payload). + return middleware.WSConnectionLimit(ws.GraphQLHandler(d.hub, ws.GraphQLDeps{ + Auth: middleware.GraphQLDBAuth(d.authDB), + RateLimiter: middleware.GraphQLRateLimiter(d.rlCfg), + Backend: handlers.NewGraphQLBackend(d.pool), + })) + }), + } +} + +// registerRoutes mounts every route on the mux. main() is its only caller. +func registerRoutes(mux *http.ServeMux, d routeDeps) { + for _, b := range routeBindings() { + pattern := b.route.Path + if b.route.Method != "" { + pattern = b.route.Method + " " + b.route.Path + } + mux.Handle(pattern, b.handler(d)) + } +} + +// routeInventory exposes the route table for the OpenAPI inventory contract +// test without constructing any handler or dependency. +func routeInventory() []RegisteredRoute { + bindings := routeBindings() + out := make([]RegisteredRoute, 0, len(bindings)) + for _, b := range bindings { + out = append(out, b.route) + } + return out +} diff --git a/services/api/routes_inventory_test.go b/services/api/routes_inventory_test.go new file mode 100644 index 00000000..fe206bf0 --- /dev/null +++ b/services/api/routes_inventory_test.go @@ -0,0 +1,155 @@ +package main + +import ( + "fmt" + "regexp" + "sort" + "strings" + "testing" + + "github.com/Depo-dev/trident/services/api/internal/contracttest" + "github.com/getkin/kin-openapi/openapi3" +) + +// Issue #513: the OpenAPI spec and the implemented routes must be the same +// set. A spec that drifts from the implementation is worse than no spec, +// because SDKs and users trust it — so this test fails when a route exists +// without a spec entry, or a spec entry without a route, in either direction. +// +// The route side comes from routeInventory() (routes.go), the same table +// main() registers from, so the comparison can never silently miss a route. +// Routes deliberately excluded from the public v1 surface carry an explicit +// exemption reason in the table; there is no third state. + +// paramPattern collapses path-parameter names so /v1/events/{id} and +// /v1/events/{eventId} compare equal — the shape is the contract, the +// parameter name is documentation. +var paramPattern = regexp.MustCompile(`\{[^}]+\}`) + +func normalizePath(p string) string { + return paramPattern.ReplaceAllString(p, "{}") +} + +func opKey(method, path string) string { + return strings.ToUpper(method) + " " + normalizePath(path) +} + +func TestEveryRouteIsDocumentedOrExempted(t *testing.T) { + doc := contracttest.LoadSpec(t) + + specOps := make(map[string]bool) + for path, item := range doc.Paths.Map() { + for method := range item.Operations() { + specOps[opKey(method, path)] = true + } + } + + routeOps := make(map[string]bool) + for _, route := range routeInventory() { + if !route.Documented { + if strings.TrimSpace(route.ExemptionReason) == "" { + t.Errorf("route %s %s is undocumented with no exemption reason — document it in api/openapi.yaml or state why it is excluded", + route.Method, route.Path) + } + continue + } + if route.Method == "" { + t.Errorf("route %s is marked documented but has no method — OpenAPI operations are method-scoped", route.Path) + continue + } + routeOps[opKey(route.Method, route.Path)] = true + } + + var missingFromSpec, missingFromRouter []string + for op := range routeOps { + if !specOps[op] { + missingFromSpec = append(missingFromSpec, op) + } + } + for op := range specOps { + if !routeOps[op] { + missingFromRouter = append(missingFromRouter, op) + } + } + sort.Strings(missingFromSpec) + sort.Strings(missingFromRouter) + + for _, op := range missingFromSpec { + t.Errorf("implemented route has no spec entry: %s — add it to api/openapi.yaml (then regenerate SDK models) or exempt it in routes.go with a reason", op) + } + for _, op := range missingFromRouter { + t.Errorf("spec documents an operation no route implements: %s — remove it from api/openapi.yaml or mount the route", op) + } +} + +// Beyond paths: every documented operation must state its status codes — at +// least one success and at least one error — and every JSON error response +// must use the canonical ErrorResponse envelope. Covering "status codes and +// error envelopes, not just paths and happy-path shapes" is half of #513: +// an SDK generated from an operation with no error contract invents one. +func TestEveryOperationDocumentsStatusCodesAndErrorEnvelope(t *testing.T) { + // Operations with no documented error response, each with the reason it + // is acceptable. Kept deliberately tiny — new operations must document + // their error contract. + noErrorResponseAllowed := map[string]string{ + "GET /v1/health": "liveness probe: unauthenticated, returns 200 by design; a failure is a transport error, not an API response", + "GET /metrics": "Prometheus exposition endpoint; scrapers treat any non-200 as scrape failure", + "GET /v1/version": "static build metadata with no failure mode of its own", + } + + // Error responses whose JSON body is deliberately NOT the canonical + // envelope, each with the reason. Anything else gets flagged. + nonEnvelopeErrorAllowed := map[string]string{ + "GET /v1/ready 503": "readiness failure returns the ReadyResponse check detail so probes can see WHICH dependency failed", + } + + doc := contracttest.LoadSpec(t) + + for path, item := range doc.Paths.Map() { + for method, op := range item.Operations() { + key := strings.ToUpper(method) + " " + path + var hasSuccess, hasError bool + for statusStr, ref := range op.Responses.Map() { + if ref == nil || ref.Value == nil { + continue + } + var status int + if _, err := fmt.Sscanf(statusStr, "%d", &status); err != nil { + continue + } + switch { + case status >= 200 && status < 400: + hasSuccess = true + case status >= 400: + hasError = true + media := ref.Value.Content.Get("application/json") + if media == nil { + continue + } + if _, ok := nonEnvelopeErrorAllowed[key+" "+statusStr]; ok { + continue + } + if media.Schema == nil || !strings.HasSuffix(media.Schema.Ref, "/ErrorResponse") { + t.Errorf("%s: response %s has a JSON body that is not the canonical ErrorResponse envelope (ref %q)", + key, statusStr, refOf(media.Schema)) + } + } + } + if !hasSuccess { + t.Errorf("%s: no success (2xx/3xx) response documented", key) + } + if !hasError { + if _, ok := noErrorResponseAllowed[key]; !ok { + t.Errorf("%s: no error (4xx/5xx) response documented — SDKs and users need the error contract, not just the happy path", key) + } + } + } + } +} + +func refOf(s *openapi3.SchemaRef) string { + if s == nil { + return "" + } + return s.Ref +} diff --git a/services/api/webhooks.go b/services/api/webhooks.go index 0e9c646a..ce8bd1fd 100644 --- a/services/api/webhooks.go +++ b/services/api/webhooks.go @@ -89,23 +89,33 @@ type webhookDelivery struct { Success bool `json:"success"` } -func resolveAPIKeyID(ctx context.Context, db *sql.DB, r *http.Request) (string, error) { - if db == nil { - return "", nil - } - if header := strings.TrimSpace(r.Header.Get("X-API-Key")); header != "" { - var id string - if err := db.QueryRowContext(ctx, `SELECT id FROM api_keys WHERE id = $1`, header).Scan(&id); err == nil { - return id, nil - } - } - var id string - if err := db.QueryRowContext(ctx, `INSERT INTO api_keys DEFAULT VALUES RETURNING id`).Scan(&id); err != nil { - return "", err +// resolveAPIKeyID returns the authenticated API key's UUID, which +// middleware.NewDBAuth resolved and attached to the request context. +// +// It previously interpreted the raw X-API-Key HEADER as an api_keys.id UUID +// — which no real key ever is, since keys are "trident_" strings — and +// then fell back to `INSERT INTO api_keys DEFAULT VALUES`, which violates +// the table's NOT NULL constraints. Every legitimate caller therefore got a +// 500 before reaching a subscription, making the documented list/create +// happy paths unreachable (caught while bringing these routes under the +// OpenAPI contract test, issue #513). +// +// Legacy env-hash keys have no database identity and cannot own webhook +// subscriptions; that is now an explicit auth error instead of a stray row +// insert. +func resolveAPIKeyID(ctx context.Context) (string, error) { + if id := middleware.APIKeyIDFromContext(ctx); id != "" { + return id, nil } - return id, nil + return "", errAPIKeyNotResolvable } +// errAPIKeyNotResolvable marks a request authenticated without a +// database-backed API key (legacy env-hash auth). +var errAPIKeyNotResolvable = errors.New( + "webhook ownership requires a database-backed API key", +) + type webhookDeliveryResult struct { Success bool StatusCode int @@ -558,7 +568,11 @@ func listWebhooksHandler(db *sql.DB) http.HandlerFunc { http.Error(w, "database unavailable", http.StatusServiceUnavailable) return } - apiKeyID, err := resolveAPIKeyID(r.Context(), db, r) + apiKeyID, err := resolveAPIKeyID(r.Context()) + if errors.Is(err, errAPIKeyNotResolvable) { + httputil.WriteErrorCtx(r.Context(), w, http.StatusUnauthorized, httputil.UNAUTHORIZED, err.Error()) + return + } if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -691,7 +705,11 @@ func createWebhookHandler(db *sql.DB) http.HandlerFunc { http.Error(w, "failed to generate webhook secret", http.StatusInternalServerError) return } - apiKeyID, err := resolveAPIKeyID(r.Context(), db, r) + apiKeyID, err := resolveAPIKeyID(r.Context()) + if errors.Is(err, errAPIKeyNotResolvable) { + httputil.WriteErrorCtx(r.Context(), w, http.StatusUnauthorized, httputil.UNAUTHORIZED, err.Error()) + return + } if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -995,7 +1013,15 @@ func rotateWebhookSecretHandler(db *sql.DB) http.HandlerFunc { // Rotation must be scoped to the caller's API key. Without this an // authenticated caller could rotate any other tenant's webhook secret // and read both the old and new values back. - apiKeyID, err := resolveAPIKeyID(r.Context(), db, r) + apiKeyID, err := resolveAPIKeyID(r.Context()) + if errors.Is(err, errAPIKeyNotResolvable) { + // Same canonical contract as webhook creation: legacy env-hash + // auth carries no key identity to scope ownership to, and an + // unresolvable key is the caller's auth mode, not a server + // fault — 401, never a 500. + httputil.WriteErrorCtx(r.Context(), w, http.StatusUnauthorized, httputil.UNAUTHORIZED, err.Error()) + return + } if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return