From 96495767afeebf1d42cd5a57cd707f80ea9d8d6c Mon Sep 17 00:00:00 2001 From: Urban Krepel Date: Fri, 11 Sep 2026 10:06:37 +0200 Subject: [PATCH 1/2] fix: omit empty native shared model files --- native/src/render.rs | 5 +- src/native/configuration-lifecycle.test.ts | 71 +++++++++++++++++++++- 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/native/src/render.rs b/native/src/render.rs index 28eee4f..e3d3993 100644 --- a/native/src/render.rs +++ b/native/src/render.rs @@ -77,7 +77,10 @@ pub fn render_model_proxies( let (proxies, proxies_elapsed) = proxies.join().unwrap(); (common, proxies, common_elapsed, proxies_elapsed) }); - rendered.insert(options.default_tag.clone(), Value::String(common)); + // Match JavaScript: an empty schema collection emits no shared model file. + if !schemas.is_empty() { + rendered.insert(options.default_tag.clone(), Value::String(common)); + } rendered.extend(proxies); if std::env::var_os("OPENAPI_NATIVE_PROFILE").is_some() { eprintln!( diff --git a/src/native/configuration-lifecycle.test.ts b/src/native/configuration-lifecycle.test.ts index 349f33e..ef7b234 100644 --- a/src/native/configuration-lifecycle.test.ts +++ b/src/native/configuration-lifecycle.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, readFile, rm, stat, utimes, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm, stat, utimes, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { stringify } from "yaml"; @@ -87,3 +87,72 @@ describe("configuration input and output lifecycle", () => { } }); }); + +describe("empty shared models", () => { + test.each(cases.flatMap((entry) => [false, true].map((modelsOnly) => ({ ...entry, modelsOnly }))))( + "$renderer / $format / incremental=$incremental / modelsOnly=$modelsOnly preserves shared enums", + async ({ renderer, format, incremental, modelsOnly }) => { + const directory = await mkdtemp(path.join(os.tmpdir(), "codegen-empty-models-")); + const input = path.join(directory, `schema.${format}`); + const output = path.join(directory, "output"); + const common = path.join(output, "common/common.models.ts"); + const previousNative = process.env.OPENAPI_CODEGEN_NATIVE; + const previousRequired = process.env.OPENAPI_CODEGEN_REQUIRE_FULL_NATIVE; + process.env.OPENAPI_CODEGEN_NATIVE = renderer === "native" ? "1" : "0"; + process.env.OPENAPI_CODEGEN_REQUIRE_FULL_NATIVE = "1"; + try { + const document = { + openapi: "3.0.3", + info: { title: "No models", version: "1" }, + paths: { + "/health": { + get: { + operationId: "health", + tags: ["health"], + responses: { "204": { description: "OK" } }, + }, + }, + }, + }; + await writeFile(input, format === "json" ? JSON.stringify(document) : stringify(document)); + const generate = (clearOutput = false) => + runGenerate({ + fileConfig: { + input, + output, + clearOutput, + incremental, + modelsOnly, + modelsInCommon: true, + acl: false, + mutationEffects: false, + restClientImportPath: "@test/rest", + }, + }); + await generate(); + await expect(stat(common)).rejects.toMatchObject({ code: "ENOENT" }); + if (!modelsOnly) { + expect(await readFile(path.join(output, "health/health.api.ts"), "utf8")).toContain("/health"); + } + await mkdir(path.dirname(common), { recursive: true }); + const existing = 'export enum Status { Ready = "ready" }\n'; + await writeFile(common, existing); + const sentinel = new Date("2001-01-01T00:00:00Z"); + await utimes(common, sentinel, sentinel); + const before = (await stat(common)).mtimeMs; + await generate(); + expect(await readFile(common, "utf8")).toBe(existing); + expect((await stat(common)).mtimeMs).toBe(before); + // Explicit cleanup removes stale generated models, rather than replacing them with an empty module. + await generate(true); + await expect(stat(common)).rejects.toMatchObject({ code: "ENOENT" }); + } finally { + if (previousNative === undefined) delete process.env.OPENAPI_CODEGEN_NATIVE; + else process.env.OPENAPI_CODEGEN_NATIVE = previousNative; + if (previousRequired === undefined) delete process.env.OPENAPI_CODEGEN_REQUIRE_FULL_NATIVE; + else process.env.OPENAPI_CODEGEN_REQUIRE_FULL_NATIVE = previousRequired; + await rm(directory, { recursive: true, force: true }); + } + }, + ); +}); From eb0361f6eec28c5a4153f795b36f8165dba8699b Mon Sep 17 00:00:00 2001 From: Urban Krepel Date: Fri, 11 Sep 2026 10:13:19 +0200 Subject: [PATCH 2/2] test: include schema-free Petstore in CI renderer parity --- scripts/renderer-parity-cases.ts | 19 ++++++++++++-- scripts/renderer-parity.ts | 6 ++--- .../getEndpointsFromOpenAPIDoc.test.ts | 15 +++++++++++ .../core/getMetadataFromOpenAPIDoc.test.ts | 9 +++++++ src/native/configuration-parity.test.ts | 25 ++++++++++++++++--- test/petstore.yaml | 8 ++++++ 6 files changed, 73 insertions(+), 9 deletions(-) diff --git a/scripts/renderer-parity-cases.ts b/scripts/renderer-parity-cases.ts index 695f718..a7ef906 100644 --- a/scripts/renderer-parity-cases.ts +++ b/scripts/renderer-parity-cases.ts @@ -1,10 +1,25 @@ -import { parse } from "yaml"; +import { readFileSync } from "node:fs"; +import { parse, stringify } from "yaml"; import { resolveConfig } from "../src/generators/core/resolveConfig"; import { generateCodeFromOpenAPIDoc } from "../src/generators/generateCodeFromOpenAPIDoc"; import { generateFilesFromNativeOpenAPI } from "../src/native/generateFilesFromNativeOpenAPI"; import { type ParityScenario } from "./renderer-parity-configs"; -export const parityFixtures = ["test/petstore.yaml", "test/configuration.yaml"]; +export const parityFixtures = [ + { name: "petstore", file: "test/petstore.yaml" }, + { name: "configuration", file: "test/configuration.yaml" }, + { name: "petstore-health", file: "test/petstore.yaml", healthOnly: true }, +]; + +export function readParityFixture(fixture: (typeof parityFixtures)[number]) { + const source = readFileSync(fixture.file, "utf8"); + if (!fixture.healthOnly) return source; + const document = parse(source); + const health = document.paths["/health"]; + if (!health) throw new Error("Petstore health parity endpoint is missing"); + // A schema-free Petstore slice exercises omission across every configuration. + return stringify({ ...document, paths: { "/health": health }, components: {} }); +} export function renderParityCase(source: string, scenario: ParityScenario, renderer: "js" | "native") { let options; diff --git a/scripts/renderer-parity.ts b/scripts/renderer-parity.ts index 9f786ae..e59281b 100644 --- a/scripts/renderer-parity.ts +++ b/scripts/renderer-parity.ts @@ -2,7 +2,7 @@ import { createHash } from "node:crypto"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { parityScenarios } from "./renderer-parity-configs"; -import { parityFixtures, renderParityCase } from "./renderer-parity-cases"; +import { parityFixtures, readParityFixture, renderParityCase } from "./renderer-parity-cases"; export type Manifest = Record; @@ -36,9 +36,9 @@ async function generate(renderer: string, output: string) { const manifest: Manifest = {}; const routes: Record = {}; for (const fixture of parityFixtures) { - const source = await readFile(fixture, "utf8"); + const source = readParityFixture(fixture); for (const scenario of parityScenarios) { - const prefix = `${path.basename(fixture, ".yaml")}/${scenario.name}`; + const prefix = `${fixture.name}/${scenario.name}`; const { files, route } = renderParityCase(source, scenario, renderer); routes[prefix] = route; // Include even deliberately empty and rejected cases in the hash contract. diff --git a/src/generators/core/endpoints/getEndpointsFromOpenAPIDoc.test.ts b/src/generators/core/endpoints/getEndpointsFromOpenAPIDoc.test.ts index 7df9af1..da8baa0 100644 --- a/src/generators/core/endpoints/getEndpointsFromOpenAPIDoc.test.ts +++ b/src/generators/core/endpoints/getEndpointsFromOpenAPIDoc.test.ts @@ -898,6 +898,21 @@ describe("getEndpointsFromOpenAPIDoc", () => { const resolver = new SchemaResolver(openApiDoc, generateOptions); const endpoints = getEndpointsFromOpenAPIDoc(resolver); expect(endpoints).toEqual([ + { + description: undefined, + summary: "No-model response for empty shared model parity coverage", + errors: [], + method: "get", + operationName: "getHealth", + parameters: [], + path: "/health", + tags: ["Health"], + mediaDownload: false, + mediaUpload: false, + requestFormat: "application/json", + response: "z.void()", + responseStatusCodes: ["204"], + }, ...[ ["EmailAdmin", "email", "EmailActivityAdminResponse"], ["PushNotificationAdmin", "push", "PushNotificationActivityAdminResponse"], diff --git a/src/generators/core/getMetadataFromOpenAPIDoc.test.ts b/src/generators/core/getMetadataFromOpenAPIDoc.test.ts index daa65f5..411a112 100644 --- a/src/generators/core/getMetadataFromOpenAPIDoc.test.ts +++ b/src/generators/core/getMetadataFromOpenAPIDoc.test.ts @@ -247,6 +247,15 @@ describe("getMetadataFromOpenAPIDoc", () => { ]; const queries: QueryMetadata[] = [ + { + name: "useGet", + namespace: "HealthQueries", + importPath: "health/health.queries", + isMutation: false, + isQuery: true, + params: [], + response: { metaType: "primitive", type: "void" }, + }, { name: "useReadActivity", importPath: "emailAdmin/emailAdmin.queries", diff --git a/src/native/configuration-parity.test.ts b/src/native/configuration-parity.test.ts index 908e90e..3412617 100644 --- a/src/native/configuration-parity.test.ts +++ b/src/native/configuration-parity.test.ts @@ -2,7 +2,7 @@ import { parse, stringify } from "yaml"; import { readFileSync } from "node:fs"; import { afterEach, describe, expect, test, vi } from "vitest"; import { parityScenarios, optionCases, lifecycleOptions } from "../../scripts/renderer-parity-configs"; -import { parityFixtures, renderParityCase } from "../../scripts/renderer-parity-cases"; +import { parityFixtures, readParityFixture, renderParityCase } from "../../scripts/renderer-parity-cases"; import { getNativeBindings } from "./native-bindings"; afterEach(() => { @@ -22,8 +22,8 @@ describe("configuration coverage inventory", () => { }); for (const fixture of parityFixtures) { - const source = readFileSync(fixture, "utf8"); - describe(`all renderer configurations: ${fixture}`, () => { + const source = readParityFixture(fixture); + describe(`all renderer configurations: ${fixture.name}`, () => { test.each(parityScenarios)("$name", (scenario) => { vi.stubEnv("OPENAPI_CODEGEN_NATIVE", "1"); const binding = vi.spyOn(getNativeBindings(), "compileData"); @@ -44,7 +44,7 @@ for (const fixture of parityFixtures) { test("canonical layouts retain full native rendering and local namespaces use native hybrid", () => { vi.stubEnv("OPENAPI_CODEGEN_NATIVE", "1"); - const source = readFileSync(parityFixtures[0], "utf8"); + const source = readParityFixture(parityFixtures[0]); for (const [tsNamespaces, modelsInCommon] of [ [true, true], [false, false], @@ -117,3 +117,20 @@ test("model owner resolution preserves model-like text inside validation regexes expect(api(expected.files)).toContain(".regex(/EmailAdminModels.BaseLogLevelEnumSchema/)"); expect(api(actual.files)).toBe(api(expected.files)); }); + +test.each([false, true])("Petstore empty shared model scenario is active with modelsOnly=%s", (modelsOnly) => { + const source = readParityFixture(parityFixtures.find((fixture) => fixture.name === "petstore-health")!); + const scenario = parityScenarios.find((entry) => entry.name === `layout-${modelsOnly ? "010111" : "000111"}`)!; + expect(scenario).toBeDefined(); + for (const renderer of ["js", "native"] as const) { + vi.stubEnv("OPENAPI_CODEGEN_NATIVE", renderer === "native" ? "1" : "0"); + const result = renderParityCase(source, scenario, renderer); + expect(result.route).toBe(renderer === "native" ? "full-native" : "js"); + expect(result.files.some((file) => file.fileName.endsWith("common.models.ts"))).toBe(false); + if (modelsOnly) { + expect(result.files.every((file) => file.fileName.endsWith(".models.ts"))).toBe(true); + } else { + expect(result.files.find((file) => file.fileName.endsWith("health.api.ts"))?.content).toContain("/health"); + } + } +}); diff --git a/test/petstore.yaml b/test/petstore.yaml index 92571c4..26bdd3b 100644 --- a/test/petstore.yaml +++ b/test/petstore.yaml @@ -38,6 +38,14 @@ tags: - name: user description: Operations about user paths: + /health: + get: + tags: [Health] + summary: No-model response for empty shared model parity coverage + operationId: getHealth + responses: + "204": + description: Healthy /activity/email: get: tags: [EmailAdmin]