From 63c321e2897f09479dfb0ecd3e0af15b44f610db Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Wed, 8 Jul 2026 17:55:25 +0530 Subject: [PATCH] feat(generator): emit all documented responses via Tuyau rawResponse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getResponses() previously read only the `response` type and hardcoded it under status 200. As a result error responses (401/403/422/…) were never emitted, non-200 successes (201) were mislabeled 200, and empty-body 204s were dropped entirely. Drive responses from Tuyau's normalized `rawResponse` union ({ status; response } across all status codes): split the union, read each member's instantiated status literal and body, group by status, and emit one response per code (oneOf when a code has multiple shapes; no content for empty 204 bodies). Falls back to the legacy `response`→200 path when `rawResponse` is absent, so older @tuyau/core registries keep working. Status/response literals are read via getTypeAtLocation so the instantiated value (201 / concrete body) is used rather than the generic ExtractRawResponse type parameters. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01P3ZYnmGMEZwTuBuZviLDSq --- packages/generator/src/context.ts | 8 +++ packages/generator/src/route.ts | 102 ++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+) diff --git a/packages/generator/src/context.ts b/packages/generator/src/context.ts index 95f83d0..94a0e9a 100644 --- a/packages/generator/src/context.ts +++ b/packages/generator/src/context.ts @@ -24,6 +24,14 @@ export class Context { } + /** + * The anchor node (the registry interface) used to resolve the instantiated + * type of a symbol via `getTypeAtLocation`. + */ + get node() { + return this.data.node + } + child(type: TsMorph.Type) { return new Context(type, this.data, this.depth + 1) } diff --git a/packages/generator/src/route.ts b/packages/generator/src/route.ts index 377af9a..d2085e6 100644 --- a/packages/generator/src/route.ts +++ b/packages/generator/src/route.ts @@ -15,6 +15,39 @@ const routeType = { response: /^[a-zA-Z<]*import\((?:'|")([^']+)(?:'|")\)\.default\[(?:'|")(\w+)(?:'|")\][a-zA-Z>]*$/, } +const STATUS_TEXT: Record = { + 200: 'OK', + 201: 'Created', + 202: 'Accepted', + 203: 'Non-Authoritative Information', + 204: 'No Content', + 205: 'Reset Content', + 206: 'Partial Content', + 300: 'Multiple Choices', + 301: 'Moved Permanently', + 302: 'Found', + 304: 'Not Modified', + 307: 'Temporary Redirect', + 400: 'Bad Request', + 401: 'Unauthorized', + 402: 'Payment Required', + 403: 'Forbidden', + 404: 'Not Found', + 405: 'Method Not Allowed', + 406: 'Not Acceptable', + 409: 'Conflict', + 410: 'Gone', + 413: 'Payload Too Large', + 415: 'Unsupported Media Type', + 422: 'Unprocessable Entity', + 429: 'Too Many Requests', + 500: 'Internal Server Error', + 501: 'Not Implemented', + 502: 'Bad Gateway', + 503: 'Service Unavailable', + 504: 'Gateway Timeout', +} + export class Route { constructor( public name: string, @@ -114,6 +147,14 @@ export class Route { } private async getResponses(): Promise { + // Prefer Tuyau's normalized `rawResponse` union ({ status; response } across + // all status codes) so every documented response — 201/204 successes and + // 4xx errors alike — is emitted with its real code and body. + const responses = await this.getRawResponses() + if (responses) return responses + + // Legacy fallback for registries generated before `rawResponse` existed: + // a single 200 built from `response`. const { type } = this.context.property('response') if (type.isUnknown()) return @@ -131,6 +172,67 @@ export class Route { } } + private async getRawResponses(): Promise { + let raw + try { + raw = this.context.property('rawResponse') + } catch { + return + } + + if (raw.type.isUnknown()) return + + const node = this.context.node + const byStatus = new Map() + + for (const member of this.context.unionToArray(raw.type)) { + const statusSym = member.getProperty('status') + if (!statusSym) continue + + // Read the INSTANTIATED literal via getTypeAtLocation — a value + // declaration would return the generic parameter (`S`/`R`) from the + // ExtractRawResponse definition instead of the concrete `201`/body. + const statusType = statusSym.getTypeAtLocation(node) + const responseSym = member.getProperty('response') + const responseType = responseSym?.getTypeAtLocation(node) + + for (const status of this.context.unionToArray(statusType)) { + if (!status.isNumberLiteral()) continue + + const code = status.getLiteralValue() as number + if (!byStatus.has(code)) byStatus.set(code, []) + + if (responseType && this.isBodyfulResponse(responseType)) { + byStatus.get(code)!.push(await this.context.toSchema(responseType)) + } + } + } + + if (!byStatus.size) return + + const responses: OpenAPIV3_1.ResponsesObject = {} + + for (const [code, schemas] of byStatus) { + const response: OpenAPIV3_1.ResponseObject = { description: STATUS_TEXT[code] ?? String(code) } + + if (schemas.length === 1) { + response.content = { 'application/json': { schema: schemas[0] as any } } + } else if (schemas.length > 1) { + response.content = { 'application/json': { schema: { oneOf: schemas } as any } } + } + + responses[code] = response + } + + return responses + } + + private isBodyfulResponse(type: Context['type']): boolean { + if (type.isUnknown() || type.isNever() || type.isVoid() || type.isUndefined()) return false + if (type.isObject() && !type.isArray() && type.getProperties().length === 0) return false + return true + } + private getPathParameters(): OpenAPIV3_1.ParameterObject[] { const names = [...this.data.pattern.matchAll(/:(\w+)/g)].map((item) => item[1]!) return names.map((name) => ({