diff --git a/README.md b/README.md index 8d87ff1..41ef85d 100644 --- a/README.md +++ b/README.md @@ -171,9 +171,10 @@ Inferred from success response schemas. Details: [docs/envelope.md](docs/envelop Set `unwrapResponseData: true` when the project's injected `HTTPFetch` normalizes successful envelope bodies before returning `{ data }`. Every -operation whose success schema contains a `success` field then receives its -`data` payload type. Data-only objects in mixed specs remain raw. Success -envelopes without a `data` field receive the `null` type, +operation whose success schema is recognized as an API envelope then receives +its `data` payload type. Data-only objects and business payloads that also +contain `success` remain raw. Metadata-only envelopes without a `data` field +receive the `null` type, matching clients that normalize an omitted payload to `null`. The default remains envelope-preserving and is compatible with the bundled Axios and Fetch adapters. diff --git a/docs/envelope.md b/docs/envelope.md index cddf47c..9fd1013 100644 --- a/docs/envelope.md +++ b/docs/envelope.md @@ -2,7 +2,10 @@ The generator inspects each operation’s success JSON schema and classifies the spec into one of three **envelope modes**. You do not set the mode in `source.ts`; it is inferred. -An object “looks like an envelope” when it has a `data`, `success`, or `message` field. +An object “looks like an envelope” when it has a `data` field, or when it has a +`success` field and every other field is envelope metadata (`error`, `message`, +`requestId`, or `timestamp`). A business payload such as +`{ success, deletedCount, requested }` remains raw. ## shared @@ -41,9 +44,9 @@ Callers still return the HTTPFetch `{ data }` payload (the transport wrapper), n ## HTTP clients that unwrap envelopes Set `unwrapResponseData: true` only when the injected `HTTPFetch` already -normalizes `{ success, data }` bodies. Responses containing `success` emit the -inner `data` payload type, or `null` when `data` is absent. Data-only objects -remain raw, matching clients that use `success` to distinguish an API envelope. +normalizes `{ success, data }` bodies. Recognized envelopes emit the inner +`data` payload type, or `null` when `data` is absent. Business payloads +containing `success` plus domain fields and data-only cursor objects remain raw. Envelope objects composed through component references and `allOf` are recognized without changing their source schemas. diff --git a/package.json b/package.json index 57aa95a..452d635 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@openmirai/openapi-codegen", - "version": "0.1.6", + "version": "0.1.7", "description": "Headless OpenAPI to TypeScript codegen CLI and HTTPFetch runtime", "homepage": "https://github.com/openmirai/mirai-openapi-codegen#readme", "bugs": { diff --git a/src/emitters/types/index.ts b/src/emitters/types/index.ts index b676563..1967698 100644 --- a/src/emitters/types/index.ts +++ b/src/emitters/types/index.ts @@ -1,5 +1,6 @@ import { buildBaseResponseInterface, + isEnvelopeSchema, matchesEnvelopeShape, } from "../../envelope-guard/index"; import type { EnvelopeMode, EnvelopeShape } from "../../envelope-guard/index"; @@ -246,8 +247,8 @@ function renderResponseType( : undefined; const isSuccessEnvelope = resolved.kind === "object" && - resolved.properties !== undefined && - resolved.properties.success !== undefined; + resolved.properties?.success !== undefined && + isEnvelopeSchema(schema, options.source.components.schemas); if (options.unwrapResponseData === true && isSuccessEnvelope) { if (dataSchema === undefined) { return `export type ${typeName}Response = null;`; diff --git a/src/envelope-guard/index.ts b/src/envelope-guard/index.ts index 07e4c7e..eea7a8a 100644 --- a/src/envelope-guard/index.ts +++ b/src/envelope-guard/index.ts @@ -98,9 +98,31 @@ export function matchesEnvelopeShape( return actual !== undefined && fingerprint(actual) === fingerprint(expected); } +const ENVELOPE_METADATA_FIELDS = new Set([ + "error", + "message", + "requestId", + "success", + "timestamp", +]); + function looksLikeEnvelope(shape: EnvelopeShape): boolean { const names = new Set(shape.fields.map((field) => field.name)); - return names.has("data") || names.has("success") || names.has("message"); + if (names.has("data")) { + return true; + } + if (!names.has("success")) { + return false; + } + return [...names].every((name) => ENVELOPE_METADATA_FIELDS.has(name)); +} + +export function isEnvelopeSchema( + schema: IRSchema, + components: Record +): boolean { + const shape = extractEnvelopeShape(schema, components); + return shape !== undefined && looksLikeEnvelope(shape); } export function collectOperationEnvelopes( diff --git a/test/fixtures/specs/mixed-envelope.json b/test/fixtures/specs/mixed-envelope.json index c0ff0a9..19d9bfa 100644 --- a/test/fixtures/specs/mixed-envelope.json +++ b/test/fixtures/specs/mixed-envelope.json @@ -112,6 +112,27 @@ } } }, + "/api/acme/v3/batch-result": { + "delete": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { "type": "boolean" }, + "deletedCount": { "type": "integer" }, + "requested": { "type": "integer" } + }, + "required": ["success"] + } + } + } + } + } + } + }, "/api/acme/v3/composed": { "get": { "responses": { diff --git a/test/integration/generate.test.ts b/test/integration/generate.test.ts index acf37b9..3a4ec5e 100644 --- a/test/integration/generate.test.ts +++ b/test/integration/generate.test.ts @@ -215,6 +215,17 @@ describe("integration: monolith generate", () => { "export type POSTApiAcmeV3EmptyResponse = null;" ); + const rawSuccessPayloadType = readFileSync( + join(generatedDir, "types/api/acme/v3/batch-result/DELETE.d.ts"), + "utf8" + ); + expect(rawSuccessPayloadType).toContain("deletedCount?: number"); + expect(rawSuccessPayloadType).toContain("requested?: number"); + expect(rawSuccessPayloadType).toContain("success: boolean"); + expect(rawSuccessPayloadType).not.toContain( + "DELETEApiAcmeV3BatchResultResponse = null" + ); + const composedEnvelopeType = readFileSync( join(generatedDir, "types/api/acme/v3/composed/GET.d.ts"), "utf8"