From 3cc7c674e3de7083f9007713b4f799d34576829d Mon Sep 17 00:00:00 2001 From: Urban Krepel Date: Thu, 10 Sep 2026 14:20:12 +0200 Subject: [PATCH 1/2] fix: import model namespaces in native module-local output --- native/src/render.rs | 78 ++++++++++++++---- native/src/zod.rs | 12 ++- package.json | 2 +- scripts/renderer-parity.ts | 34 ++++++-- .../getEndpointsFromOpenAPIDoc.test.ts | 28 +++++++ .../core/getMetadataFromOpenAPIDoc.test.ts | 52 ++++++++++++ src/native/model-imports.test.ts | 79 +++++++++++++++++++ test/petstore.yaml | 49 ++++++++++++ 8 files changed, 306 insertions(+), 28 deletions(-) create mode 100644 src/native/model-imports.test.ts diff --git a/native/src/render.rs b/native/src/render.rs index 450b9c74..33422485 100644 --- a/native/src/render.rs +++ b/native/src/render.rs @@ -52,6 +52,7 @@ pub fn render_model_proxies( document, schemas, schema_refs, + schema_owners, generated_objects, circular_schemas, &options.default_tag, @@ -217,7 +218,11 @@ fn render_local_models( let namespace_prefixes = schema_owners .values() .filter_map(Value::as_str) - .map(|owner| format!("{}Models.", capitalize(owner))) + .map(|owner| { + let suffix = options.configs.get("models") + .map(|config| config.namespace_suffix.as_str()).unwrap_or("Models"); + format!("{}{suffix}.", capitalize(owner)) + }) .collect::>(); for value in tag_schemas.values_mut() { let Value::String(code) = value else { @@ -232,6 +237,7 @@ fn render_local_models( document, &tag_schemas, schema_refs, + schema_owners, generated_objects, circular_schemas, &tag, @@ -243,7 +249,14 @@ fn render_local_models( let owner_tag = decapitalize(&owner); lines.push(format!( "import {{ {} }} from \"{import_root}{owner_tag}/{owner_tag}.{model_suffix}\";", - names.join(", ") + if options.ts_namespaces { + let suffix = options.configs.get("models") + .map(|config| config.namespace_suffix.as_str()) + .unwrap_or("Models"); + format!("{}{suffix}", capitalize(&owner)) + } else { + names.join(", ") + } )); } content = content.replacen("import { z } from \"zod\";", &lines.join("\n"), 1); @@ -424,6 +437,7 @@ fn render_common_models( document: &Value, schemas: &Map, schema_refs: &Map, + schema_owners: &Map, generated_objects: &Map, circular_schemas: &[String], namespace_tag: &str, @@ -436,6 +450,27 @@ fn render_common_models( .map(|config| config.namespace_suffix.as_str()) .unwrap_or("Models"); let namespace = format!("{}{}", capitalize(namespace_tag), namespace_suffix); + let property_ref_names: HashMap<&str, String> = schema_refs + .iter() + .filter_map(|(name, reference)| { + let reference = reference.as_str()?; + let type_name = remove_suffix(name, suffix); + let owner = if options.models_in_common || !options.split_by_tags { + Some(options.default_tag.as_str()) + } else { + schema_owners.get(name).and_then(Value::as_str) + }; + let type_name = if let Some(owner) = owner + && options.ts_namespaces + && owner != namespace_tag + { + format!("{}{namespace_suffix}.{type_name}", capitalize(owner)) + } else { + type_name.to_string() + }; + Some((reference, type_name)) + }) + .collect(); let mut enum_objects = HashMap::default(); if let Some(component_schemas) = document.pointer("/components/schemas") { collect_enum_objects(component_schemas, &mut enum_objects); @@ -463,6 +498,7 @@ fn render_common_models( &enum_objects, circular_schemas, suffix, + &property_ref_names, options, ) }) @@ -491,6 +527,7 @@ fn render_common_schema_lines( enum_objects: &HashMap, circular_schemas: &[String], suffix: &str, + property_ref_names: &HashMap<&str, String>, options: &GenerateOptions, ) -> Option> { let code = code.as_str()?; @@ -522,7 +559,7 @@ fn render_common_schema_lines( )); } let mut properties = IndexMap::default(); - collect_property_docs(document, schema, "", &mut properties, suffix); + collect_property_docs(document, schema, "", &mut properties, property_ref_names); for (property, (ty, description)) in properties { lines.push(format!( " * @property {{ {ty} }} {property} {} ", @@ -744,18 +781,18 @@ fn collect_property_docs( schema: &Value, prefix: &str, properties: &mut IndexMap, - schema_suffix: &str, + property_ref_names: &HashMap<&str, String>, ) { if let Some(all_of) = schema.get("allOf").and_then(Value::as_array) { for member in all_of.iter().filter(|member| member.get("$ref").is_some()) { if let Some(reference) = member.get("$ref").and_then(Value::as_str) { if let Some(resolved) = resolve_document_ref(document, reference) { - collect_property_docs(document, resolved, prefix, properties, schema_suffix); + collect_property_docs(document, resolved, prefix, properties, property_ref_names); } } } for member in all_of.iter().filter(|member| member.get("$ref").is_none()) { - collect_property_docs(document, member, prefix, properties, schema_suffix); + collect_property_docs(document, member, prefix, properties, property_ref_names); } } if let Some(object) = schema.get("properties").and_then(Value::as_object) { @@ -770,7 +807,7 @@ fn collect_property_docs( .and_then(Value::as_str) .and_then(|reference| resolve_document_ref(document, reference)) .unwrap_or(property_schema); - let ty = property_doc_type(document, property_schema, schema_suffix); + let ty = property_doc_type(document, property_schema, property_ref_names); let preserve_existing_object = properties.contains_key(&key) && property_schema.get("type").and_then(Value::as_str) == Some("object"); if !preserve_existing_object { @@ -791,7 +828,7 @@ fn collect_property_docs( items, &format!("{key}.[0]"), properties, - schema_suffix, + property_ref_names, ); } } @@ -801,7 +838,7 @@ fn collect_property_docs( property_schema, &key, properties, - schema_suffix, + property_ref_names, ); } } else if composite { @@ -818,7 +855,7 @@ fn collect_property_docs( member, &key, properties, - schema_suffix, + property_ref_names, ); } } @@ -838,24 +875,28 @@ fn collect_property_docs( properties.insert( key.clone(), ( - property_doc_type(document, additional, schema_suffix), + property_doc_type(document, additional, property_ref_names), schema_description(additional), ), ); - collect_property_docs(document, additional, &key, properties, schema_suffix); + collect_property_docs(document, additional, &key, properties, property_ref_names); } } -fn property_doc_type(document: &Value, schema: &Value, schema_suffix: &str) -> String { +fn property_doc_type( + document: &Value, + schema: &Value, + property_ref_names: &HashMap<&str, String>, +) -> String { if let Some(reference) = schema.get("$ref").and_then(Value::as_str) { - return property_ref_type(reference); + return property_ref_type(reference, property_ref_names); } if schema.get("type").and_then(Value::as_str) == Some("array") { let item_type = schema .get("items") .and_then(|items| { if let Some(reference) = items.get("$ref").and_then(Value::as_str) { - Some(property_ref_type(reference)) + Some(property_ref_type(reference, property_ref_names)) } else { Some( items @@ -875,7 +916,7 @@ fn property_doc_type(document: &Value, schema: &Value, schema_suffix: &str) -> S .and_then(Value::as_array) .and_then(|values| values.first()) { - return property_doc_type(document, first, schema_suffix); + return property_doc_type(document, first, property_ref_names); } } schema @@ -885,7 +926,10 @@ fn property_doc_type(document: &Value, schema: &Value, schema_suffix: &str) -> S .to_string() } -fn property_ref_type(reference: &str) -> String { +fn property_ref_type(reference: &str, property_ref_names: &HashMap<&str, String>) -> String { + if let Some(name) = property_ref_names.get(reference) { + return name.clone(); + } let mut name = reference .rsplit('/') .next() diff --git a/native/src/zod.rs b/native/src/zod.rs index baf72136..1b044a98 100644 --- a/native/src/zod.rs +++ b/native/src/zod.rs @@ -225,7 +225,7 @@ impl<'a> ZodCompiler<'a> { let zod_name = schema_name(name, &self.options.schema_suffix); let owner = self.schema_tag(&reference); let output = if self.options.ts_namespaces && owner != tag { - format!("{}Models.{zod_name}", capitalize(&owner)) + format!("{}.{zod_name}", self.models_namespace(&owner)) } else { zod_name }; @@ -375,7 +375,7 @@ impl<'a> ZodCompiler<'a> { if root_ref != Some(enum_ref.as_str()) { let owner = self.schema_tag(&enum_ref); return Ok(Some(if self.options.ts_namespaces && owner != tag { - format!("{}Models.{name}", capitalize(&owner)) + format!("{}.{name}", self.models_namespace(&owner)) } else { name.clone() })); @@ -383,7 +383,7 @@ impl<'a> ZodCompiler<'a> { } if let Some((name, owner)) = self.extracted_enums.get(&code) { return Ok(Some(if self.options.ts_namespaces && owner != tag { - format!("{}Models.{name}", capitalize(owner)) + format!("{}.{name}", self.models_namespace(owner)) } else { name.clone() })); @@ -589,6 +589,12 @@ impl<'a> ZodCompiler<'a> { self.document.pointer(reference.strip_prefix('#')?) } + fn models_namespace(&self, tag: &str) -> String { + let suffix = self.options.configs.get("models") + .map(|config| config.namespace_suffix.as_str()).unwrap_or("Models"); + format!("{}{suffix}", capitalize(tag)) + } + fn schema_tag(&self, reference: &str) -> String { if !self.options.split_by_tags || self.options.models_in_common { return self.options.default_tag.clone(); diff --git a/package.json b/package.json index bf334698..36511ac0 100644 --- a/package.json +++ b/package.json @@ -91,7 +91,7 @@ "test": "bun run build:native && bun run test:ts && bun run test:native", "test:ts": "OPENAPI_CODEGEN_NATIVE=0 vitest run", "test:native": "OPENAPI_CODEGEN_NATIVE=1 OPENAPI_CODEGEN_REQUIRE_FULL_NATIVE=1 vitest run", - "test:parity": "vitest run src/native/renderer-parity.test.ts src/native/renderer-parity-manifest.test.ts", + "test:parity": "vitest run src/native/renderer-parity.test.ts src/native/renderer-parity-manifest.test.ts src/native/model-imports.test.ts", "test:watch": "vitest", "build": "tsdown && bun run build:native", "build:ts": "tsdown", diff --git a/scripts/renderer-parity.ts b/scripts/renderer-parity.ts index d9301654..104b34e8 100644 --- a/scripts/renderer-parity.ts +++ b/scripts/renderer-parity.ts @@ -7,6 +7,9 @@ import type { OpenAPIV3 } from "openapi-types"; import { resolveConfig } from "../src/generators/core/resolveConfig"; import { generateCodeFromOpenAPIDoc } from "../src/generators/generateCodeFromOpenAPIDoc"; import { generateFilesFromNativeOpenAPI } from "../src/native/generateFilesFromNativeOpenAPI"; +import { getNativeBindings } from "../src/native/native-bindings"; +import { GenerateType, type GenerateFileData } from "../src/generators/types/generate"; +import { getTagFileName } from "../src/generators/utils/generate/generate.utils"; export type Manifest = Record; @@ -40,23 +43,40 @@ async function generate(renderer: string, output: string) { await mkdir(path.dirname(output), { recursive: true }); await mkdir(output); const manifest: Manifest = {}; - for (const tsNamespaces of [true, false]) { - const scenario = tsNamespaces ? "namespaces" : "modules"; + const scenarios = [ + { name: "namespaces", tsNamespaces: true, modelsInCommon: true, modelsOnly: false }, + { name: "modules", tsNamespaces: false, modelsInCommon: false, modelsOnly: false }, + { name: "local-model-namespaces", tsNamespaces: true, modelsInCommon: false, modelsOnly: true }, + ]; + for (const { name: scenario, tsNamespaces, modelsInCommon, modelsOnly } of scenarios) { const options = resolveConfig({ fileConfig: { input: "test/petstore.yaml", output: "generated", tsNamespaces, - modelsInCommon: tsNamespaces, + modelsInCommon, + modelsOnly, acl: false, restClientImportPath: "@test/app-rest-client", }, params: {}, }); - const files = - renderer === "js" - ? generateCodeFromOpenAPIDoc(document, options) - : generateFilesFromNativeOpenAPI(source, true, options); + let files: GenerateFileData[] | undefined; + if (renderer === "js") { + files = generateCodeFromOpenAPIDoc(document, options); + } else if (modelsOnly) { + // This configuration uses native model rendering through the hybrid pipeline. + // Read native output directly so JS fallback cannot mask a regression. + const { renderedModels } = getNativeBindings().compileData(source, true, JSON.stringify(options)).data as { + renderedModels: Record; + }; + files = Object.entries(renderedModels).map(([tag, content]) => ({ + fileName: path.join(options.output, getTagFileName({ tag, type: GenerateType.Models, options })), + content, + })); + } else { + files = generateFilesFromNativeOpenAPI(source, true, options); + } if (!files?.length) throw new Error(`${renderer} did not generate files for ${scenario}`); for (const file of files) { const relative = path.relative(options.output, file.fileName); diff --git a/src/generators/core/endpoints/getEndpointsFromOpenAPIDoc.test.ts b/src/generators/core/endpoints/getEndpointsFromOpenAPIDoc.test.ts index c32c2650..7df9af1c 100644 --- a/src/generators/core/endpoints/getEndpointsFromOpenAPIDoc.test.ts +++ b/src/generators/core/endpoints/getEndpointsFromOpenAPIDoc.test.ts @@ -898,6 +898,30 @@ describe("getEndpointsFromOpenAPIDoc", () => { const resolver = new SchemaResolver(openApiDoc, generateOptions); const endpoints = getEndpointsFromOpenAPIDoc(resolver); expect(endpoints).toEqual([ + ...[ + ["EmailAdmin", "email", "EmailActivityAdminResponse"], + ["PushNotificationAdmin", "push", "PushNotificationActivityAdminResponse"], + ].map(([tag, route, response]) => ({ + description: undefined, + summary: "Shared-model import regression example", + errors: [], + method: "get", + operationName: `read${tag}Activity`, + parameters: [], + path: `/activity/${route}`, + tags: [tag], + mediaDownload: false, + mediaUpload: false, + requestFormat: "application/json", + response, + responseFormat: "application/json", + responseDescription: "Activity with shared log level and label", + responseStatusCodes: ["200"], + responseObject: { + description: "Activity with shared log level and label", + content: { "application/json": { schema: { $ref: `#/components/schemas/${response}` } } }, + }, + })), { description: "Update an existing pet by Id", summary: "Update an existing pet", @@ -1821,6 +1845,10 @@ describe("getEndpointsFromOpenAPIDoc", () => { }, ]); expect(resolver.getZodSchemas()).toStrictEqual({ + BaseLogLevelEnum: 'z.enum(["info", "error"])', + LabelResponse: "z.object({ text: z.string() }).partial()", + EmailActivityAdminResponse: "z.object({ level: BaseLogLevelEnum, label: LabelResponse.optional() })", + PushNotificationActivityAdminResponse: "z.object({ level: BaseLogLevelEnum, label: LabelResponse.optional() })", ApiResponse: "z.object({ code: z.int(), type: z.string(), message: z.string() }).partial()", Category: "z.object({ id: z.int(), name: z.string() }).partial()", CreateUsersWithListInputBody: "z.array(User)", diff --git a/src/generators/core/getMetadataFromOpenAPIDoc.test.ts b/src/generators/core/getMetadataFromOpenAPIDoc.test.ts index 171363a8..daa65f5c 100644 --- a/src/generators/core/getMetadataFromOpenAPIDoc.test.ts +++ b/src/generators/core/getMetadataFromOpenAPIDoc.test.ts @@ -193,7 +193,39 @@ describe("getMetadataFromOpenAPIDoc", () => { metaType: "primitive", }; + const BaseLogLevelEnum: ModelMetadata = { + type: "BaseLogLevelEnum", + namespace: "CommonModels", + importPath: "common/common.models", + metaType: "primitive", + }; + const LabelResponse: ModelMetadata = { + type: "LabelResponse", + namespace: "CommonModels", + importPath: "common/common.models", + metaType: "object", + objectProperties: [{ name: "text", type: "string", isRequired: false, metaType: "primitive" }], + }; + const EmailActivityAdminResponse: ModelMetadata = { + type: "EmailActivityAdminResponse", + namespace: "EmailAdminModels", + importPath: "emailAdmin/emailAdmin.models", + metaType: "object", + objectProperties: [ + { name: "level", isRequired: true, ...BaseLogLevelEnum }, + { name: "label", isRequired: false, ...LabelResponse }, + ], + }; + const PushNotificationActivityAdminResponse: ModelMetadata = { + ...EmailActivityAdminResponse, + type: "PushNotificationActivityAdminResponse", + namespace: "PushNotificationAdminModels", + importPath: "pushNotificationAdmin/pushNotificationAdmin.models", + }; + const models = (withEnums = true): ModelMetadata[] => [ + EmailActivityAdminResponse, + PushNotificationActivityAdminResponse, ...(withEnums ? [FindByStatusStatusEnum] : []), Category, Tag, @@ -208,11 +240,31 @@ describe("getMetadataFromOpenAPIDoc", () => { GetInventoryResponse, User, CreateWithListInputBody, + BaseLogLevelEnum, + LabelResponse, Address, Customer, ]; const queries: QueryMetadata[] = [ + { + name: "useReadActivity", + importPath: "emailAdmin/emailAdmin.queries", + namespace: "EmailAdminQueries", + isQuery: true, + isMutation: false, + params: [], + response: { ...EmailActivityAdminResponse }, + }, + { + name: "useReadActivity", + importPath: "pushNotificationAdmin/pushNotificationAdmin.queries", + namespace: "PushNotificationAdminQueries", + isQuery: true, + isMutation: false, + params: [], + response: { ...PushNotificationActivityAdminResponse }, + }, { name: "useUpdate", importPath: "pet/pet.queries", diff --git a/src/native/model-imports.test.ts b/src/native/model-imports.test.ts new file mode 100644 index 00000000..f4e9bca9 --- /dev/null +++ b/src/native/model-imports.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, test } from "vitest"; +import type { OpenAPIV3 } from "openapi-types"; +import { execFileSync } from "node:child_process"; +import { mkdir, mkdtemp, readFile, rm, writeFile, symlink } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { parse } from "yaml"; + +import { DEFAULT_GENERATE_OPTIONS } from "@/generators/const/options.const"; +import { getDataFromOpenAPIDoc } from "@/generators/core/getDataFromOpenAPIDoc"; +import { resolveConfig } from "@/generators/core/resolveConfig"; +import { generateModels } from "@/generators/generate/generateModels"; +import { GenerateType } from "@/generators/types/generate"; +import { getTagFileName } from "@/generators/utils/generate/generate.utils"; +import { getNativeBindings } from "./native-bindings"; + +describe("native module-local model imports", () => { + test.each([ + [true, "Models"], + [false, "Models"], + [true, "Schemas"], + ] as const)( + "imports shared schemas consistently with namespaces=%s and suffix=%s", + async (tsNamespaces, namespaceSuffix) => { + const source = await readFile("test/petstore.yaml", "utf8"); + const document = parse(source) as OpenAPIV3.Document; + const options = resolveConfig({ + fileConfig: { + input: "fixture", + output: "output", + modelsInCommon: false, + tsNamespaces, + acl: false, + importPath: "relative", + includeTags: ["EmailAdmin", "PushNotificationAdmin"], + configs: { + ...DEFAULT_GENERATE_OPTIONS.configs, + models: { ...DEFAULT_GENERATE_OPTIONS.configs.models, namespaceSuffix }, + }, + }, + params: {}, + }); + const expected = getDataFromOpenAPIDoc(document, options); + const { renderedModels } = getNativeBindings().compileData(source, true, JSON.stringify(options)).data as { + renderedModels: Record; + }; + const email = renderedModels.EmailAdmin; + expect(email).toContain( + tsNamespaces ? `import { Common${namespaceSuffix} }` : "import { BaseLogLevelEnumSchema, LabelResponseSchema }", + ); + // Load the generated TypeScript itself to catch undefined namespace references. + const directory = await mkdtemp(path.join(os.tmpdir(), "native-model-imports-")); + try { + await symlink(path.join(process.cwd(), "node_modules"), path.join(directory, "node_modules"), "dir"); + for (const [tag, content] of Object.entries(renderedModels)) { + const file = path.join(directory, getTagFileName({ tag, type: GenerateType.Models, options })); + await mkdir(path.dirname(file), { recursive: true }); + await writeFile(file, content); + } + const exported = tsNamespaces ? `EmailAdmin${namespaceSuffix}` : "EmailActivityAdminResponseSchema"; + const schema = tsNamespaces ? `${exported}.EmailActivityAdminResponseSchema` : exported; + await writeFile( + path.join(directory, "check.ts"), + ` + import { ${exported} } from "./emailAdmin/emailAdmin.models"; + if (${schema}.parse({ level: "info" }).level !== "info") throw new Error("Expected valid log level"); + if (${schema}.safeParse({ level: "invalid" }).success) throw new Error("Expected invalid log level rejection"); + `, + ); + execFileSync("bun", [path.join(directory, "check.ts")], { stdio: "pipe" }); + } finally { + await rm(directory, { recursive: true, force: true }); + } + for (const tag of expected.data.keys()) { + expect(renderedModels[tag], tag).toBe(generateModels({ ...expected, tag })); + } + }, + ); +}); diff --git a/test/petstore.yaml b/test/petstore.yaml index 62b279e7..92571c47 100644 --- a/test/petstore.yaml +++ b/test/petstore.yaml @@ -38,6 +38,30 @@ tags: - name: user description: Operations about user paths: + /activity/email: + get: + tags: [EmailAdmin] + summary: Shared-model import regression example + operationId: readEmailAdminActivity + responses: + "200": + description: Activity with shared log level and label + content: + application/json: + schema: + $ref: "#/components/schemas/EmailActivityAdminResponse" + /activity/push: + get: + tags: [PushNotificationAdmin] + summary: Shared-model import regression example + operationId: readPushNotificationAdminActivity + responses: + "200": + description: Activity with shared log level and label + content: + application/json: + schema: + $ref: "#/components/schemas/PushNotificationActivityAdminResponse" /pet: put: tags: @@ -599,6 +623,31 @@ paths: description: User not found components: schemas: + # Shared across tags to exercise namespace imports in module-local output. + BaseLogLevelEnum: + type: string + enum: [info, error] + LabelResponse: + type: object + properties: + text: + type: string + EmailActivityAdminResponse: + type: object + required: [level] + properties: + level: + $ref: "#/components/schemas/BaseLogLevelEnum" + label: + $ref: "#/components/schemas/LabelResponse" + PushNotificationActivityAdminResponse: + type: object + required: [level] + properties: + level: + $ref: "#/components/schemas/BaseLogLevelEnum" + label: + $ref: "#/components/schemas/LabelResponse" Order: type: object properties: From aff6e5a17a9fe651d64ca3f1ce9fcecea9ef1c97 Mon Sep 17 00:00:00 2001 From: Urban Krepel Date: Thu, 10 Sep 2026 14:48:41 +0200 Subject: [PATCH 2/2] test: cover generator configurations across native and JavaScript --- .github/workflows/renderer-parity.yml | 2 +- .gitignore | 1 + README.md | 4 + native/src/endpoints.rs | 19 + native/src/lib.rs | 16 + native/src/render.rs | 374 ++++++++++++++++-- native/src/resolver.rs | 20 +- native/src/zod.rs | 11 +- package.json | 2 +- scripts/renderer-parity-cases.ts | 44 +++ scripts/renderer-parity-configs.ts | 135 +++++++ scripts/renderer-parity.ts | 100 ++--- src/generators/core/resolveConfig.ts | 3 + .../utils/generate/generate.imports.utils.ts | 4 +- .../utils/generate/generate.zod.utils.ts | 6 +- src/native/configuration-lifecycle.test.ts | 89 +++++ src/native/configuration-parity.test.ts | 119 ++++++ src/native/configuration-runtime.test.ts | 71 ++++ src/native/generateFilesFromNativeOpenAPI.ts | 3 + src/native/getDataFromNativeOpenAPIDoc.ts | 13 +- test/configuration.yaml | 127 ++++++ 21 files changed, 1055 insertions(+), 108 deletions(-) create mode 100644 scripts/renderer-parity-cases.ts create mode 100644 scripts/renderer-parity-configs.ts create mode 100644 src/native/configuration-lifecycle.test.ts create mode 100644 src/native/configuration-parity.test.ts create mode 100644 src/native/configuration-runtime.test.ts create mode 100644 test/configuration.yaml diff --git a/.github/workflows/renderer-parity.yml b/.github/workflows/renderer-parity.yml index 0abebf69..d7ba0a72 100644 --- a/.github/workflows/renderer-parity.yml +++ b/.github/workflows/renderer-parity.yml @@ -33,7 +33,7 @@ jobs: - run: bun run build:native if: matrix.renderer == 'native' - - name: Check naming regressions + - name: Check configuration parity and runtime regressions if: matrix.renderer == 'native' run: bun run test:parity env: diff --git a/.gitignore b/.gitignore index 49847f9e..020260df 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ node_modules test/* !test/config.ts !test/petstore.yaml +!test/configuration.yaml !test/generated/.gitkeep !test/generated/base/.gitkeep !test/generated/next/.gitkeep diff --git a/README.md b/README.md index 32cbfdb0..e640f7d8 100644 --- a/README.md +++ b/README.md @@ -159,6 +159,10 @@ Set `OPENAPI_CODEGEN_NATIVE=0` to force the TypeScript path, or `OPENAPI_CODEGEN Release packages include native binaries for Linux x64, macOS arm64, and Windows x64. Build a binary for the current platform with `bun run build:native`. +Run `bun run test:parity` after building the addon to compare every generated file across 64 layout combinations and representative values for every other renderer option. The matrix uses both `test/petstore.yaml` and `test/configuration.yaml`, exercises complete native and hybrid native generation, and loads generated models to check runtime references. The contradictory combination `modelsInCommon: true` with `modelsInModules: true` is rejected explicitly. Arbitrary strings and lists are covered by representative cases, not every possible value. + +The separate Renderer parity workflow generates this matrix with JavaScript and native on Linux and macOS, then compares exact file lists and SHA-256 hashes. Artifacts include route reports identifying complete versus hybrid native generation. Runner integration tests cover input/output, stale-file cleanup, and unchanged files; `incremental` is currently retained as a compatibility option and does not change the writer's unchanged-file optimization. + ## Common Issues ### App REST Client Interceptors diff --git a/native/src/endpoints.rs b/native/src/endpoints.rs index 8021ec67..b71f5863 100644 --- a/native/src/endpoints.rs +++ b/native/src/endpoints.rs @@ -654,6 +654,25 @@ impl<'a> EndpointExtractor<'a> { } else { obj.get("schema").unwrap_or(&Value::Null) }; + let mut described_schema; + let schema = if self.options.with_description { + described_schema = schema.clone(); + if let Some(object) = described_schema.as_object_mut() { + object.insert( + "description".into(), + Value::String( + obj.get("description") + .and_then(Value::as_str) + .unwrap_or("") + .trim() + .to_string(), + ), + ); + } + &described_schema + } else { + schema + }; let required = location == "path" || obj .get("required") diff --git a/native/src/lib.rs b/native/src/lib.rs index 5482a722..a6d2dd6c 100644 --- a/native/src/lib.rs +++ b/native/src/lib.rs @@ -260,6 +260,21 @@ pub fn compile_data(source: String, yaml: bool, options_json: String) -> Result< ordered_schemas.extend(schemas); let mut usage_tags: rustc_hash::FxHashMap> = rustc_hash::FxHashMap::default(); + // Direct inline responses need not produce a named generated schema. Keep + // the resolver's reference usage too, so those endpoints still contribute + // to ownership when the rendered-schema graph is propagated below. + for (reference, tags) in &resolver.schema_tags { + let name = zod::schema_name( + reference.rsplit('/').next().unwrap_or_default(), + &options.schema_suffix, + ); + if ordered_schemas.contains_key(&name) { + usage_tags + .entry(name) + .or_default() + .extend(tags.iter().cloned()); + } + } for endpoint in &endpoints { let tag = endpoint .get("tags") @@ -413,6 +428,7 @@ pub fn compile_data(source: String, yaml: bool, options_json: String) -> Result< "endpoints": if rendered_complete { Value::Array(Vec::new()) } else { Value::Array(endpoints) }, "schemas": if rendered_complete { compact() } else { Value::Object(ordered_schemas) }, "schemaOwners": if rendered_complete { compact() } else { Value::Object(schema_owners) }, + "schemaUsageTags": if rendered_complete { compact() } else { serde_json::to_value(usage_tags).unwrap() }, "schemaRefs": if rendered_complete { compact() } else { Value::Object(schema_refs) }, "circularSchemas": if rendered_complete { Value::Array(Vec::new()) } else { serde_json::to_value(circular_schemas).unwrap() }, "topologyOrder": if rendered_complete { Value::Array(Vec::new()) } else { serde_json::to_value(topology_order).unwrap() }, diff --git a/native/src/render.rs b/native/src/render.rs index 33422485..3e5eac3d 100644 --- a/native/src/render.rs +++ b/native/src/render.rs @@ -219,8 +219,11 @@ fn render_local_models( .values() .filter_map(Value::as_str) .map(|owner| { - let suffix = options.configs.get("models") - .map(|config| config.namespace_suffix.as_str()).unwrap_or("Models"); + let suffix = options + .configs + .get("models") + .map(|config| config.namespace_suffix.as_str()) + .unwrap_or("Models"); format!("{}{suffix}.", capitalize(owner)) }) .collect::>(); @@ -250,7 +253,9 @@ fn render_local_models( lines.push(format!( "import {{ {} }} from \"{import_root}{owner_tag}/{owner_tag}.{model_suffix}\";", if options.ts_namespaces { - let suffix = options.configs.get("models") + let suffix = options + .configs + .get("models") .map(|config| config.namespace_suffix.as_str()) .unwrap_or("Models"); format!("{}{suffix}", capitalize(&owner)) @@ -787,7 +792,13 @@ fn collect_property_docs( for member in all_of.iter().filter(|member| member.get("$ref").is_some()) { if let Some(reference) = member.get("$ref").and_then(Value::as_str) { if let Some(resolved) = resolve_document_ref(document, reference) { - collect_property_docs(document, resolved, prefix, properties, property_ref_names); + collect_property_docs( + document, + resolved, + prefix, + properties, + property_ref_names, + ); } } } @@ -1068,7 +1079,7 @@ fn schema_identifiers(code: &str) -> Vec<&str> { for candidate in code.split(|character: char| { !(character.is_ascii_alphanumeric() || character == '_' || character == '$') }) { - if candidate.ends_with("Schema") && !candidate.is_empty() && !result.contains(&candidate) { + if !candidate.is_empty() && !result.contains(&candidate) { result.push(candidate); } } @@ -1216,6 +1227,7 @@ fn decapitalize(value: &str) -> String { } fn remove_suffix(value: &str, suffix: &str) -> String { + let value = value.split('.').next().unwrap_or(value); value.strip_suffix(suffix).unwrap_or(value).to_string() } @@ -1294,7 +1306,7 @@ fn render_endpoint_module( ) -> String { let mut lines = vec![format!( "import {{ AppRestClient }} from \"{}\";", - options.rest_client_import_path + rest_client_import_path(options) )]; let has_get = endpoints .iter() @@ -1477,7 +1489,12 @@ fn render_endpoint_module( if options.ts_namespaces { lines.push("}".into()); } - format!("{}\n", lines.join("\n").trim_end()) + resolve_model_namespace_owners( + format!("{}\n", lines.join("\n").trim_end()), + tag, + schema_owners, + options, + ) } #[derive(Clone)] @@ -2243,8 +2260,9 @@ fn render_acl_module( if has_models { if options.ts_namespaces { lines.push(format!( - "import type {{ {models_namespace} }} from \"./{}.models\";", - decapitalize(tag) + "import type {{ {models_namespace} }} from \"./{}.{}\";", + decapitalize(tag), + output_suffix(options, "models", "models") )); } else { let mut imports: IndexMap> = IndexMap::default(); @@ -2271,9 +2289,16 @@ fn render_acl_module( for (owner, names) in imports { let owner_tag = decapitalize(&owner); let path = if owner == tag { - format!("./{owner_tag}.models") + format!( + "./{owner_tag}.{}", + output_suffix(options, "models", "models") + ) } else { - format!("{}{owner_tag}/{owner_tag}.models", import_root(options)) + format!( + "{}{owner_tag}/{owner_tag}.{}", + import_root(options), + output_suffix(options, "models", "models") + ) }; lines.push(format!( "import type {{ {} }} from \"{path}\";", @@ -2547,7 +2572,7 @@ fn render_query_module( if uses_native_rest_client(options) && has_media_upload { lines.push(format!( "import {{ AppRestClient }} from \"{}\";", - options.rest_client_import_path + rest_client_import_path(options) )); } if uses_native_rest_client(options) { @@ -2607,8 +2632,9 @@ fn render_query_module( )); if options.ts_namespaces { lines.push(format!( - "import {{ {acl_namespace} }} from \"./{}.acl\";", - decapitalize(tag) + "import {{ {acl_namespace} }} from \"./{}.{}\";", + decapitalize(tag), + output_suffix(options, "acl", "acl") )); } else { let abilities = acl_endpoints @@ -2616,9 +2642,10 @@ fn render_query_module( .map(|endpoint| format!("canUse{}", query_names(endpoint).1)) .collect::>(); lines.push(format!( - "import {{ {} }} from \"./{}.acl\";", + "import {{ {} }} from \"./{}.{}\";", abilities.join(", "), - decapitalize(tag) + decapitalize(tag), + output_suffix(options, "acl", "acl") )); } } @@ -2654,8 +2681,9 @@ fn render_query_module( { if options.ts_namespaces { lines.push(format!( - "import {{ {models_namespace} }} from \"./{}.models\";", - decapitalize(tag) + "import {{ {models_namespace} }} from \"./{}.{}\";", + decapitalize(tag), + output_suffix(options, "models", "models") )); } else { let mut imports: IndexMap> = IndexMap::default(); @@ -2686,9 +2714,16 @@ fn render_query_module( for (owner, names) in imports { let owner_tag = decapitalize(&owner); let path = if owner == tag { - format!("./{owner_tag}.models") + format!( + "./{owner_tag}.{}", + output_suffix(options, "models", "models") + ) } else { - format!("{}{owner_tag}/{owner_tag}.models", import_root(options)) + format!( + "{}{owner_tag}/{owner_tag}.{}", + import_root(options), + output_suffix(options, "models", "models") + ) }; lines.push(format!( "import type {{ {} }} from \"{path}\";", @@ -2699,8 +2734,9 @@ fn render_query_module( } if options.ts_namespaces { lines.push(format!( - "import {{ {api_namespace} }} from \"./{}.api\";", - decapitalize(tag) + "import {{ {api_namespace} }} from \"./{}.{}\";", + decapitalize(tag), + output_suffix(options, "endpoints", "api") )); } else { let operations = endpoints @@ -2715,9 +2751,10 @@ fn render_query_module( }) .collect::>(); lines.push(format!( - "import {{ {} }} from \"./{}.api\";", + "import {{ {} }} from \"./{}.{}\";", operations.join(", "), - decapitalize(tag) + decapitalize(tag), + output_suffix(options, "endpoints", "api") )); } lines.push(String::new()); @@ -2818,7 +2855,212 @@ fn render_query_module( content = content.replace(&format!("{api_namespace}."), ""); content = content.replace(&format!("{acl_namespace}."), ""); } - content + resolve_model_namespace_owners(content, tag, schema_owners, options) +} + +// Namespace-based modules can reference models shared by several tags. +fn resolve_model_namespace_owners( + content: String, + tag: &str, + schema_owners: &Map, + options: &GenerateOptions, +) -> String { + if !options.ts_namespaces || options.models_in_common || options.models_in_modules { + return content; + } + let namespace_suffix = options + .configs + .get("models") + .map(|config| config.namespace_suffix.as_str()) + .unwrap_or("Models"); + let local_namespace = format!("{}{namespace_suffix}", capitalize(tag)); + let prefix = format!("{local_namespace}."); + let mut owners = HashMap::default(); + for (schema, owner) in schema_owners { + if let Some(owner) = owner.as_str().filter(|owner| *owner != tag) { + owners.insert(schema.as_str().to_string(), owner); + owners.insert(remove_suffix(schema, &options.schema_suffix), owner); + } + } + let mut imports = IndexSet::default(); + let mut has_local = false; + let mut output = String::with_capacity(content.len()); + let mut remaining = content.as_str(); + while !remaining.is_empty() { + // URLs, literal values and descriptions are data, not model references. + if remaining.starts_with("/*") { + let end = remaining + .find("*/") + .map(|index| index + 2) + .unwrap_or(remaining.len()); + for (index, line) in remaining[..end].split('\n').enumerate() { + if index > 0 { + output.push('\n'); + } + let trimmed = line.trim_start(); + let typed_doc = ["* @param { ", "* @returns { ", "* @property { "] + .iter() + .any(|start| trimmed.starts_with(start)); + if typed_doc { + let start = line.find("{ ").unwrap() + 2; + let end = line[start..] + .find(" }") + .map(|end| start + end) + .unwrap_or(start); + output.push_str(&line[..start]); + let mut ty = line[start..end].to_string(); + for (name, owner) in &owners { + let source = format!("{prefix}{name}"); + let target = format!("{}{namespace_suffix}.{name}", capitalize(owner)); + ty = replace_model_identifier(&ty, &source, &target); + } + output.push_str(&ty); + output.push_str(&line[end..]); + } else { + output.push_str(line); + } + } + remaining = &remaining[end..]; + continue; + } + if remaining.starts_with("//") { + let end = remaining.find('\n').unwrap_or(remaining.len()); + output.push_str(&remaining[..end]); + remaining = &remaining[end..]; + continue; + } + let first = remaining.chars().next().unwrap(); + if first == '/' && output.trim_end().ends_with(".regex(") { + let mut escaped = false; + let mut character_class = false; + let mut end = remaining.len(); + for (index, ch) in remaining.char_indices().skip(1) { + if escaped { escaped = false; continue; } + if ch == '\\' { escaped = true; continue; } + if ch == '[' { character_class = true; } + if ch == ']' { character_class = false; } + if ch == '/' && !character_class { end = index + 1; break; } + } + output.push_str(&remaining[..end]); + remaining = &remaining[end..]; + continue; + } + if matches!(first, '\'' | '"' | '`') { + // Native templates interpolate endpoint parameter names, never model expressions. + let mut escaped = false; + let mut end = remaining.len(); + for (index, ch) in remaining.char_indices().skip(1) { + if escaped { + escaped = false; + continue; + } + if ch == '\\' { + escaped = true; + continue; + } + if ch == first { + end = index + ch.len_utf8(); + break; + } + } + output.push_str(&remaining[..end]); + remaining = &remaining[end..]; + continue; + } + if remaining.starts_with(&prefix) + && output + .chars() + .last() + .is_none_or(|ch| !model_identifier_char(ch)) + { + remaining = &remaining[prefix.len()..]; + let length = remaining + .find(|ch: char| !model_identifier_char(ch)) + .unwrap_or(remaining.len()); + let name = &remaining[..length]; + if let Some(owner) = owners.get(name) { + imports.insert(*owner); + output.push_str(&format!("{}{namespace_suffix}.", capitalize(owner))); + } else { + has_local = true; + output.push_str(&prefix); + } + continue; + } + output.push(first); + remaining = &remaining[first.len_utf8()..]; + } + if imports.is_empty() { + return output; + } + let local_import = format!("import {{ {local_namespace} }} from "); + let mut lines = output.lines().map(str::to_string).collect::>(); + let insertion = lines + .iter() + .position(|line| line.starts_with(&local_import)) + .unwrap_or_else(|| lines.iter().position(|line| line.is_empty()).unwrap_or(0)); + if !has_local + && lines + .get(insertion) + .is_some_and(|line| line.starts_with(&local_import)) + { + lines.remove(insertion); + } + let insertion = insertion + usize::from(has_local); + let suffix = output_suffix(options, "models", "models"); + for (offset, owner) in imports.into_iter().enumerate() { + let owner_tag = decapitalize(owner); + lines.insert( + insertion + offset, + format!( + "import {{ {}{namespace_suffix} }} from \"{}{owner_tag}/{owner_tag}.{suffix}\";", + capitalize(owner), + import_root(options) + ), + ); + } + format!("{}\n", lines.join("\n")) +} + +fn model_identifier_char(ch: char) -> bool { + ch.is_ascii_alphanumeric() || ch == '_' || ch == '$' +} + +fn replace_model_identifier(value: &str, source: &str, target: &str) -> String { + let mut output = String::new(); + let mut remaining = value; + while let Some(index) = remaining.find(source) { + output.push_str(&remaining[..index]); + let after = &remaining[index + source.len()..]; + let boundary = output + .chars() + .last() + .is_none_or(|ch| !model_identifier_char(ch)) + && after + .chars() + .next() + .is_none_or(|ch| !model_identifier_char(ch)); + output.push_str(if boundary { target } else { source }); + remaining = after; + } + output.push_str(remaining); + output +} + +fn rest_client_import_path(options: &GenerateOptions) -> String { + if options.rest_client_import_path.is_empty() { + format!("{}app-rest-client", import_root(options)) + } else { + options.rest_client_import_path.clone() + } +} + +fn output_suffix<'a>(options: &'a GenerateOptions, kind: &str, default: &'a str) -> &'a str { + options + .configs + .get(kind) + .map(|config| config.output_file_name_suffix.as_str()) + .unwrap_or(default) } fn import_root(options: &GenerateOptions) -> String { @@ -3026,7 +3268,7 @@ fn render_query_hook_native( } else { "AppQueryOptions" }; - lines.push(format!("export const {hook} = ({}options?: {option_type}) => {{", if params.is_empty() { "".into() } else { format!("{{ {args} }}: {{ {} }}, ", render_param_list(¶ms)) })); + lines.push(format!("export const {hook} = ({}options?: {option_type}{}) => {{", if params.is_empty() { "".into() } else { format!("{{ {args} }}: {{ {} }}, ", render_param_list(¶ms)) }, if options.axios_request_config { format!(", config?: {}", request_config_type(options)) } else { String::new() })); lines.push(" const queryConfig = OpenApiQueryConfig.useConfig();".into()); let has_acl = options.check_acl && endpoint @@ -3050,11 +3292,16 @@ fn render_query_hook_native( if infinite { "Infinite" } else { "" } ); let call_args = format!( - "{}{{ allowInvalidResponseData: queryConfig.allowInvalidResponseData }}", + "{}{{ {}allowInvalidResponseData: queryConfig.allowInvalidResponseData }}", if params.is_empty() { "".into() } else { format!("{{ {args} }}, ") + }, + if options.axios_request_config { + "...config, " + } else { + "" } ); lines.push(format!(" ...{options_name}({call_args}),")); @@ -3487,8 +3734,9 @@ fn render_mutation_native( format!(", {{ {variables} }}") }; lines.push(format!( - "export const use{mutation_cap} = ({path_arg}options?: AppMutationOptions{}) => {{", - if options.mutation_effects { " & MutationEffectsOptions" } else { "" } + "export const use{mutation_cap} = ({path_arg}options?: AppMutationOptions{}{}) => {{", + if options.mutation_effects { " & MutationEffectsOptions" } else { "" }, + if options.axios_request_config { format!(", config?: {}", request_config_type(options)) } else { String::new() } )); if options.mutation_default_on_error { lines.push(" const queryConfig = OpenApiQueryConfig.useConfig();".into()); @@ -3547,12 +3795,32 @@ fn render_mutation_native( ); lines.push(" },".into()); } else if has_acl { + let config_arg = if options.axios_request_config { + if args.is_empty() { + "config" + } else { + ", config" + } + } else { + "" + }; lines.push(format!( - " return {api_namespace}.{api_operation}({args})" + " return {api_namespace}.{api_operation}({args}{config_arg})" )); lines.push(" },".into()); } else { - lines.push(format!(" {api_namespace}.{api_operation}({args})")); + let config_arg = if options.axios_request_config { + if args.is_empty() { + "config" + } else { + ", config" + } + } else { + "" + }; + lines.push(format!( + " {api_namespace}.{api_operation}({args}{config_arg})" + )); lines.push(",".into()); } if is_scoped { @@ -3859,3 +4127,47 @@ fn render_mutation_docs( lines.push(format!(" * @statusCodes [{statuses}]")); lines.push(" */".into()); } + +#[cfg(test)] +mod namespace_owner_tests { + use super::*; + + #[test] + fn shared_model_routing_preserves_literals_and_description_text() { + let options: GenerateOptions = serde_json::from_value(serde_json::json!({})).unwrap(); + let owners = serde_json::json!({ "BaseLogLevelEnumSchema": "Common" }) + .as_object() + .unwrap() + .clone(); + let source = r#"import { EmailAdminModels } from "./emailAdmin.models"; + +/** + * @returns { EmailAdminModels.BaseLogLevelEnum } EmailAdminModels.BaseLogLevelEnumSchema stays literal + * @description EmailAdminModels.BaseLogLevelEnumSchema remains unchanged + */ +const value = EmailAdminModels.BaseLogLevelEnumSchema; +const path = `/EmailAdminModels.BaseLogLevelEnumSchema/${id}`; +const quoted = "EmailAdminModels.BaseLogLevelEnumSchema"; +const single = 'EmailAdminModels.BaseLogLevelEnumSchema'; +const regex = z.string().regex(/EmailAdminModels.BaseLogLevelEnumSchema/); +const escapedRegex = z.string().regex(/\/[/]EmailAdminModels.BaseLogLevelEnumSchema/); +// EmailAdminModels.BaseLogLevelEnumSchema remains unchanged +"#; + let actual = resolve_model_namespace_owners(source.into(), "EmailAdmin", &owners, &options); + assert!(actual.contains("const value = CommonModels.BaseLogLevelEnumSchema;")); + assert!(actual.contains("* @returns { CommonModels.BaseLogLevelEnum } EmailAdminModels.BaseLogLevelEnumSchema stays literal")); + assert!( + actual.contains( + "* @description EmailAdminModels.BaseLogLevelEnumSchema remains unchanged" + ) + ); + assert!(actual.contains("const path = `/EmailAdminModels.BaseLogLevelEnumSchema/${id}`;")); + assert!(actual.contains("const quoted = \"EmailAdminModels.BaseLogLevelEnumSchema\";")); + assert!(actual.contains("const single = 'EmailAdminModels.BaseLogLevelEnumSchema';")); + assert!(actual.contains("// EmailAdminModels.BaseLogLevelEnumSchema remains unchanged")); + assert!(actual.contains("const regex = z.string().regex(/EmailAdminModels.BaseLogLevelEnumSchema/);")); + assert!(actual.contains(r"const escapedRegex = z.string().regex(/\/[/]EmailAdminModels.BaseLogLevelEnumSchema/);")); + assert!(!actual.contains("import { EmailAdminModels }")); + assert!(actual.contains("import { CommonModels }")); + } +} diff --git a/native/src/resolver.rs b/native/src/resolver.rs index f99b33f7..bcce09e1 100644 --- a/native/src/resolver.rs +++ b/native/src/resolver.rs @@ -96,6 +96,22 @@ impl<'a> Resolver<'a> { .collect(); let mut operations = index_operations(document, options)?; assign_unique_names(&mut operations, options); + // Naming shares a single collision index when tags are not split, but + // schema compilation still uses each operation's source tag, as in the + // JavaScript resolver's operation contexts. + if !options.split_by_tags { + for operation in &mut operations { + operation.tag = format_tag( + operation + .operation + .get("tags") + .and_then(Value::as_array) + .and_then(|tags| tags.first()) + .and_then(Value::as_str) + .unwrap_or(&options.default_tag), + ); + } + } let after_operations = started.elapsed(); let mut schema_tags = collect_schema_tags(document, &operations, &deep_dependencies); let after_tags = started.elapsed(); @@ -457,8 +473,8 @@ fn index_operations<'a>( .unwrap_or(&options.default_tag); let formatted = format_tag(source_tag); let normalized = formatted.to_lowercase(); - if (!include.is_empty() && !include.contains(&normalized)) - || exclude.contains(&normalized) + if !include.contains(&normalized) + && (!include.is_empty() || exclude.contains(&normalized)) { continue; } diff --git a/native/src/zod.rs b/native/src/zod.rs index 1b044a98..69fc47d3 100644 --- a/native/src/zod.rs +++ b/native/src/zod.rs @@ -297,11 +297,10 @@ impl<'a> ZodCompiler<'a> { root_ref, stack, )?; - let actual = self.resolve_schema(items).unwrap_or(items); format!( "{code}{}", chain( - actual, + items, Meta { required: true, parent_partial: false @@ -590,8 +589,12 @@ impl<'a> ZodCompiler<'a> { } fn models_namespace(&self, tag: &str) -> String { - let suffix = self.options.configs.get("models") - .map(|config| config.namespace_suffix.as_str()).unwrap_or("Models"); + let suffix = self + .options + .configs + .get("models") + .map(|config| config.namespace_suffix.as_str()) + .unwrap_or("Models"); format!("{}{suffix}", capitalize(tag)) } diff --git a/package.json b/package.json index 36511ac0..806ae674 100644 --- a/package.json +++ b/package.json @@ -91,7 +91,7 @@ "test": "bun run build:native && bun run test:ts && bun run test:native", "test:ts": "OPENAPI_CODEGEN_NATIVE=0 vitest run", "test:native": "OPENAPI_CODEGEN_NATIVE=1 OPENAPI_CODEGEN_REQUIRE_FULL_NATIVE=1 vitest run", - "test:parity": "vitest run src/native/renderer-parity.test.ts src/native/renderer-parity-manifest.test.ts src/native/model-imports.test.ts", + "test:parity": "vitest run src/native/renderer-parity.test.ts src/native/renderer-parity-manifest.test.ts src/native/model-imports.test.ts src/native/configuration-parity.test.ts src/native/configuration-runtime.test.ts src/native/configuration-lifecycle.test.ts", "test:watch": "vitest", "build": "tsdown && bun run build:native", "build:ts": "tsdown", diff --git a/scripts/renderer-parity-cases.ts b/scripts/renderer-parity-cases.ts new file mode 100644 index 00000000..695f7181 --- /dev/null +++ b/scripts/renderer-parity-cases.ts @@ -0,0 +1,44 @@ +import { parse } 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 function renderParityCase(source: string, scenario: ParityScenario, renderer: "js" | "native") { + let options; + try { + options = resolveConfig({ + fileConfig: { + input: "fixture", + output: "generated", + acl: false, + restClientImportPath: "@test/rest", + ...scenario.options, + }, + params: {}, + }); + } catch (error) { + if (scenario.invalid && error instanceof Error && error.message === scenario.invalid) + return { files: [], route: "rejected" }; + throw error; + } + if (scenario.invalid) throw new Error(`Invalid configuration was accepted: ${scenario.name}`); + if (renderer === "js") return { files: generateCodeFromOpenAPIDoc(parse(source), options), route: "js" }; + // Match the production route: complete native first, otherwise native extraction with + // per-generator rendering. Never retry a failed native call using JavaScript extraction. + const previous = process.env.OPENAPI_CODEGEN_REQUIRE_FULL_NATIVE; + process.env.OPENAPI_CODEGEN_REQUIRE_FULL_NATIVE = "0"; + try { + const files = generateFilesFromNativeOpenAPI(source, true, options); + if (files) return { files, route: "full-native" }; + return { + files: generateCodeFromOpenAPIDoc(parse(source), options, undefined, { source, yaml: true }), + route: "hybrid-native", + }; + } finally { + if (previous === undefined) delete process.env.OPENAPI_CODEGEN_REQUIRE_FULL_NATIVE; + else process.env.OPENAPI_CODEGEN_REQUIRE_FULL_NATIVE = previous; + } +} diff --git a/scripts/renderer-parity-configs.ts b/scripts/renderer-parity-configs.ts new file mode 100644 index 00000000..b815c969 --- /dev/null +++ b/scripts/renderer-parity-configs.ts @@ -0,0 +1,135 @@ +import { DEFAULT_GENERATE_OPTIONS } from "../src/generators/const/options.const"; +import type { GenerateOptions } from "../src/generators/types/options"; + +export type ParityScenario = { name: string; options: Partial; invalid?: string }; +const layoutKeys = [ + "splitByTags", + "tsNamespaces", + "modelsInCommon", + "modelsInModules", + "modelsOnly", + "treeShakeableNamespaces", +] as const; +type LayoutKey = (typeof layoutKeys)[number]; +type LifecycleKey = "input" | "output" | "clearOutput" | "incremental"; +type OptionKey = Exclude; +const bool = (key: K) => + [false, true].map((value) => ({ [key]: value }) as Partial); +const configs = DEFAULT_GENERATE_OPTIONS.configs; + +// Every renderer option must have representative values; adding an option fails typechecking +// until its coverage is classified. Strings/arrays have unbounded domains, so these are examples. +export const optionCases = { + defaultTag: [{ defaultTag: "Shared" }], + includeTags: [{ includeTags: ["pet"] }, { includeTags: ["PET"], excludeTags: ["pet"] }], + excludeTags: [{ excludeTags: ["pet"] }], + excludePathRegex: [{ excludePathRegex: "^/pet" }], + excludeRedundantZodSchemas: bool("excludeRedundantZodSchemas"), + tsPath: [{ tsPath: "@/openapi" }], + importPath: ["ts", "relative", "absolute"].map((importPath) => ({ importPath })) as Partial[], + configs: [ + { + configs: Object.fromEntries( + Object.entries(configs).map(([key, value]) => [ + key, + { + outputFileNameSuffix: `${value.outputFileNameSuffix}-custom`, + namespaceSuffix: `${value.namespaceSuffix}Custom`, + }, + ]), + ) as GenerateOptions["configs"], + }, + ], + baseUrl: [{ baseUrl: "https://example.test/api" }], + standalone: bool("standalone"), + schemaSuffix: [{ schemaSuffix: "Validator" }, { schemaSuffix: "" }], + enumSuffix: [{ enumSuffix: "Kind" }, { enumSuffix: "" }], + withImplicitRequiredProps: bool("withImplicitRequiredProps"), + withDefaultValues: bool("withDefaultValues"), + withDescription: bool("withDescription"), + allReadonly: bool("allReadonly"), + extractEnums: bool("extractEnums"), + replaceOptionalWithNullish: bool("replaceOptionalWithNullish"), + restClient: [{ restClient: "axios" }, { restClient: "native" }], + restClientImportPath: [{ restClientImportPath: "" }, { restClientImportPath: "@test/custom-client" }], + zodImportPath: [{ zodImportPath: "@test/zod" }], + errorHandlingImportPath: [{ errorHandlingImportPath: "@test/errors" }], + withDeprecatedEndpoints: bool("withDeprecatedEndpoints"), + removeOperationPrefixEndingWith: [ + { removeOperationPrefixEndingWith: "" }, + { removeOperationPrefixEndingWith: "Controller_" }, + ], + parseRequestParams: bool("parseRequestParams"), + inlineEndpoints: bool("inlineEndpoints"), + inlineEndpointsExcludeModules: [{ inlineEndpoints: true, inlineEndpointsExcludeModules: ["pet"] }], + queryTypesImportPath: [{ queryTypesImportPath: "@test/query-types" }], + mutationEffectsImportPath: [{ mutationEffects: true, mutationEffectsImportPath: "@test/mutation-effects" }], + axiosRequestConfig: bool("axiosRequestConfig"), + mutationEffects: bool("mutationEffects"), + mutationDefaultOnError: bool("mutationDefaultOnError"), + workspaceContext: [{ workspaceContext: [] }, { workspaceContext: ["officeId"] }], + prefetchQueries: bool("prefetchQueries"), + mutationScope: [false, true, { include: ["Items/update"] }, { exclude: ["Items/update"] }].map((mutationScope) => ({ + mutationScope, + })), + infiniteQueries: bool("infiniteQueries"), + infiniteQueryParamNames: [{ infiniteQueries: true, infiniteQueryParamNames: { page: "pageIndex" } }], + infiniteQueryResponseParamNames: [ + { + infiniteQueries: true, + infiniteQueryResponseParamNames: { page: "pageIndex", totalItems: "count", limit: "pageSize" }, + }, + ], + acl: bool("acl"), + checkAcl: [false, true].map((checkAcl) => ({ acl: true, checkAcl })), + abilityContextGenericAppAbilities: [false, true].map((abilityContextGenericAppAbilities) => ({ + acl: true, + abilityContextGenericAppAbilities, + })), + abilityContextImportPath: [{ acl: true, abilityContextImportPath: "@test/ability-context" }], + aclCheckImportPath: [{ acl: true, aclCheckImportPath: "@test/acl" }], + builderConfigs: bool("builderConfigs"), + filterParamName: [{ builderConfigs: true, filterParamName: "filters" }], + dataResponseParamNames: [{ builderConfigs: true, dataResponseParamNames: ["results"] }], + dynamicInputsImportPath: [{ builderConfigs: true, dynamicInputsImportPath: "@test/inputs" }], + dynamicColumnsImportPath: [{ builderConfigs: true, dynamicColumnsImportPath: "@test/columns" }], +} satisfies Record[]>; + +export const lifecycleOptions = [ + "input", + "output", + "clearOutput", + "incremental", +] as const satisfies readonly LifecycleKey[]; +export const parityScenarios: ParityScenario[] = []; +for (let bits = 0; bits < 64; bits++) { + const options = Object.fromEntries( + layoutKeys.map((key, bit) => [key, Boolean(bits & (1 << bit))]), + ) as Partial; + parityScenarios.push({ + name: `layout-${bits.toString(2).padStart(6, "0")}`, + options, + ...(options.modelsInCommon && options.modelsInModules + ? { invalid: "modelsInCommon and modelsInModules cannot both be enabled" } + : {}), + }); +} +for (const [key, variants] of Object.entries(optionCases)) { + for (const [index, variant] of variants.entries()) { + for (const modelsInCommon of [false, true]) { + parityScenarios.push({ + name: `option-${key}-${index}-${modelsInCommon ? "common" : "local"}`, + options: { tsNamespaces: true, modelsInCommon, ...variant }, + }); + } + } +} +// Interacting transport/output choices, beyond one-option-at-a-time variants. +for (const restClient of ["axios", "native"] as const) + for (const importPath of ["ts", "relative", "absolute"] as const) + for (const standalone of [false, true]) { + parityScenarios.push({ + name: `transport-${restClient}-${importPath}-${standalone}`, + options: { modelsInCommon: true, restClient, importPath, standalone, axiosRequestConfig: true }, + }); + } diff --git a/scripts/renderer-parity.ts b/scripts/renderer-parity.ts index 104b34e8..9f786aea 100644 --- a/scripts/renderer-parity.ts +++ b/scripts/renderer-parity.ts @@ -1,15 +1,8 @@ import { createHash } from "node:crypto"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; -import { parse } from "yaml"; -import type { OpenAPIV3 } from "openapi-types"; - -import { resolveConfig } from "../src/generators/core/resolveConfig"; -import { generateCodeFromOpenAPIDoc } from "../src/generators/generateCodeFromOpenAPIDoc"; -import { generateFilesFromNativeOpenAPI } from "../src/native/generateFilesFromNativeOpenAPI"; -import { getNativeBindings } from "../src/native/native-bindings"; -import { GenerateType, type GenerateFileData } from "../src/generators/types/generate"; -import { getTagFileName } from "../src/generators/utils/generate/generate.utils"; +import { parityScenarios } from "./renderer-parity-configs"; +import { parityFixtures, renderParityCase } from "./renderer-parity-cases"; export type Manifest = Record; @@ -37,65 +30,48 @@ async function generate(renderer: string, output: string) { // Explicit selection also protects against accidentally adding automatic fallback here. process.env.OPENAPI_CODEGEN_NATIVE = renderer === "js" ? "0" : "1"; process.env.OPENAPI_CODEGEN_REQUIRE_FULL_NATIVE = "1"; - const source = await readFile("test/petstore.yaml", "utf8"); - const document = parse(source) as OpenAPIV3.Document; - // Refuse stale output: every uploaded file must belong to this generation. + // Refuse stale output: every uploaded file belongs to this generation. await mkdir(path.dirname(output), { recursive: true }); await mkdir(output); const manifest: Manifest = {}; - const scenarios = [ - { name: "namespaces", tsNamespaces: true, modelsInCommon: true, modelsOnly: false }, - { name: "modules", tsNamespaces: false, modelsInCommon: false, modelsOnly: false }, - { name: "local-model-namespaces", tsNamespaces: true, modelsInCommon: false, modelsOnly: true }, - ]; - for (const { name: scenario, tsNamespaces, modelsInCommon, modelsOnly } of scenarios) { - const options = resolveConfig({ - fileConfig: { - input: "test/petstore.yaml", - output: "generated", - tsNamespaces, - modelsInCommon, - modelsOnly, - acl: false, - restClientImportPath: "@test/app-rest-client", - }, - params: {}, - }); - let files: GenerateFileData[] | undefined; - if (renderer === "js") { - files = generateCodeFromOpenAPIDoc(document, options); - } else if (modelsOnly) { - // This configuration uses native model rendering through the hybrid pipeline. - // Read native output directly so JS fallback cannot mask a regression. - const { renderedModels } = getNativeBindings().compileData(source, true, JSON.stringify(options)).data as { - renderedModels: Record; - }; - files = Object.entries(renderedModels).map(([tag, content]) => ({ - fileName: path.join(options.output, getTagFileName({ tag, type: GenerateType.Models, options })), - content, - })); - } else { - files = generateFilesFromNativeOpenAPI(source, true, options); - } - if (!files?.length) throw new Error(`${renderer} did not generate files for ${scenario}`); - for (const file of files) { - const relative = path.relative(options.output, file.fileName); - if (relative.startsWith("..") || path.isAbsolute(relative)) - throw new Error(`Unexpected output: ${file.fileName}`); - const name = `${scenario}/${relative.split(path.sep).join("/")}`; - if (name in manifest) throw new Error(`Duplicate generated file: ${name}`); - const destination = path.join(output, "files", name); - await mkdir(path.dirname(destination), { recursive: true }); - await writeFile(destination, file.content); - // Hash exact bytes on disk; do not normalize whitespace or generated code. - manifest[name] = createHash("sha256") - .update(await readFile(destination)) + const routes: Record = {}; + for (const fixture of parityFixtures) { + const source = await readFile(fixture, "utf8"); + for (const scenario of parityScenarios) { + const prefix = `${path.basename(fixture, ".yaml")}/${scenario.name}`; + const { files, route } = renderParityCase(source, scenario, renderer); + routes[prefix] = route; + // Include even deliberately empty and rejected cases in the hash contract. + manifest[`${prefix}/case.json`] = createHash("sha256") + .update( + JSON.stringify({ + options: scenario.options, + rejected: Boolean(scenario.invalid), + files: files.map((f) => f.fileName).sort(), + }), + ) .digest("hex"); + for (const file of files) { + const relative = path.relative("generated", file.fileName); + if (relative.startsWith("..") || path.isAbsolute(relative)) + throw new Error(`Unexpected output: ${file.fileName}`); + const name = `${prefix}/${relative.split(path.sep).join("/")}`; + if (name in manifest) throw new Error(`Duplicate generated file: ${name}`); + const destination = path.join(output, "files", name); + await mkdir(path.dirname(destination), { recursive: true }); + await writeFile(destination, file.content); + manifest[name] = createHash("sha256") + .update(await readFile(destination)) + .digest("hex"); + } } } + await writeFile(path.join(output, "routes.json"), `${JSON.stringify(routes, null, 2)}\n`); const sorted = Object.fromEntries(Object.entries(manifest).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))); await writeFile(path.join(output, "manifest.json"), `${JSON.stringify(sorted, null, 2)}\n`); - console.log(`${renderer}: hashed ${Object.keys(manifest).length} generated files`); + console.log( + `${renderer}: hashed ${Object.keys(manifest).length - Object.keys(routes).length} generated files across ${Object.keys(routes).length} cases`, + ); } async function compare(directory: string, artifacts: string[]) { @@ -115,7 +91,9 @@ async function compare(directory: string, artifacts: string[]) { } } if (failed) throw new Error("Generated file lists or SHA-256 hashes differ"); - console.log(`All ${artifacts.length} renderers/platforms match (${Object.keys(manifests[0]).length} files each)`); + console.log( + `All ${artifacts.length} renderers/platforms match (${Object.keys(manifests[0]).length} manifest entries each)`, + ); } if (import.meta.main) { diff --git a/src/generators/core/resolveConfig.ts b/src/generators/core/resolveConfig.ts index 7b8cfe87..5fa214e2 100644 --- a/src/generators/core/resolveConfig.ts +++ b/src/generators/core/resolveConfig.ts @@ -23,6 +23,9 @@ export function resolveConfig({ inlineEndpointsExcludeModules: inlineEndpointsExcludeModules?.split(","), workspaceContext: workspaceContext?.split(","), }); + if (resolvedConfig.modelsInCommon && resolvedConfig.modelsInModules) { + throw new Error("modelsInCommon and modelsInModules cannot both be enabled"); + } resolvedConfig.checkAcl = resolvedConfig.acl && resolvedConfig.checkAcl; resolvedConfig.workspaceContext = Array.from( new Set((resolvedConfig.workspaceContext ?? []).map((value) => value.trim()).filter(Boolean)), diff --git a/src/generators/utils/generate/generate.imports.utils.ts b/src/generators/utils/generate/generate.imports.utils.ts index 8612ee61..4853fd23 100644 --- a/src/generators/utils/generate/generate.imports.utils.ts +++ b/src/generators/utils/generate/generate.imports.utils.ts @@ -25,14 +25,14 @@ export function getModelsImports({ zodSchemasAsTypes?: string[]; }) { const type = GenerateType.Models; - const getTag = (zodSchemaName: string) => resolver.getTagByZodSchemaName(zodSchemaName); + const getTag = (zodSchemaName: string) => resolver.getTagByZodSchemaName(zodSchemaName.split(".")[0]); const zodSchemaImports = getImports({ type, tag, entities: zodSchemas, getTag, - getEntityName: (zodSchema) => zodSchema, + getEntityName: (zodSchema) => zodSchema.split(".")[0], options: resolver.options, }); diff --git a/src/generators/utils/generate/generate.zod.utils.ts b/src/generators/utils/generate/generate.zod.utils.ts index 96ce36b1..c9cb198e 100644 --- a/src/generators/utils/generate/generate.zod.utils.ts +++ b/src/generators/utils/generate/generate.zod.utils.ts @@ -14,14 +14,14 @@ import { isNamedZodSchema } from "@/generators/utils/zod-schema.utils"; import { getSchemaDescriptions } from "./generate.openapi.utils"; export const getZodSchemaInferedTypeName = (zodSchemaName: string, options: GenerateOptions) => - removeSuffix(zodSchemaName, options.schemaSuffix); + removeSuffix(isNamedZodSchema(zodSchemaName) ? zodSchemaName.split(".")[0] : zodSchemaName, options.schemaSuffix); export const getImportedZodSchemaName = (resolver: SchemaResolver, zodSchemaName: string, namespaceTag?: string) => { if (!isNamedZodSchema(zodSchemaName)) { return zodSchemaName; } - const tag = getOwningOrLocalProxyTag(resolver, zodSchemaName, namespaceTag); + const tag = getOwningOrLocalProxyTag(resolver, zodSchemaName.split(".")[0], namespaceTag); const namespacePrefix = resolver.options.tsNamespaces ? `${getNamespaceName({ type: GenerateType.Models, tag, options: resolver.options })}.` : ""; @@ -57,7 +57,7 @@ export const getImportedZodSchemaInferedTypeName = ( // See getOwningOrLocalProxyTag. namespaceTag still forces the prefix to render even when // tag === currentTag. - const tag = getOwningOrLocalProxyTag(resolver, zodSchemaName, namespaceTag); + const tag = getOwningOrLocalProxyTag(resolver, zodSchemaName.split(".")[0], namespaceTag); const namespacePrefix = resolver.options.tsNamespaces && (Boolean(namespaceTag) || tag !== currentTag) ? `${getNamespaceName({ type: GenerateType.Models, tag, options: resolver.options })}.` diff --git a/src/native/configuration-lifecycle.test.ts b/src/native/configuration-lifecycle.test.ts new file mode 100644 index 00000000..349f33ea --- /dev/null +++ b/src/native/configuration-lifecycle.test.ts @@ -0,0 +1,89 @@ +import { mkdtemp, readFile, rm, stat, utimes, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { stringify } from "yaml"; +import { describe, expect, test } from "vitest"; + +import { runGenerate } from "@/generators/run/generate.runner"; + +const cases = ["js", "native"].flatMap((renderer) => + ["yaml", "json"].flatMap((format) => [false, true].map((incremental) => ({ renderer, format, incremental }))), +); + +describe("configuration input and output lifecycle", () => { + test.each(cases)("$renderer / $format / incremental=$incremental", async ({ renderer, format, incremental }) => { + const directory = await mkdtemp(path.join(os.tmpdir(), "codegen-lifecycle-")); + const input = path.join(directory, `schema.${format}`); + const output = path.join(directory, "output"); + 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"; + const document = (obsolete: boolean) => ({ + openapi: "3.0.3", + info: { title: "Lifecycle", version: "1" }, + paths: Object.fromEntries( + (obsolete ? ["records", "obsolete"] : ["records"]).map((tag) => [ + `/${tag}`, + { get: { operationId: `read${tag}`, tags: [tag], responses: { "204": { description: "OK" } } } }, + ]), + ), + }); + const saveInput = async (obsolete: boolean) => { + const value = document(obsolete); + await writeFile(input, format === "json" ? JSON.stringify(value) : stringify(value)); + }; + const generate = (clearOutput: boolean) => + runGenerate({ + fileConfig: { + input, + output, + incremental, + clearOutput, + modelsInCommon: true, + acl: false, + mutationEffects: false, + restClientImportPath: "@test/rest", + }, + }); + try { + await saveInput(true); + const first = await generate(false); + expect(first.config.input).toBe(input); + expect(first.config.output).toBe(output); + expect(first.stats.generatedFilesCount).toBeGreaterThan(0); + const current = path.join(output, "records/records.api.ts"); + const stale = path.join(output, "obsolete/obsolete.api.ts"); + expect(await readFile(current, "utf8")).toContain("/records"); + expect(await readFile(stale, "utf8")).toContain("/obsolete"); + const userFile = path.join(output, "obsolete/notes.ts"); + await writeFile(userFile, "// User-owned source\n"); + + const sentinel = new Date("2001-01-01T00:00:00Z"); + await utimes(current, sentinel, sentinel); + const unchangedMtime = (await stat(current)).mtimeMs; + await saveInput(false); + await generate(false); + expect(await readFile(stale, "utf8")).toContain("/obsolete"); + // incremental is currently a compatibility option: unchanged writes are skipped + // for both values. Assert the actual behavior without timers or sleeps. + expect((await stat(current)).mtimeMs).toBe(unchangedMtime); + + await generate(true); + await expect(stat(stale)).rejects.toMatchObject({ code: "ENOENT" }); + expect(await readFile(userFile, "utf8")).toBe("// User-owned source\n"); + expect((await stat(current)).mtimeMs).toBe(unchangedMtime); + + await rm(input); + await expect(generate(true)).rejects.toMatchObject({ code: "ENOENT" }); + expect(await readFile(current, "utf8")).toContain("/records"); + expect(await readFile(userFile, "utf8")).toBe("// User-owned source\n"); + } 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 }); + } + }); +}); diff --git a/src/native/configuration-parity.test.ts b/src/native/configuration-parity.test.ts new file mode 100644 index 00000000..908e90e9 --- /dev/null +++ b/src/native/configuration-parity.test.ts @@ -0,0 +1,119 @@ +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 { getNativeBindings } from "./native-bindings"; + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); +}); + +describe("configuration coverage inventory", () => { + test("covers every layout combination without duplicate cases", () => { + const layouts = parityScenarios.filter((s) => s.name.startsWith("layout-")); + expect(layouts).toHaveLength(64); + expect(layouts.filter((s) => s.invalid)).toHaveLength(16); + expect(new Set(parityScenarios.map((s) => s.name)).size).toBe(parityScenarios.length); + expect(Object.keys(optionCases).length).toBeGreaterThan(40); + expect(lifecycleOptions).toEqual(["input", "output", "clearOutput", "incremental"]); + }); +}); + +for (const fixture of parityFixtures) { + const source = readFileSync(fixture, "utf8"); + describe(`all renderer configurations: ${fixture}`, () => { + test.each(parityScenarios)("$name", (scenario) => { + vi.stubEnv("OPENAPI_CODEGEN_NATIVE", "1"); + const binding = vi.spyOn(getNativeBindings(), "compileData"); + const expected = renderParityCase(source, scenario, "js"); + expect(binding).not.toHaveBeenCalled(); + const actual = renderParityCase(source, scenario, "native"); + if (scenario.invalid) { + expect(actual.route).toBe("rejected"); + expect(binding).not.toHaveBeenCalled(); + } else { + expect(binding).toHaveBeenCalled(); + } + const contents = (files: typeof actual.files) => Object.fromEntries(files.map((f) => [f.fileName, f.content])); + expect(contents(actual.files)).toEqual(contents(expected.files)); + }); + }); +} + +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"); + for (const [tsNamespaces, modelsInCommon] of [ + [true, true], + [false, false], + ]) { + expect(renderParityCase(source, { name: "full", options: { tsNamespaces, modelsInCommon } }, "native").route).toBe( + "full-native", + ); + } + expect( + renderParityCase(source, { name: "hybrid", options: { tsNamespaces: true, modelsInCommon: false } }, "native") + .route, + ).toBe("hybrid-native"); +}); + +test("mutation scope include/exclude examples exercise the selected path mutation", () => { + const source = readFileSync("test/configuration.yaml", "utf8"); + for (const renderer of ["js", "native"] as const) { + vi.stubEnv("OPENAPI_CODEGEN_NATIVE", "1"); + const query = (mutationScope: { include: string[] } | { exclude: string[] }) => + renderParityCase( + source, + { name: "scope", options: { modelsInCommon: true, mutationScope } }, + renderer, + ).files.find((f) => f.fileName.endsWith("items.queries.ts"))!.content; + expect(query({ include: ["Items/update"] })).toContain("export const useUpdate = ({ officeId, id }"); + expect(query({ exclude: ["Items/update"] })).toContain("export const useUpdate = (options?"); + } +}); + +test("described response schema uses its owning model namespace", () => { + const source = readFileSync("test/configuration.yaml", "utf8"); + for (const renderer of ["js", "native"] as const) { + vi.stubEnv("OPENAPI_CODEGEN_NATIVE", "1"); + const api = renderParityCase( + source, + { name: "description-owner", options: { modelsInCommon: false, withDescription: true } }, + renderer, + ).files.find((f) => f.fileName.endsWith("items.api.ts"))!.content; + expect(api).toContain("ItemsModels.ItemSchema.describe("); + expect(api).not.toContain("CommonModels.ItemSchema.describe("); + } +}); + +test("model owner resolution preserves model-like text inside endpoint URLs", () => { + vi.stubEnv("OPENAPI_CODEGEN_NATIVE", "1"); + const source = readFileSync("test/petstore.yaml", "utf8").replace( + "/activity/email:", + "/EmailAdminModels.BaseLogLevelEnumSchema:", + ); + expect(source).toContain("/EmailAdminModels.BaseLogLevelEnumSchema:"); + const scenario = { name: "literal-owner", options: { modelsInCommon: false } }; + const expected = renderParityCase(source, scenario, "js"); + const actual = renderParityCase(source, scenario, "native"); + const api = (files: typeof actual.files) => files.find((f) => f.fileName.endsWith("emailAdmin.api.ts"))!.content; + expect(api(expected.files)).toContain("/EmailAdminModels.BaseLogLevelEnumSchema"); + expect(api(actual.files)).toBe(api(expected.files)); +}); + +test("model owner resolution preserves model-like text inside validation regexes", () => { + vi.stubEnv("OPENAPI_CODEGEN_NATIVE", "1"); + const document = parse(readFileSync("test/petstore.yaml", "utf8")); + document.paths["/activity/email"].get.parameters = [ + { name: "filter", in: "query", schema: { type: "string", pattern: "EmailAdminModels.BaseLogLevelEnumSchema" } }, + ]; + const source = stringify(document); + const scenario = { name: "regex-owner", options: { modelsInCommon: false } }; + const expected = renderParityCase(source, scenario, "js"); + const actual = renderParityCase(source, scenario, "native"); + const api = (files: typeof actual.files) => files.find((f) => f.fileName.endsWith("emailAdmin.api.ts"))!.content; + expect(api(expected.files)).toContain(".regex(/EmailAdminModels.BaseLogLevelEnumSchema/)"); + expect(api(actual.files)).toBe(api(expected.files)); +}); diff --git a/src/native/configuration-runtime.test.ts b/src/native/configuration-runtime.test.ts new file mode 100644 index 00000000..59d5cf38 --- /dev/null +++ b/src/native/configuration-runtime.test.ts @@ -0,0 +1,71 @@ +import { execFileSync } from "node:child_process"; +import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, test } from "vitest"; + +import { renderParityCase } from "../../scripts/renderer-parity-cases"; +import { parityScenarios } from "../../scripts/renderer-parity-configs"; + +const layouts = parityScenarios.filter(({ name, invalid }) => name.startsWith("layout-") && !invalid); + +describe("generated model runtime across valid layouts", () => { + test("covers all 48 supported layout combinations", () => { + expect(layouts).toHaveLength(48); + }); + + test.each(layouts)("loads and validates models for $name", async (scenario) => { + const source = await readFile("test/petstore.yaml", "utf8"); + const previous = process.env.OPENAPI_CODEGEN_NATIVE; + process.env.OPENAPI_CODEGEN_NATIVE = "1"; + let generated; + try { + generated = renderParityCase( + source, + { + ...scenario, + options: { ...scenario.options, importPath: "relative" }, + }, + "native", + ); + } finally { + if (previous === undefined) delete process.env.OPENAPI_CODEGEN_NATIVE; + else process.env.OPENAPI_CODEGEN_NATIVE = previous; + } + expect(["full-native", "hybrid-native"]).toContain(generated.route); + const models = generated.files.filter(({ fileName }) => fileName.endsWith(".models.ts")); + expect(models.length).toBeGreaterThan(0); + const directory = await mkdtemp(path.join(os.tmpdir(), "native-layout-runtime-")); + try { + await symlink(path.join(process.cwd(), "node_modules"), path.join(directory, "node_modules"), "dir"); + const imports: string[] = []; + for (const [index, file] of models.entries()) { + const relative = path.relative("generated", file.fileName); + expect(relative.startsWith("..")).toBe(false); + const destination = path.join(directory, relative); + await mkdir(path.dirname(destination), { recursive: true }); + await writeFile(destination, file.content); + imports.push(`import * as model${index} from ${JSON.stringify(`./${relative}`)};`); + } + await writeFile( + path.join(directory, "check.ts"), + `${imports.join("\n")} +const modules = [${models.map((_, index) => `model${index}`).join(",")}]; +const candidates = modules.flatMap(module => [module, ...Object.values(module)]) + .filter(value => value && typeof value === "object") + .map(value => value.EmailActivityAdminResponseSchema).filter(Boolean); +if (!candidates.length) throw new Error("Missing email activity schema export"); +for (const schema of candidates) { + if (schema.parse({ level: "info", label: { text: "ok" } }).level !== "info") + throw new Error("Expected valid shared log level"); + if (schema.safeParse({ level: "invalid" }).success) + throw new Error("Expected invalid shared log level rejection"); +} +`, + ); + execFileSync("bun", [path.join(directory, "check.ts")], { stdio: "pipe", timeout: 15_000 }); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); +}); diff --git a/src/native/generateFilesFromNativeOpenAPI.ts b/src/native/generateFilesFromNativeOpenAPI.ts index 50ae84c7..2fc62d16 100644 --- a/src/native/generateFilesFromNativeOpenAPI.ts +++ b/src/native/generateFilesFromNativeOpenAPI.ts @@ -21,6 +21,9 @@ export function generateFilesFromNativeOpenAPI( yaml: boolean, options: GenerateOptions, ): GenerateFileData[] | undefined { + if (options.standalone && options.importPath === "ts") { + options = { ...options, importPath: "relative" }; + } if (!supportsCompleteNativeRender(options)) { if (process.env.OPENAPI_CODEGEN_REQUIRE_FULL_NATIVE === "1") { throw new Error("The selected options are not supported by the full native renderer"); diff --git a/src/native/getDataFromNativeOpenAPIDoc.ts b/src/native/getDataFromNativeOpenAPIDoc.ts index fb3e5b78..a6ced722 100644 --- a/src/native/getDataFromNativeOpenAPIDoc.ts +++ b/src/native/getDataFromNativeOpenAPIDoc.ts @@ -12,6 +12,7 @@ type NativeData = { endpoints: Endpoint[]; schemas: Record; schemaOwners: Record; + schemaUsageTags: Record; schemaRefs: Record; circularSchemas: string[]; topologyOrder: string[]; @@ -106,7 +107,9 @@ class NativeSchemaResolver { } getTagByZodSchemaName(name: string) { - return this.options.modelsInCommon ? this.options.defaultTag : this.nativeData.schemaOwners[name]; + return !this.options.splitByTags || this.options.modelsInCommon + ? this.options.defaultTag + : (this.nativeData.schemaOwners[name] ?? this.options.defaultTag); } isSchemaCircular(ref: string) { @@ -255,8 +258,12 @@ export function getDataFromNativeOpenAPIDoc( getTagElement(getEndpointTag(endpoint, options), data).endpoints.push(endpoint); } for (const [name, code] of Object.entries(nativeData.schemas)) { - const tag = options.modelsInCommon ? options.defaultTag : nativeData.schemaOwners[name]; - if (tag) getTagElement(tag, data).zodSchemas[name] = code; + const tags = options.modelsInModules + ? (nativeData.schemaUsageTags[name] ?? [options.defaultTag]) + : [options.modelsInCommon ? options.defaultTag : nativeData.schemaOwners[name]]; + for (const tag of tags) { + if (tag) getTagElement(tag, data).zodSchemas[name] = code; + } } if (process.env.OPENAPI_NATIVE_PROFILE === "1") { diff --git a/test/configuration.yaml b/test/configuration.yaml new file mode 100644 index 00000000..2b73ecd6 --- /dev/null +++ b/test/configuration.yaml @@ -0,0 +1,127 @@ +openapi: 3.0.3 +info: + title: Generator configuration coverage + version: 1.0.0 +servers: + - url: https://example.test/api +paths: + /offices/{officeId}/items: + parameters: + - name: officeId + in: path + required: true + schema: { type: string } + get: + tags: [Items] + operationId: ItemsController_list + x-acl: [{ action: read, subject: Item, conditions: { officeId: "$params.officeId" } }] + parameters: + - { name: page, in: query, schema: { type: integer, default: 1 } } + - { name: pageIndex, in: query, schema: { type: integer, default: 1 } } + - { name: limit, in: query, schema: { type: integer, default: 20 } } + - { name: filter, in: query, schema: { type: string } } + - { name: filters, in: query, schema: { type: string } } + responses: + "200": + description: A page of items + content: + application/json: + schema: { $ref: "#/components/schemas/ItemsPage" } + post: + tags: [Items] + operationId: ItemsController_create + x-acl: [{ action: create, subject: Item }] + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/Item" } + responses: + "201": + description: Created + content: + application/json: + schema: { $ref: "#/components/schemas/Item" } + /offices/{officeId}/items/{id}: + parameters: + - { name: officeId, in: path, required: true, schema: { type: string } } + - { name: id, in: path, required: true, schema: { type: string } } + get: + tags: [Items] + operationId: ItemsController_read + responses: + "200": + description: Item + content: + application/json: + schema: { $ref: "#/components/schemas/Item" } + "404": + description: Missing item + content: + application/json: + schema: { $ref: "#/components/schemas/ItemNotFound" } + patch: + tags: [Items] + operationId: ItemsController_update + x-acl: [{ action: update, subject: Item, conditions: { officeId: "$params.officeId" } }] + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/Item" } + responses: + "200": + description: Updated + content: + application/json: + schema: { $ref: "#/components/schemas/Item" } + delete: + tags: [Items] + operationId: ItemsController_delete + responses: + "204": { description: Deleted } + /legacy-items: + get: + tags: [Legacy] + operationId: LegacyController_list + deprecated: true + responses: + "200": + description: Legacy items + content: + application/json: + schema: { type: array, items: { $ref: "#/components/schemas/Item" } } +components: + schemas: + ItemNotFound: + type: object + x-domain-error-domain: item + x-domain-error-name: ItemNotFound + properties: + code: { type: number, enum: [4001] } + message: { type: string } + Item: + type: object + description: Item configuration example + required: [id] + properties: + id: { type: string, readOnly: true } + title: { type: string, default: Untitled, description: Display title } + note: { type: string, nullable: true } + enabled: { type: boolean, default: true } + status: { type: string, enum: [active, archived] } + ItemsPage: + type: object + required: [items, limit] + properties: + items: { type: array, items: { $ref: "#/components/schemas/Item" } } + results: { type: array, items: { $ref: "#/components/schemas/Item" } } + page: { type: integer } + pageIndex: { type: integer } + totalItems: { type: integer } + count: { type: integer } + limit: { type: integer } + pageSize: { type: integer } + Unused: + type: object + properties: { value: { type: string } }