Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 20 additions & 7 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand All @@ -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[@]}"
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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 }>`.
Expand Down
9 changes: 9 additions & 0 deletions docs/envelope.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>` 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`.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
2 changes: 2 additions & 0 deletions src/config/__tests__/load.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ export default defineSourceConfig({
generationMode: "merge",
naming: "operationId",
tanstackQuery: true,
unwrapResponseData: true,
importBase: "@acme/api/generated",
maxRenderDepth: 42,
queryExtends: {
Expand All @@ -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");
Expand Down
4 changes: 4 additions & 0 deletions src/config/load.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
6 changes: 6 additions & 0 deletions src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
50 changes: 49 additions & 1 deletion src/emitters/__tests__/resolve-schema.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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<string, IRSchema> = {
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<string, IRSchema> = {
A: { kind: "ref", ref: "#/components/schemas/B" },
B: { kind: "ref", ref: "#/components/schemas/A" },
};

expect(
resolveObjectSchema(
{ kind: "ref", ref: "#/components/schemas/A" },
schemas
)
).toBeUndefined();
});
});
69 changes: 69 additions & 0 deletions src/emitters/resolve-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, IRSchema>,
visitedRefs: ReadonlySet<string> = 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<IRSchema["properties"]> = {};
const required = new Set<string>();
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;
Expand Down
20 changes: 18 additions & 2 deletions src/emitters/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -29,6 +29,7 @@ export interface TypesEmitterOptions {
queryExtends?: QueryExtendsConfig;
maxRenderDepth?: number;
resolveMapKeyRefs?: boolean;
unwrapResponseData?: boolean;
typesDir: string;
baseFile: string;
tsconfigPaths?: TsconfigPathsConfig;
Expand Down Expand Up @@ -219,7 +220,7 @@ function resolveSuccessResponseSchema(
schema: NonNullable<ReturnType<typeof getSuccessResponseSchema>>,
components: IRSource["components"]["schemas"]
) {
return schema.kind === "ref" ? resolveRef(schema, components) : schema;
return resolveObjectSchema(schema, components) ?? schema;
}

function renderResponseType(
Expand All @@ -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" ||
Expand Down
6 changes: 3 additions & 3 deletions src/envelope-guard/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -58,8 +58,8 @@
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;
}

Expand Down Expand Up @@ -232,10 +232,10 @@
if (plain === null) {
return undefined;
}
return parseBaseResponseBody(plain[1]!);

Check warning on line 235 in src/envelope-guard/index.ts

View workflow job for this annotation

GitHub Actions / publish

typescript(no-non-null-assertion)

Forbidden non-null assertion.

Check warning on line 235 in src/envelope-guard/index.ts

View workflow job for this annotation

GitHub Actions / Check repository

typescript(no-non-null-assertion)

Forbidden non-null assertion.
}

return parseBaseResponseBody(match[1]!);

Check warning on line 238 in src/envelope-guard/index.ts

View workflow job for this annotation

GitHub Actions / publish

typescript(no-non-null-assertion)

Forbidden non-null assertion.

Check warning on line 238 in src/envelope-guard/index.ts

View workflow job for this annotation

GitHub Actions / Check repository

typescript(no-non-null-assertion)

Forbidden non-null assertion.
}

function parseBaseResponseBody(body: string): UserBaseResponseShape {
Expand Down
4 changes: 4 additions & 0 deletions src/generate/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Loading
Loading