import type { Context, Env, MiddlewareHandler, ValidationTargets } from 'hono' import type { HasRequiredKeys } from 'hono/utils/types' import { describeRoute as core_describeRoute, type DescribeRouteOptions, resolver, type ResponsesWithResolver, uniqueSymbol, validator, } from 'hono-openapi' import * as z from 'zod/mini' import * as Response from './Response.js' import * as Schema from './Schema.js' /** * `toJSONSchema` override that re-applies `examples` zod strips from * pipe/default schemas in `io: 'input'` mode. zod intentionally drops * `examples` (and `default`) from the input JSON Schema of a `ZodPipe` * (e.g. `chainId`, `include`, boolean queries) and a `ZodDefault`, so an * `examples` attached via `z.meta(...)` on those wrappers never reaches the * generated parameter. This override runs after conversion and copies the * registered `examples` back onto the node's JSON Schema, so every parameter * surfaces its example regardless of the underlying schema shape. */ function recoverExamples(ctx: { jsonSchema: { examples?: unknown }; zodSchema: unknown }) { if (ctx.jsonSchema.examples !== undefined) return const meta = z.globalRegistry.get(ctx.zodSchema as never) as { examples?: unknown } | undefined if (meta?.examples === undefined) return ctx.jsonSchema.examples = Array.isArray(meta.examples) ? meta.examples : Object.values(meta.examples as Record).map((e) => e.value) } /** * Shared zod→JSON-Schema conversion options applied to every parameter and * response schema. {@link recoverExamples} re-attaches `examples` that zod * strips from pipe/default schemas (e.g. `Schema.Hex`, `Schema.Quantity`), so a * field's `z.meta({ examples })` survives even on a branded pipe output — no * per-field non-pipe schema is needed. Supplying our own `override` opts out of * hono-openapi's default zod date override, so re-add its `unrepresentable: * 'any'` to keep transform/pipe nodes (which have no JSON-Schema form) emitting * `{}` instead of throwing. */ const schemaOptions = { options: { override: recoverExamples, unrepresentable: 'any' } } as const // Re-export the upstream `hono-openapi` API surface used by the app so all // callers go through this local `OpenApi` module. Forwarded as direct named // re-exports because `uniqueSymbol` is a `unique symbol` whose identity is // lost when copied through a `const` declaration. export { type HandlerUniqueProperty, resolver, type ResponsesWithResolver, uniqueSymbol, validator, } from 'hono-openapi' function cloneResponseContainers(spec: DescribeRouteOptions): DescribeRouteOptions { if (!spec.responses) return spec return { ...spec, responses: Object.fromEntries( Object.entries(spec.responses).map(([status, response]) => [ status, !response || !('content' in response) ? response : { ...response, content: Object.fromEntries( Object.entries(response.content ?? {}).map(([mediaType, media]) => [ mediaType, { ...media }, ]), ), }, ]), ), } } /** Describes a route without allowing spec generation to mutate reusable response definitions. */ export function describeRoute(spec: DescribeRouteOptions): MiddlewareHandler { const handler = core_describeRoute(spec) Object.defineProperty(handler, uniqueSymbol, { get: () => ({ spec: cloneResponseContainers(spec) }), }) return handler } /** Emits a named OpenAPI component and uses its `$ref` wherever the schema appears. */ export function component( schema: schema, name: string, ): schema { const cloned = schema.clone() z.globalRegistry.add(cloned, { ...z.globalRegistry.get(schema), ref: name }) return cloned as schema } /** * Standard machine-readable `error.code` values per status, shared API-wide. * The reference enumerates `error.code` to these so generated clients narrow it * to a literal union instead of an open `string`. The values here are shared * defaults; operation-specific codes are supplied through {@link responses.Options}. */ export const errorCodes = { 400: [ 'query_invalid', 'param_invalid', 'body_invalid', 'address_invalid', 'token_invalid', 'symbol_invalid', 'swap_continuation_invalid', 'swap_provider_invalid', 'quote_amount_out_of_range', 'pair_invalid', 'pair_id_invalid', 'transaction_invalid', 'order_invalid', 'block_invalid', 'chain_id_invalid', 'chain_id_unsupported', 'destination_transition_invalid', 'sender_tag_invalid', 'url_invalid', 'api_key_malformed', ], 401: ['api_key_missing', 'api_key_invalid', 'unauthorized'], 403: ['api_key_forbidden', 'api_key_ip_forbidden', 'forbidden'], 404: [ 'not_found', 'token_not_found', 'token_logo_not_found', 'block_not_found', 'transaction_not_found', 'receipt_not_found', 'order_not_found', 'pair_not_found', 'quote_not_available', 'verified_token_not_found', 'webhook_not_found', 'delivery_not_found', 'webhooks_not_enabled', 'billing_not_found', ], 429: ['rate_limit_exceeded', 'payment_required'], 500: ['internal_error'], 501: ['billing_unconfigured'], 502: ['upstream_error'], 504: ['request_timeout'], } as const satisfies Record /** * Reusable response-header definitions, registered under `components.headers` * (see `App.ts`) and `$ref`-erenced from 200/429 responses. The rate-limit * headers reflect the quota the request consumed; they are absent on edge-cache * hits (served before metering) and on `402` payment challenges (owned by mppx, * which sets `WWW-Authenticate`/`Payment-Receipt` instead). */ export const headerComponents = { RateLimitLimit: { description: 'How many requests you may make in the current time window. Not sent on cached responses or `402` payment challenges.', // prettier-ignore schema: { type: 'integer' }, }, RateLimitRemaining: { description: 'How many requests you have left in the current window before you are rate-limited. Not sent on cached responses or `402` payment challenges.', // prettier-ignore schema: { type: 'integer' }, }, RateLimitReset: { description: 'When the current window resets, as a Unix timestamp in seconds. Not sent on cached responses or `402` payment challenges.', // prettier-ignore schema: { type: 'integer' }, }, RateLimitScope: { description: 'Which quota this request counted against (e.g. `data:read`). Not sent on cached responses or `402` payment challenges.', // prettier-ignore schema: { type: 'string' }, }, RetryAfter: { description: 'How many seconds to wait before trying again. Sent with `429` (rate-limited) responses.', schema: { type: 'integer' }, }, TempoRequestId: { description: 'A unique id for this request, returned on every response (and as `requestId` in error bodies). Include it when contacting support so we can find your request.', // prettier-ignore schema: { type: 'string' }, }, WwwAuthenticate: { description: 'On a `402` response, the payment challenge to satisfy. Use it to build the `Authorization: Payment` credential and retry the request.', // prettier-ignore schema: { type: 'string' }, }, } as const const headerRef = (name: keyof typeof headerComponents) => ({ $ref: `#/components/headers/${name}`, }) /** Response headers attached to metered success (2xx) responses. */ export const successHeaders = { 'RateLimit-Limit': headerRef('RateLimitLimit'), 'RateLimit-Remaining': headerRef('RateLimitRemaining'), 'RateLimit-Reset': headerRef('RateLimitReset'), 'RateLimit-Scope': headerRef('RateLimitScope'), 'tempo-request-id': headerRef('TempoRequestId'), } /** Response headers attached to `429` rate-limit responses. */ export const rateLimitHeaders = { ...successHeaders, 'Retry-After': headerRef('RetryAfter'), } /** Representative `requestId` used in error-body examples. */ const exampleRequestId = '0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9' const standardErrorNames = { 400: 'InvalidRequestError', 401: 'AuthenticationError', 403: 'ForbiddenError', 404: 'ResourceNotFoundError', 429: 'RateLimitError', 500: 'InternalError', 501: 'NotImplementedError', 502: 'UpstreamError', 504: 'RequestTimeoutError', } as const satisfies Record const reservedErrorNames = new Set(Object.values(standardErrorNames)) const errorSchemas = new Map() function errorName(status: number, codes: readonly string[]) { const standard = errorCodes[status as keyof typeof errorCodes] if ( standard && standard.length === codes.length && [...standard].sort().every((code, index) => code === codes[index]) ) return standardErrorNames[status as keyof typeof standardErrorNames] const name = `${codes .map((code) => code .split('_') .map((part) => `${part[0]?.toUpperCase()}${part.slice(1)}`) .join(''), ) .join('Or')}Error` if (reservedErrorNames.has(name)) return `${name.slice(0, -'Error'.length)}${status}Error` return name } function errorSchema(status: number, codes: readonly [string, ...string[]] | undefined) { const defaults = errorCodes[status as keyof typeof errorCodes] if (!codes && !defaults) { const cached = errorSchemas.get('*') if (cached) return cached const schema = component(Schema.errorResponse(), 'ErrorResponse') errorSchemas.set('*', schema) return schema } const normalized = [...(codes ?? defaults ?? [])].sort() as [string, ...string[]] const key = normalized.join('\0') const cached = errorSchemas.get(key) if (cached) return cached const schema = component(Schema.errorResponse(normalized), errorName(status, normalized)) errorSchemas.set(key, schema) return schema } function mergeErrorCodes( status: number, codes: readonly string[] | undefined, ): readonly [string, ...string[]] | undefined { if (!codes) return errorCodes[status as keyof typeof errorCodes] if (status !== 400 && status !== 403) return codes.length ? (codes as [string, ...string[]]) : undefined const baseline = status === 400 ? (['api_key_malformed'] as const) : errorCodes[403] return [...new Set([...baseline, ...codes])] as [string, ...string[]] } /** Builds the `application/json` content for a standard error response: the named enumerated-code envelope plus an example body. */ function errorContent( status: number, codes: readonly [string, ...string[]] | undefined, message: string, ) { return { 'application/json': { schema: resolver(errorSchema(status, codes), schemaOptions), example: { error: { code: codes?.[0] ?? 'error', message }, requestId: exampleRequestId }, }, } } /** * `402` payment-challenge response, injected per operation for MPP-enabled * routes during spec generation (see `App.create`). The challenge is * protocol-native (owned by mppx) rather than the JSON error envelope, so it * declares no JSON body — only the `WWW-Authenticate` header. */ export const paymentChallenge = { description: 'Payment required. This endpoint accepts MPP payment, and the request either exceeded free quota or needs a paid request credential. The challenge is protocol-native (handled by mppx), not the JSON error envelope: read the `WWW-Authenticate` header and retry with `Authorization: Payment `. A successful paid response carries `Payment-Receipt`.', headers: { 'WWW-Authenticate': headerRef('WwwAuthenticate') }, } as const type ResponseComponentName = 'InternalError' | 'PaymentRequired' | 'RateLimited' | 'RequestTimeout' /** * Reusable responses registered under `components.responses` (see `App.create`) * and `$ref`-erenced from operations, so the uniform `402`/`429`/`500`/`504` * responses are defined once instead of inlined on every operation. */ export function responseComponents(): ResponsesWithResolver { return { InternalError: { content: errorContent(500, errorCodes[500], 'Internal server error.'), description: 'Internal server error.', }, PaymentRequired: paymentChallenge, RateLimited: { content: errorContent(429, errorCodes[429], 'Rate limit exceeded.'), description: 'Rate limit exceeded. On endpoints that accept MPP, over-quota unpaid requests return `402 Payment Required` instead.', headers: { ...rateLimitHeaders }, }, RequestTimeout: { content: errorContent(504, errorCodes[504], 'Request timed out'), description: 'Request timed out. The request did not complete within the 60-second deadline; retry it.', }, } } const responseRef = (name: ResponseComponentName) => ({ $ref: `#/components/responses/${name}`, }) /** `$ref` to the shared `402` payment-challenge response. */ export const paymentChallengeRef = responseRef('PaymentRequired') /** * Builds a single standard JSON error response with `error.code` enumerated for * the status. For routes whose custom `responses` map can't go through * {@link responses} — non-JSON success bodies (e.g. image, SSE) or proxy routes * with upstream-shaped error bodies. `429`/`500` resolve to the shared * `components.responses` entries. */ export function standardError( status: keyof typeof errorCodes, description: string, codes?: readonly [string, ...string[]], ): ResponsesWithResolver[string] { if (status === 429) return responseRef('RateLimited') if (status === 500) return responseRef('InternalError') if (status === 504) return responseRef('RequestTimeout') return { content: errorContent(status, mergeErrorCodes(status, codes), description), description } } /** * Builds an OpenAPI `responses` map with the API's standard error responses * pre-filled, plus the success response (with rate-limit headers): * * - `400` invalid request, `401` missing/invalid key, `403` forbidden, `429` * rate limited, `500` internal error, `502` upstream failure, and `504` * request timeout on every operation; * - `404`/`409`/`412`/`413`/`415` only when an `errors` entry is set. * * `error.code` is enumerated per status from {@link errorCodes}. Operation-level * 400/403 codes merge with shared auth codes; other overrides replace defaults. * The default `429` and `500` `$ref` shared {@link responseComponents}; an exact * operation-level `429` override is emitted inline with the standard headers. * `402` is injected per operation from the route's MPP policy in `App.ts`. */ export function responses( options: responses.Options, ): ResponsesWithResolver { const { errors, success } = options const error = (status: number, fallback: string) => { const override = errors?.[status as 400] const description = typeof override === 'string' ? override : (override?.description ?? fallback) const codes = mergeErrorCodes(status, typeof override === 'object' ? override.codes : undefined) return { content: errorContent(status, codes, description), description, } } return { 200: { content: { 'application/json': { schema: resolver(success.schema, schemaOptions), ...(success.examples === undefined ? success.example === undefined ? {} : { example: success.example } : { examples: success.examples }), }, }, description: success.description, headers: { ...successHeaders }, }, 400: error(400, 'Invalid request.'), 401: error(401, 'Missing or invalid API key.'), 403: error(403, 'Forbidden.'), ...(errors?.[404] && { 404: error(404, 'Not found.') }), ...(errors?.[409] && { 409: error(409, 'Conflict.') }), ...(errors?.[412] && { 412: error(412, 'Precondition failed.') }), ...(errors?.[413] && { 413: error(413, 'Payload too large.') }), ...(errors?.[415] && { 415: error(415, 'Unsupported media type.') }), 429: errors?.[429] ? { ...error(429, 'Rate limit exceeded.'), headers: { ...rateLimitHeaders } } : responseRef('RateLimited'), 500: responseRef('InternalError'), ...(errors?.[501] && { 501: error(501, 'Not implemented.') }), 502: error(502, 'Upstream data failure.'), 504: responseRef('RequestTimeout'), } } export declare namespace responses { /** * Override for a standard error response: a description string, or an object * adding operation codes. Codes replace defaults except shared 400/403 auth codes. */ type ErrorOverride = | string | { /** Human-readable description of the error response. */ description?: string | undefined /** Operation-specific `error.code` values for this status. */ codes?: readonly string[] | undefined } /** Options for building a standard OpenAPI responses map. */ type Options = { /** * Optional overrides for standard error responses. Setting * `404`/`409`/`412`/`413`/`415` includes that status in the map. */ errors?: | { /** Override for the 400 response. */ 400?: ErrorOverride | undefined /** Override for the 401 response. */ 401?: ErrorOverride | undefined /** Add operation-specific 403 codes or override its description. */ 403?: ErrorOverride | undefined /** Override for the 404 response. Setting this includes 404 in the map. */ 404?: ErrorOverride | undefined /** Override for the 409 response. Setting this includes 409 in the map. */ 409?: ErrorOverride | undefined /** Override for the 412 response. Setting this includes 412 in the map. */ 412?: ErrorOverride | undefined /** Override for the 413 response. Setting this includes 413 in the map. */ 413?: ErrorOverride | undefined /** Override for the 415 response. Setting this includes 415 in the map. */ 415?: ErrorOverride | undefined /** Override the shared 429 response with operation-specific rate-limit codes. */ 429?: ErrorOverride | undefined /** Override for the 501 response. Setting this includes 501 in the map. */ 501?: ErrorOverride | undefined /** Override for the 502 response. */ 502?: ErrorOverride | undefined } | undefined /** 200 response schema and description. */ success: { /** Human-readable description of the success body. */ description: string /** Optional full example body rendered for the 200 response. */ example?: unknown /** Optional named example bodies rendered for the 200 response. */ examples?: Record /** Zod schema describing the 200 response body. */ schema: schema } } } /** * Always false at runtime, but typed as `boolean` so route handlers can include * a never-reached validation-error branch in Hono's inferred response union. */ export const narrowValidation = false as boolean /** Typed validation error response for Hono client inference. Never reached at runtime. */ export function validationError( c: Context, options: validationError.Options, ) { return Response.error(c, { code: options.code, message: options.message, status: 400 }) } export declare namespace validationError { /** Options for building a typed validation error response. */ type Options = { /** Stable machine-readable validation error code. */ code: code /** Human-readable validation error message. */ message: string } } /** * Wrapper around `hono-openapi`'s `validator` that returns the API's standard * 400 error envelope (with structured validation details) when parsing fails. * * Use this for every route validator instead of repeating the `result.success` * ceremony per route. */ export function validate< const target extends keyof ValidationTargets, const schema extends z.ZodMiniType, >( target: target, schema: schema, options: validate.Options, ): MiddlewareHandler> { return validator( target, schema, (result, c) => { if (result.success) return undefined return Response.error(c, { code: options.code, details: Response.validationDetails(result.error), message: options.message, status: 400, }) }, // Recover `examples` zod strips from pipe/default input schemas, so every // generated parameter surfaces the example attached via `z.meta(...)`. schemaOptions, ) as never } export declare namespace validate { /** Hono input attached by the standard OpenAPI validator wrapper. */ type Input = { in: RequestInput> out: { [key in target]: z.output } } /** Options for the standard validator helper. */ type Options = { /** Stable machine-readable error code (e.g. `query_invalid`). */ code: string /** Human-readable error message. */ message: string } /** Request input accepted by `hono/client` for a validation target. */ type RequestInput = target extends 'query' ? input extends object ? HasRequiredKeys extends true ? { query: TargetInput<'query', input> } : { query?: TargetInput<'query', input> | undefined } : { query: input } : { [key in target]: TargetInput } /** Converts schema input into the fetch-client input shape for a target. */ type TargetInput< target extends keyof ValidationTargets, input, > = input extends ValidationTargets[target] ? input : { [key in keyof input]: key extends keyof ValidationTargets[target] ? ValidationTargets[target][key] : never } }