diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index af58a3a..5ba6936 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -91,10 +91,16 @@ jobs: fi echo "RELEASE_VERSION=$version" >> "$GITHUB_ENV" - - name: Publish to npm with Trusted Publishing - run: npm publish --access public + - name: Publish package + run: | + set -euo pipefail + if npm view "@openmirai/openapi-codegen@${RELEASE_VERSION}" version >/dev/null 2>&1; then + echo "@openmirai/openapi-codegen@${RELEASE_VERSION} is already published" + else + npm publish --access public + fi - - name: Create GitHub Release + - name: Create release env: GH_TOKEN: ${{ github.token }} run: | @@ -103,7 +109,14 @@ jobs: echo "GitHub release v${RELEASE_VERSION} already exists" exit 0 fi - gh release create "v${RELEASE_VERSION}" \ - --title "v${RELEASE_VERSION}" \ - --generate-notes \ - --verify-tag + release_args=( + "v${RELEASE_VERSION}" + --title "v${RELEASE_VERSION}" + --generate-notes + ) + if [[ "$RELEASE_REF" == refs/tags/* || "$RELEASE_REF" == v* ]]; then + release_args+=(--verify-tag) + else + release_args+=(--target "$RELEASE_REF") + fi + gh release create "${release_args[@]}" diff --git a/README.md b/README.md index db8fb99..8d87ff1 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,7 @@ Re-exported types from the package root: | `tanstackQuery` | Emit Query helpers when `query-scope.ts` exists | | `importBase` | Force import prefix for generated function files (overrides tsconfig aliases) | | `maxRenderDepth` / `resolveMapKeyRefs` | Schema renderer limits | +| `unwrapResponseData` | Emit an envelope's `data` schema as the operation response type when the project's `HTTPFetch` already unwraps envelopes | ### 3. Spec resolution (first match wins) @@ -168,6 +169,15 @@ Inferred from success response schemas. Details: [docs/envelope.md](docs/envelop | **raw** | No shared envelope | Spec schema as-is | | **mixed** | Some ops have `data`, others do not | Unwrap **per operation** when `data` exists | +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, +matching clients that normalize an omitted payload to `null`. +The default remains envelope-preserving and is compatible with the bundled +Axios and Fetch adapters. + ### 5. HTTPFetch (`http.ts`) Adapters implement `HTTPFetch` from `@openmirai/openapi-codegen/http` (or the axios/fetch adapter packages). Methods return `Promise<{ data: TResponse }>`. diff --git a/docs/envelope.md b/docs/envelope.md index 6b21ab4..cddf47c 100644 --- a/docs/envelope.md +++ b/docs/envelope.md @@ -38,6 +38,15 @@ Example from `test/fixtures/specs/mixed-envelope.json`: Callers still return the HTTPFetch `{ data }` payload (the transport wrapper), not a TypeScript `as` cast. Envelope unwrap is a **type** concern: `TResponse` is `BaseResponse` or the raw body, depending on the operation. +## 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. +Envelope objects composed through component references and `allOf` are +recognized without changing their source schemas. + ## accept-base `openapi-codegen accept-base --source atlas` regenerates `generated/base.ts` and rewrites `BaseResponse` in `models.ts` to match the spec. Use it when the envelope shape in the spec is the source of truth and `models.ts` is stale. Do not combine with `--check`. diff --git a/package.json b/package.json index cd2ed55..57aa95a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@openmirai/openapi-codegen", - "version": "0.1.5", + "version": "0.1.6", "description": "Headless OpenAPI to TypeScript codegen CLI and HTTPFetch runtime", "homepage": "https://github.com/openmirai/mirai-openapi-codegen#readme", "bugs": { diff --git a/src/config/__tests__/load.test.ts b/src/config/__tests__/load.test.ts index d0d093f..f1d7d3f 100644 --- a/src/config/__tests__/load.test.ts +++ b/src/config/__tests__/load.test.ts @@ -112,6 +112,7 @@ export default defineSourceConfig({ generationMode: "merge", naming: "operationId", tanstackQuery: true, + unwrapResponseData: true, importBase: "@acme/api/generated", maxRenderDepth: 42, queryExtends: { @@ -133,6 +134,7 @@ export default defineSourceConfig({ expect(config.generationMode).toBe("merge"); expect(config.naming).toBe("operationId"); expect(config.tanstackQuery).toBe(true); + expect(config.unwrapResponseData).toBe(true); expect(config.importBase).toBe("@acme/api/generated"); expect(config.maxRenderDepth).toBe(42); expect(config.queryExtends?.paginationTypeName).toBe("OffsetLimitQuery"); diff --git a/src/config/load.ts b/src/config/load.ts index 93b3231..f70c06e 100644 --- a/src/config/load.ts +++ b/src/config/load.ts @@ -121,6 +121,10 @@ function parseSourceConfigContent(content: string): SourceConfig { config.resolveMapKeyRefs = false; } + if (/unwrapResponseData:\s*true/.test(normalized)) { + config.unwrapResponseData = true; + } + if (/tanstackQuery:\s*true/.test(normalized)) { config.tanstackQuery = true; } diff --git a/src/config/types.ts b/src/config/types.ts index 1507378..814bf77 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -36,6 +36,12 @@ export interface SourceConfig { naming?: NamingStrategy; maxRenderDepth?: number; resolveMapKeyRefs?: boolean; + /** + * Emit an envelope's `data` schema as the operation response type. + * Enable this only when the project's HTTPFetch implementation already + * unwraps response envelopes before returning its `{ data }` value. + */ + unwrapResponseData?: boolean; queryExtends?: QueryExtendsConfig; /** When true, emit TanStack Query helpers for GET endpoints (requires query-scope.ts). */ tanstackQuery?: boolean; diff --git a/src/emitters/__tests__/resolve-schema.test.ts b/src/emitters/__tests__/resolve-schema.test.ts index 9b3537f..b63237c 100644 --- a/src/emitters/__tests__/resolve-schema.test.ts +++ b/src/emitters/__tests__/resolve-schema.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; -import { refNameFromSchema, resolveRef } from "../resolve-schema"; +import { + refNameFromSchema, + resolveObjectSchema, + resolveRef, +} from "../resolve-schema"; import type { IRSchema } from "../../parser/types"; describe("resolve-schema", () => { @@ -40,4 +44,48 @@ describe("resolve-schema", () => { ).toBe("Item"); expect(refNameFromSchema({ kind: "string" })).toBeUndefined(); }); + + it("flattens nested refs and allOf object schemas", () => { + const schemas: Record = { + Data: { + kind: "object", + properties: { + data: { required: true, schema: { kind: "string" } }, + }, + }, + Envelope: { + allOf: [ + { kind: "ref", ref: "#/components/schemas/Data" }, + { + kind: "object", + properties: { + success: { required: true, schema: { kind: "boolean" } }, + }, + }, + ], + kind: "allOf", + }, + }; + + const resolved = resolveObjectSchema( + { kind: "ref", ref: "#/components/schemas/Envelope" }, + schemas + ); + expect(resolved?.properties).toHaveProperty("data"); + expect(resolved?.properties).toHaveProperty("success"); + }); + + it("terminates cyclic component references", () => { + const schemas: Record = { + A: { kind: "ref", ref: "#/components/schemas/B" }, + B: { kind: "ref", ref: "#/components/schemas/A" }, + }; + + expect( + resolveObjectSchema( + { kind: "ref", ref: "#/components/schemas/A" }, + schemas + ) + ).toBeUndefined(); + }); }); diff --git a/src/emitters/resolve-schema.ts b/src/emitters/resolve-schema.ts index bebe92f..53f1b96 100644 --- a/src/emitters/resolve-schema.ts +++ b/src/emitters/resolve-schema.ts @@ -16,6 +16,75 @@ export function resolveRef( return components[refName]; } +/** + * Resolve an object schema through component references and `allOf` composition. + * The returned object is a new flattened view; component schemas are never mutated. + */ +export function resolveObjectSchema( + schema: IRSchema, + components: Record, + visitedRefs: ReadonlySet = new Set() +): IRSchema | undefined { + if (schema.kind === "object") { + return schema; + } + + if (schema.kind === "ref") { + const refName = refNameFromSchema(schema); + if (refName === undefined || visitedRefs.has(refName)) { + return undefined; + } + const resolved = components[refName]; + if (resolved === undefined) { + return undefined; + } + return resolveObjectSchema( + resolved, + components, + new Set([...visitedRefs, refName]) + ); + } + + if (schema.kind !== "allOf" || schema.allOf === undefined) { + return undefined; + } + + const properties: NonNullable = {}; + const required = new Set(); + for (const member of schema.allOf) { + const resolved = resolveObjectSchema(member, components, visitedRefs); + if (resolved?.properties === undefined) { + return undefined; + } + for (const [name, property] of Object.entries(resolved.properties)) { + const existing = properties[name]; + properties[name] = + existing === undefined + ? { ...property } + : { + required: existing.required || property.required, + schema: + JSON.stringify(existing.schema) === + JSON.stringify(property.schema) + ? existing.schema + : { + allOf: [existing.schema, property.schema], + kind: "allOf", + }, + }; + if (property.required) { + required.add(name); + } + } + } + + return { + kind: "object", + properties, + required: [...required], + }; +} + export function refNameFromSchema(schema: IRSchema): string | undefined { if (schema.kind !== "ref" || schema.ref === undefined) { return undefined; diff --git a/src/emitters/types/index.ts b/src/emitters/types/index.ts index a047250..b676563 100644 --- a/src/emitters/types/index.ts +++ b/src/emitters/types/index.ts @@ -12,7 +12,7 @@ import type { TsconfigPathsConfig } from "../../utils/tsconfig-paths"; import { resolveAliasAwareImport } from "../../utils/imports"; import { renderSchemaType } from "../schema-renderer"; import type { SchemaRenderContext } from "../schema-renderer"; -import { resolveRef } from "../resolve-schema"; +import { resolveObjectSchema } from "../resolve-schema"; import { getFunctionTypeName, getSuccessResponseSchema, @@ -29,6 +29,7 @@ export interface TypesEmitterOptions { queryExtends?: QueryExtendsConfig; maxRenderDepth?: number; resolveMapKeyRefs?: boolean; + unwrapResponseData?: boolean; typesDir: string; baseFile: string; tsconfigPaths?: TsconfigPathsConfig; @@ -219,7 +220,7 @@ function resolveSuccessResponseSchema( schema: NonNullable>, components: IRSource["components"]["schemas"] ) { - return schema.kind === "ref" ? resolveRef(schema, components) : schema; + return resolveObjectSchema(schema, components) ?? schema; } function renderResponseType( @@ -243,6 +244,21 @@ function renderResponseType( resolved.kind === "object" && resolved.properties?.data !== undefined ? resolved.properties.data.schema : undefined; + const isSuccessEnvelope = + resolved.kind === "object" && + resolved.properties !== undefined && + resolved.properties.success !== undefined; + if (options.unwrapResponseData === true && isSuccessEnvelope) { + if (dataSchema === undefined) { + return `export type ${typeName}Response = null;`; + } + const dataType = renderSchemaType( + dataSchema, + createRenderContext(options, `${typeName}Response.data`, knownTypeImports) + ); + return `export type ${typeName}Response = ${dataType};`; + } + if (dataSchema !== undefined) { const usesBaseResponse = _envelopeMode === "shared" || diff --git a/src/envelope-guard/index.ts b/src/envelope-guard/index.ts index 4277b73..07e4c7e 100644 --- a/src/envelope-guard/index.ts +++ b/src/envelope-guard/index.ts @@ -1,5 +1,5 @@ import type { IRSchema, IRSchemaProperty, IRSource } from "../parser/types"; -import { resolveRef } from "../emitters/resolve-schema"; +import { resolveObjectSchema, resolveRef } from "../emitters/resolve-schema"; import { isSuccessStatusCode, schemaKindLabel } from "../utils/naming"; export type EnvelopeMode = "shared" | "raw" | "mixed"; @@ -58,8 +58,8 @@ function extractEnvelopeShape( return undefined; } - const resolved = resolveSchema(schema, components); - if (resolved.kind !== "object" || resolved.properties === undefined) { + const resolved = resolveObjectSchema(schema, components); + if (resolved?.properties === undefined) { return undefined; } diff --git a/src/generate/index.ts b/src/generate/index.ts index 5e3fd30..29286c9 100644 --- a/src/generate/index.ts +++ b/src/generate/index.ts @@ -242,6 +242,10 @@ export async function generateForSource( typeEmitterOptions.resolveMapKeyRefs = context.sourceConfig.resolveMapKeyRefs; } + if (context.sourceConfig.unwrapResponseData !== undefined) { + typeEmitterOptions.unwrapResponseData = + context.sourceConfig.unwrapResponseData; + } if (primaryEnvelope !== undefined) { typeEmitterOptions.sharedEnvelope = primaryEnvelope; } diff --git a/test/fixtures/specs/mixed-envelope.json b/test/fixtures/specs/mixed-envelope.json index 4b64cdd..c0ff0a9 100644 --- a/test/fixtures/specs/mixed-envelope.json +++ b/test/fixtures/specs/mixed-envelope.json @@ -1,6 +1,25 @@ { "openapi": "3.0.0", "info": { "title": "Acme Mixed Envelope API", "version": "3.0.0" }, + "components": { + "schemas": { + "SuccessMeta": { + "type": "object", + "properties": { "success": { "type": "boolean" } }, + "required": ["success"] + }, + "ComposedEnvelope": { + "allOf": [ + { "$ref": "#/components/schemas/SuccessMeta" }, + { + "type": "object", + "properties": { "data": { "type": "number" } }, + "required": ["data"] + } + ] + } + } + }, "paths": { "/api/acme/v3/widgets": { "get": { @@ -72,6 +91,41 @@ } } } + }, + "/api/acme/v3/empty": { + "post": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { "type": "boolean" }, + "message": { "type": "string" } + }, + "required": ["success"] + } + } + } + } + } + } + }, + "/api/acme/v3/composed": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ComposedEnvelope" + } + } + } + } + } + } } } } diff --git a/test/integration/generate.test.ts b/test/integration/generate.test.ts index fb722a1..acf37b9 100644 --- a/test/integration/generate.test.ts +++ b/test/integration/generate.test.ts @@ -176,6 +176,60 @@ describe("integration: monolith generate", () => { expect(secondaryEnvelopeType).toContain("cursor?: string"); }); + it("emits payload response types for HTTP clients that unwrap envelopes", async () => { + const root = join(fixtureRoot, "layouts", `mixed-payload-${Date.now()}`); + tempRoots.push(root); + const { generatedDir } = createMonolithProject({ + root, + sourceConfig: `export default { + pathPrefix: "/api/acme/v3", + unwrapResponseData: true, +};`, + sourceKey: "atlas", + specContent: mixedSpec, + }); + + await generateForSource({ cwd: root, sourceKey: "atlas" }); + + const primaryEnvelopeType = readFileSync( + join(generatedDir, "types/api/acme/v3/widgets/GET.d.ts"), + "utf8" + ); + expect(primaryEnvelopeType).toContain( + "export type GETApiAcmeV3WidgetsResponse = string[];" + ); + expect(primaryEnvelopeType).not.toContain("BaseResponse<"); + + const secondaryEnvelopeType = readFileSync( + join(generatedDir, "types/api/acme/v3/secondary/GET.d.ts"), + "utf8" + ); + expect(secondaryEnvelopeType).toContain("data?: string"); + expect(secondaryEnvelopeType).toContain("cursor?: string"); + + const emptyEnvelopeType = readFileSync( + join(generatedDir, "types/api/acme/v3/empty/POST.d.ts"), + "utf8" + ); + expect(emptyEnvelopeType).toContain( + "export type POSTApiAcmeV3EmptyResponse = null;" + ); + + const composedEnvelopeType = readFileSync( + join(generatedDir, "types/api/acme/v3/composed/GET.d.ts"), + "utf8" + ); + expect(composedEnvelopeType).toContain( + "export type GETApiAcmeV3ComposedResponse = number;" + ); + + const rawType = readFileSync( + join(generatedDir, "types/api/acme/v3/plain/[token]/GET.d.ts"), + "utf8" + ); + expect(rawType).toContain("token: string"); + }); + it("fails generate for recursive schemas without known-type override", async () => { const root = join(fixtureRoot, "layouts", `recursive-${Date.now()}`); tempRoots.push(root);