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
5 changes: 4 additions & 1 deletion native/src/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!(
Expand Down
19 changes: 17 additions & 2 deletions scripts/renderer-parity-cases.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
6 changes: 3 additions & 3 deletions scripts/renderer-parity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;

Expand Down Expand Up @@ -36,9 +36,9 @@ async function generate(renderer: string, output: string) {
const manifest: Manifest = {};
const routes: Record<string, string> = {};
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.
Expand Down
15 changes: 15 additions & 0 deletions src/generators/core/endpoints/getEndpointsFromOpenAPIDoc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
9 changes: 9 additions & 0 deletions src/generators/core/getMetadataFromOpenAPIDoc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
71 changes: 70 additions & 1 deletion src/native/configuration-lifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 });
}
},
);
});
25 changes: 21 additions & 4 deletions src/native/configuration-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand All @@ -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");
Expand All @@ -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],
Expand Down Expand Up @@ -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");
}
}
});
8 changes: 8 additions & 0 deletions test/petstore.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Loading