From d6952f6a0772b29e921135f129b8601089d4041d Mon Sep 17 00:00:00 2001 From: Urban Krepel Date: Thu, 22 Oct 2026 12:12:12 +0200 Subject: [PATCH 01/17] perf: make native local-model rendering linear in tag count render_local_models called render_common_models once per tag, and each call re-walked every component schema (collect_enum_objects) and rebuilt the property reference names for all schema refs. The inline-enum import lookup also scanned all schemas per enum. Together this was O(tags x schemas). Collect enum objects and property reference names once, apply the per-tag namespace prefix at lookup time, and index inline enum names by code. Native compile on a 400x petstore spec (6,400 paths): 3.3 s -> 345 ms. Output is byte-identical to the JS renderer. Co-Authored-By: Claude Opus 5.5 --- native/src/render.rs | 152 ++++++++++++++++++++++++++++++------------- 1 file changed, 107 insertions(+), 45 deletions(-) diff --git a/native/src/render.rs b/native/src/render.rs index 21df785..0b554eb 100644 --- a/native/src/render.rs +++ b/native/src/render.rs @@ -52,8 +52,9 @@ pub fn render_model_proxies( document, schemas, schema_refs, - schema_owners, generated_objects, + &document_enum_objects(document), + &property_refs(schema_refs, schema_owners, options), circular_schemas, &options.default_tag, options, @@ -145,6 +146,17 @@ fn render_local_models( .map(|reference| (reference, name.as_str())) }) .collect(); + let enum_objects = document_enum_objects(document); + let property_refs = property_refs(schema_refs, schema_owners, options); + // First non-ref schema per enum code, matching the previous linear `find_map` over `schemas`. + let mut enum_name_by_code: HashMap<&str, &str> = HashMap::default(); + for (name, value) in schemas { + if let Some(code) = value.as_str() + && !schema_refs.contains_key(name) + { + enum_name_by_code.entry(code).or_insert(name.as_str()); + } + } for (tag, mut tag_schemas) in by_tag { let mut imports: IndexMap> = IndexMap::default(); for schema_name in tag_schemas.keys() { @@ -169,11 +181,7 @@ fn render_local_models( let mut enum_codes = Vec::new(); collect_inline_enum_codes(schema, &mut enum_codes); for code in enum_codes { - let Some(name) = schemas.iter().find_map(|(name, value)| { - (value.as_str() == Some(code.as_str()) - && !schema_refs.contains_key(name)) - .then_some(name.as_str()) - }) else { + let Some(name) = enum_name_by_code.get(code.as_str()).copied() else { continue; }; let Some(owner) = schema_owners.get(name).and_then(Value::as_str) else { @@ -243,8 +251,9 @@ fn render_local_models( document, &tag_schemas, schema_refs, - schema_owners, generated_objects, + &enum_objects, + &property_refs, circular_schemas, &tag, options, @@ -445,8 +454,9 @@ fn render_common_models( document: &Value, schemas: &Map, schema_refs: &Map, - schema_owners: &Map, generated_objects: &Map, + enum_objects: &HashMap, + property_refs: &PropertyRefs, circular_schemas: &[String], namespace_tag: &str, options: &GenerateOptions, @@ -458,31 +468,12 @@ 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); - } + let property_ref_names = PropertyRefNames { + refs: property_refs, + namespace_tag, + namespace_suffix, + ts_namespaces: options.ts_namespaces, + }; let mut lines = vec!["import { z } from \"zod\";".to_string(), String::new()]; if options.ts_namespaces { lines.push(format!("export namespace {namespace} {{")); @@ -503,7 +494,7 @@ fn render_common_models( code, schema_refs, generated_objects, - &enum_objects, + enum_objects, circular_schemas, suffix, &property_ref_names, @@ -535,7 +526,7 @@ fn render_common_schema_lines( enum_objects: &HashMap, circular_schemas: &[String], suffix: &str, - property_ref_names: &HashMap<&str, String>, + property_ref_names: &PropertyRefNames, options: &GenerateOptions, ) -> Option> { let code = code.as_str()?; @@ -714,6 +705,62 @@ fn add_nullable_type(schema: &Value, rendered: String) -> String { } } +/// Schema reference -> (type name, owner tag); independent of the tag being rendered. +type PropertyRefs = HashMap)>; + +fn property_refs( + schema_refs: &Map, + schema_owners: &Map, + options: &GenerateOptions, +) -> PropertyRefs { + schema_refs + .iter() + .filter_map(|(name, reference)| { + let reference = reference.as_str()?; + 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) + }; + Some(( + reference.to_string(), + ( + remove_suffix(name, &options.schema_suffix), + owner.map(str::to_string), + ), + )) + }) + .collect() +} + +/// Resolves property reference type names as seen from `namespace_tag`'s models file. +struct PropertyRefNames<'a> { + refs: &'a PropertyRefs, + namespace_tag: &'a str, + namespace_suffix: &'a str, + ts_namespaces: bool, +} + +impl PropertyRefNames<'_> { + fn get(&self, reference: &str) -> Option { + let (type_name, owner) = self.refs.get(reference)?; + Some(match owner { + Some(owner) if self.ts_namespaces && owner != self.namespace_tag => { + format!("{}{}.{type_name}", capitalize(owner), self.namespace_suffix) + } + _ => type_name.clone(), + }) + } +} + +fn document_enum_objects(document: &Value) -> HashMap { + let mut enum_objects = HashMap::default(); + if let Some(component_schemas) = document.pointer("/components/schemas") { + collect_enum_objects(component_schemas, &mut enum_objects); + } + enum_objects +} + fn collect_enum_objects(value: &Value, enums: &mut HashMap) { match value { Value::Array(values) => values @@ -794,7 +841,7 @@ fn collect_property_docs( schema: &Value, prefix: &str, properties: &mut IndexMap, - property_ref_names: &HashMap<&str, String>, + property_ref_names: &PropertyRefNames, ) { 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()) { @@ -905,7 +952,7 @@ fn collect_property_docs( fn property_doc_type( document: &Value, schema: &Value, - property_ref_names: &HashMap<&str, String>, + property_ref_names: &PropertyRefNames, ) -> String { if let Some(reference) = schema.get("$ref").and_then(Value::as_str) { return property_ref_type(reference, property_ref_names); @@ -945,9 +992,9 @@ fn property_doc_type( .to_string() } -fn property_ref_type(reference: &str, property_ref_names: &HashMap<&str, String>) -> String { +fn property_ref_type(reference: &str, property_ref_names: &PropertyRefNames) -> String { if let Some(name) = property_ref_names.get(reference) { - return name.clone(); + return name; } let mut name = reference .rsplit('/') @@ -2947,11 +2994,24 @@ fn resolve_model_namespace_owners( 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; } + 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..]; @@ -4149,7 +4209,9 @@ const escapedRegex = z.string().regex(/\/[/]EmailAdminModels.BaseLogLevelEnumSch 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( + "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 }")); From c1a581a425f2378437f640c4d1f1ad6e16315fc2 Mon Sep 17 00:00:00 2001 From: Urban Krepel Date: Thu, 22 Oct 2026 12:42:23 +0200 Subject: [PATCH 02/17] perf: render default options fully native With the default options (tsNamespaces without modelsInCommon, acl on) the native renderer was not trusted to be complete, so generation went through the hybrid path: Rust rendered everything, then JS rendered again. That Rust render was also super-linear, making the default CLI run ~20x slower than plain JS (12.1 s vs 0.56 s on a 200x petstore spec). - resolve_model_namespace_owners: build the model-owner index once per render (ModelOwners) instead of per tag module, and rewrite doc-comment type references in one pass instead of one replace per owned model. - render_app_acl: emit the untyped AppAbility file when there are no ACL rules, matching the TypeScript renderer. - Allow every namespace/model layout to render fully native; the 64-layout parity suite shows identical output. Default options now render fully native with byte-identical output (up to 6,804 files); generation on a 400x petstore spec takes ~385 ms vs ~600 ms in JS. Co-Authored-By: Claude Opus 5.5 --- native/src/lib.rs | 2 - native/src/render.rs | 133 ++++++++++++++---- src/native/configuration-parity.test.ts | 19 +-- .../generateFilesFromNativeOpenAPI.test.ts | 13 +- src/native/generateFilesFromNativeOpenAPI.ts | 1 - 5 files changed, 116 insertions(+), 52 deletions(-) diff --git a/native/src/lib.rs b/native/src/lib.rs index a6d2dd6..5a66ccd 100644 --- a/native/src/lib.rs +++ b/native/src/lib.rs @@ -404,8 +404,6 @@ pub fn compile_data(source: String, yaml: bool, options_json: String) -> Result< .len(); let rendered_complete = options.native_compact && options.split_by_tags - && ((options.models_in_common && options.ts_namespaces) - || (!options.models_in_common && !options.ts_namespaces)) && !options.inline_endpoints && !options.builder_configs && options.workspace_context.is_empty() diff --git a/native/src/render.rs b/native/src/render.rs index 0b554eb..833a78d 100644 --- a/native/src/render.rs +++ b/native/src/render.rs @@ -1306,6 +1306,7 @@ pub fn render_endpoints( .unwrap_or(&options.default_tag); by_tag.entry(tag.to_string()).or_default().push(endpoint); } + let model_owners = ModelOwners::new(schema_owners, options); for (tag, tag_endpoints) in by_tag { if should_inline_endpoints(&tag, options) || tag_endpoints.is_empty() { continue; @@ -1317,6 +1318,7 @@ pub fn render_endpoints( &tag_endpoints, schemas, schema_owners, + &model_owners, options, )), ); @@ -1357,6 +1359,7 @@ fn render_endpoint_module( endpoints: &[&Value], schemas: &Map, schema_owners: &Map, + model_owners: &ModelOwners, options: &GenerateOptions, ) -> String { let mut lines = vec![format!( @@ -1547,7 +1550,7 @@ fn render_endpoint_module( resolve_model_namespace_owners( format!("{}\n", lines.join("\n").trim_end()), tag, - schema_owners, + model_owners, options, ) } @@ -1984,6 +1987,7 @@ pub fn render_queries( by_tag.entry(tag.to_string()).or_default().push(endpoint); } let tags = by_tag.into_iter().collect::>(); + let model_owners = ModelOwners::new(schema_owners, options); let chunk_size = tags.len().div_ceil(4).max(1); let modules = std::thread::scope(|scope| { tags.chunks(chunk_size) @@ -1998,6 +2002,7 @@ pub fn render_queries( tag, tag_endpoints, schema_owners, + &model_owners, options, )), ) @@ -2114,7 +2119,13 @@ fn render_app_acl(endpoints: &[Value]) -> Option { } let acl_endpoints: Vec<&Value> = by_tag.into_values().flatten().collect(); if acl_endpoints.is_empty() { - return None; + // Without ACL rules the TypeScript renderer still emits an untyped ability. + return Some( + "import type { PureAbility, AbilityTuple, Subject } from \"@casl/ability\";\n\n\ + export type AppAbilities = AbilityTuple;\n\n\ + export type AppAbility = PureAbility;" + .into(), + ); } // Named schema conditions need the full TypeScript import merger for uncommon configurations. if acl_endpoints @@ -2560,6 +2571,7 @@ fn render_query_module( tag: &str, endpoints: &[&Value], schema_owners: &Map, + model_owners: &ModelOwners, options: &GenerateOptions, ) -> String { let model_suffix = options @@ -2914,14 +2926,59 @@ fn render_query_module( content = content.replace(&format!("{api_namespace}."), ""); content = content.replace(&format!("{acl_namespace}."), ""); } - resolve_model_namespace_owners(content, tag, schema_owners, options) + resolve_model_namespace_owners(content, tag, model_owners, options) +} + +/// Model name (with and without the schema suffix) -> owning tags, in `schema_owners` order. +/// Built once per render; each tag's module looks names up through `for_tag`. +pub(crate) struct ModelOwners<'a> { + owners: HashMap>, +} + +impl<'a> ModelOwners<'a> { + pub(crate) fn new(schema_owners: &'a Map, options: &GenerateOptions) -> Self { + let mut owners: HashMap> = HashMap::default(); + for (schema, owner) in schema_owners { + if let Some(owner) = owner.as_str() { + owners.entry(schema.clone()).or_default().push(owner); + owners + .entry(remove_suffix(schema, &options.schema_suffix)) + .or_default() + .push(owner); + } + } + Self { owners } + } + + fn for_tag<'b>(&'b self, tag: &'b str) -> TagModelOwners<'a, 'b> { + TagModelOwners { owners: self, tag } + } +} + +/// The owners of models referenced from `tag`'s module: tags other than `tag`. +struct TagModelOwners<'a, 'b> { + owners: &'b ModelOwners<'a>, + tag: &'b str, +} + +impl<'a> TagModelOwners<'a, '_> { + /// The last owner other than this tag, as the previous per-tag map (later inserts win) had it. + fn get(&self, name: &str) -> Option<&'a str> { + self.owners + .owners + .get(name)? + .iter() + .rev() + .find(|owner| **owner != self.tag) + .copied() + } } // Namespace-based modules can reference models shared by several tags. fn resolve_model_namespace_owners( content: String, tag: &str, - schema_owners: &Map, + model_owners: &ModelOwners, options: &GenerateOptions, ) -> String { if !options.ts_namespaces || options.models_in_common || options.models_in_modules { @@ -2934,13 +2991,7 @@ fn resolve_model_namespace_owners( .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 owners = model_owners.for_tag(tag); let mut imports = IndexSet::default(); let mut has_local = false; let mut output = String::with_capacity(content.len()); @@ -2967,13 +3018,12 @@ fn resolve_model_namespace_owners( .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(&rename_owned_models( + &line[start..end], + &prefix, + &owners, + namespace_suffix, + )); output.push_str(&line[end..]); } else { output.push_str(line); @@ -3051,7 +3101,7 @@ fn resolve_model_namespace_owners( .unwrap_or(remaining.len()); let name = &remaining[..length]; if let Some(owner) = owners.get(name) { - imports.insert(*owner); + imports.insert(owner); output.push_str(&format!("{}{namespace_suffix}.", capitalize(owner))); } else { has_local = true; @@ -3098,22 +3148,38 @@ 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(); +/// Rewrites `{prefix}{name}` to `{Owner}{namespace_suffix}.{name}` for models owned by another tag. +/// One pass: a match needs identifier boundaries on both sides, so only the identifier right after +/// the prefix can match, and a rewritten reference never forms a new match. +fn rename_owned_models( + value: &str, + prefix: &str, + owners: &TagModelOwners, + namespace_suffix: &str, +) -> String { + let mut output = String::with_capacity(value.len()); let mut remaining = value; - while let Some(index) = remaining.find(source) { + while let Some(index) = remaining.find(prefix) { output.push_str(&remaining[..index]); - let after = &remaining[index + source.len()..]; + let after = &remaining[index + prefix.len()..]; + let length = after + .find(|ch: char| !model_identifier_char(ch)) + .unwrap_or(after.len()); + let name = &after[..length]; 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; + .is_none_or(|ch| !model_identifier_char(ch)); + match owners.get(name).filter(|_| boundary) { + Some(owner) => { + output.push_str(&capitalize(owner)); + output.push_str(namespace_suffix); + output.push('.'); + } + None => output.push_str(prefix), + } + output.push_str(name); + remaining = &after[length..]; } output.push_str(remaining); output @@ -4197,7 +4263,12 @@ 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); + let actual = resolve_model_namespace_owners( + source.into(), + "EmailAdmin", + &ModelOwners::new(&owners, &options), + &options, + ); assert!(actual.contains("const value = CommonModels.BaseLogLevelEnumSchema;")); assert!(actual.contains("* @returns { CommonModels.BaseLogLevelEnum } EmailAdminModels.BaseLogLevelEnumSchema stays literal")); assert!( diff --git a/src/native/configuration-parity.test.ts b/src/native/configuration-parity.test.ts index 3412617..3a64e08 100644 --- a/src/native/configuration-parity.test.ts +++ b/src/native/configuration-parity.test.ts @@ -42,21 +42,16 @@ for (const fixture of parityFixtures) { }); } -test("canonical layouts retain full native rendering and local namespaces use native hybrid", () => { +test("every namespace and model layout renders fully native", () => { vi.stubEnv("OPENAPI_CODEGEN_NATIVE", "1"); const source = readParityFixture(parityFixtures[0]); - for (const [tsNamespaces, modelsInCommon] of [ - [true, true], - [false, false], - ]) { - expect(renderParityCase(source, { name: "full", options: { tsNamespaces, modelsInCommon } }, "native").route).toBe( - "full-native", - ); + for (const tsNamespaces of [true, false]) { + for (const modelsInCommon of [true, 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", () => { diff --git a/src/native/generateFilesFromNativeOpenAPI.test.ts b/src/native/generateFilesFromNativeOpenAPI.test.ts index 2c08013..688d952 100644 --- a/src/native/generateFilesFromNativeOpenAPI.test.ts +++ b/src/native/generateFilesFromNativeOpenAPI.test.ts @@ -15,15 +15,16 @@ describe("supportsCompleteNativeRender", () => { expect(supportsCompleteNativeRender(supportedOptions)).toBe(true); }); - it("supports namespace-free module-local models", () => { - expect(supportsCompleteNativeRender({ ...supportedOptions, modelsInCommon: false, tsNamespaces: false })).toBe( - true, - ); + it.each([ + ["module-local models with namespaces", { modelsInCommon: false, tsNamespaces: true }], + ["namespace-free module-local models", { modelsInCommon: false, tsNamespaces: false }], + ["namespace-free common models", { modelsInCommon: true, tsNamespaces: false }], + ["the default options", DEFAULT_GENERATE_OPTIONS], + ])("supports %s", (_name, override) => { + expect(supportsCompleteNativeRender({ ...supportedOptions, ...override })).toBe(true); }); it.each([ - ["module-local models with namespaces", { modelsInCommon: false }], - ["namespace-free common models", { tsNamespaces: false }], ["unsplit output", { splitByTags: false }], ["inline endpoints", { inlineEndpoints: true }], ["builder configs", { builderConfigs: true }], diff --git a/src/native/generateFilesFromNativeOpenAPI.ts b/src/native/generateFilesFromNativeOpenAPI.ts index f7c9245..3dd037b 100644 --- a/src/native/generateFilesFromNativeOpenAPI.ts +++ b/src/native/generateFilesFromNativeOpenAPI.ts @@ -86,7 +86,6 @@ export function generateFilesFromNativeOpenAPI( export function supportsCompleteNativeRender(options: GenerateOptions) { return ( options.splitByTags && - ((options.modelsInCommon && options.tsNamespaces) || (!options.modelsInCommon && !options.tsNamespaces)) && !options.inlineEndpoints && !options.builderConfigs && (options.workspaceContext?.length ?? 0) === 0 From aa3ec14e4d0c816ac0f3d48a412a365a261c9993 Mon Sep 17 00:00:00 2001 From: Urban Krepel Date: Thu, 22 Oct 2026 12:45:16 +0200 Subject: [PATCH 03/17] chore: bump Bun to 1.4.3 and drop unused tsx Pin Bun 1.4.3 in packageManager, engines, .tool-versions, @types/bun and the CI workflows. Scripts already run through Bun, so tsx was unused. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/check.yml | 2 +- .github/workflows/publish.yml | 4 +- .github/workflows/renderer-parity.yml | 4 +- .tool-versions | 2 +- bun.lock | 67 ++------------------------- package.json | 7 ++- 6 files changed, 14 insertions(+), 72 deletions(-) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 4d4fd70..2266213 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -20,7 +20,7 @@ jobs: - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: 1.4.1 + bun-version: 1.4.3 - uses: dtolnay/rust-toolchain@stable diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index b97d44d..d902529 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -21,7 +21,7 @@ jobs: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: 1.4.1 + bun-version: 1.4.3 - uses: dtolnay/rust-toolchain@stable - run: bun install --frozen-lockfile - run: bun run build:native @@ -42,7 +42,7 @@ jobs: - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: 1.4.1 + bun-version: 1.4.3 - run: bun install --frozen-lockfile - name: Set package version from tag diff --git a/.github/workflows/renderer-parity.yml b/.github/workflows/renderer-parity.yml index d7ba0a7..f86dc58 100644 --- a/.github/workflows/renderer-parity.yml +++ b/.github/workflows/renderer-parity.yml @@ -23,7 +23,7 @@ jobs: - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: "1.4.1" + bun-version: 1.4.3 - uses: dtolnay/rust-toolchain@stable if: matrix.renderer == 'native' @@ -63,7 +63,7 @@ jobs: - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: "1.4.1" + bun-version: 1.4.3 - run: bun install --frozen-lockfile diff --git a/.tool-versions b/.tool-versions index 1f2ac5e..333cc5b 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1 +1 @@ -bun 1.4.1 +bun 1.4.3 diff --git a/bun.lock b/bun.lock index 906e873..ebc1105 100644 --- a/bun.lock +++ b/bun.lock @@ -18,7 +18,7 @@ "@casl/ability": "6.8.0", "@casl/react": "5.0.1", "@tanstack/react-query": "~5.90.21", - "@types/bun": "1.4.1", + "@types/bun": "1.4.3", "@types/node": "^25.3.0", "@types/prompt-sync": "^4.2.3", "@types/react": "^19.2.14", @@ -31,7 +31,6 @@ "oxlint-tsgolint": "^0.15.0", "react": "^19.2.4", "tsdown": "^0.22.14", - "tsx": "^4.21.0", "type-fest": "^5.4.4", "typescript": "^7.0.2", "vite": "npm:rolldown-vite@^7.3.1", @@ -111,58 +110,6 @@ "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g=="], - "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ=="], - - "@esbuild/android-arm": ["@esbuild/android-arm@0.28.2", "", { "os": "android", "cpu": "arm" }, "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg=="], - - "@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.2", "", { "os": "android", "cpu": "arm64" }, "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A=="], - - "@esbuild/android-x64": ["@esbuild/android-x64@0.28.2", "", { "os": "android", "cpu": "x64" }, "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q=="], - - "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw=="], - - "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw=="], - - "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw=="], - - "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg=="], - - "@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.2", "", { "os": "linux", "cpu": "arm" }, "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w=="], - - "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug=="], - - "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ=="], - - "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ=="], - - "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA=="], - - "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ=="], - - "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA=="], - - "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg=="], - - "@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.2", "", { "os": "linux", "cpu": "x64" }, "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ=="], - - "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw=="], - - "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.2", "", { "os": "none", "cpu": "x64" }, "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw=="], - - "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ=="], - - "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw=="], - - "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q=="], - - "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g=="], - - "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ=="], - - "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA=="], - - "@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g=="], - "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], @@ -371,7 +318,7 @@ "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], - "@types/bun": ["@types/bun@1.4.1", "", { "dependencies": { "bun-types": "1.4.1" } }, "sha512-0AVGiTXGajf1rgKom3N+c5L7CBxuoyyv1i44M0nX4UDK0G/fnRAMiri93nHuVPIb429KKtAgj7HatVmmOjeQLA=="], + "@types/bun": ["@types/bun@1.4.3", "", { "dependencies": { "bun-types": "1.4.3" } }, "sha512-N67Pqy8JXbAY/j0tNraS3V5mubGvc6rrrNDmDCtZLlFzccbUJPDb0tvgUBC5q4m8ybkP93Jcn1QB+RYQ6PvBhw=="], "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], @@ -553,7 +500,7 @@ "browserslist": ["browserslist@4.28.8", "", { "dependencies": { "baseline-browser-mapping": "^2.11.12", "caniuse-lite": "^1.0.30001809", "electron-to-chromium": "^1.5.402", "node-releases": "^2.0.53", "update-browserslist-db": "^1.3.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA=="], - "bun-types": ["bun-types@1.4.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-loKuVrAFZKfEv+JvWkHRS9GW5IqLuLRjVXN9p+vZvBN86O5hf/pBZQ5hSoyipsrMmWObZBDvWnlmKvjKTM0PdA=="], + "bun-types": ["bun-types@1.4.3", "", { "dependencies": { "@types/node": "*", "undici-types": "*" } }, "sha512-tinRJNGOmUN+tEAsn2y2e942n9JHRHR42iUYzDH75NmhJqVBokGSQkkfSFdNWwsANIdrK6ACH9t01M452YyTwg=="], "cac": ["cac@7.0.0", "", {}, "sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ=="], @@ -611,8 +558,6 @@ "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], - "esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="], - "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], "escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], @@ -833,8 +778,6 @@ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "tsx": ["tsx@4.23.13", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw=="], - "type-fest": ["type-fest@5.9.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-yANm3Jr3GiJ1qgJlxGAVxTOIcEOk1rhQHamlXtnrCK7EHP4HeM9OGxtMg/W7HFdrVzw/ZWJKGVIJusVH85sLtw=="], "typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], @@ -899,6 +842,8 @@ "bun-types/@types/node": ["@types/node@26.4.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-k97ENvZWtvA6yqz5/FS6a7duDgOPEeOQOc2iKS/nY6mX6qJUKtLnWzQS+Xj6tXweyj6ZcTAK2Qecetnvi9nCLA=="], + "bun-types/undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + "cliui/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], "cliui/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], @@ -919,8 +864,6 @@ "@vue/language-core/minimatch/brace-expansion": ["brace-expansion@2.1.4", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg=="], - "bun-types/@types/node/undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], - "cliui/strip-ansi/ansi-regex": ["ansi-regex@6.3.0", "", {}, "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ=="], "mlly/pkg-types/confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="], diff --git a/package.json b/package.json index 806ae67..626fb36 100644 --- a/package.json +++ b/package.json @@ -128,7 +128,7 @@ "@casl/ability": "6.8.0", "@casl/react": "5.0.1", "@tanstack/react-query": "~5.90.21", - "@types/bun": "1.4.1", + "@types/bun": "1.4.3", "@types/node": "^25.3.0", "@types/prompt-sync": "^4.2.3", "@types/react": "^19.2.14", @@ -141,7 +141,6 @@ "oxlint-tsgolint": "^0.15.0", "react": "^19.2.4", "tsdown": "^0.22.14", - "tsx": "^4.21.0", "type-fest": "^5.4.4", "typescript": "^7.0.2", "vite": "npm:rolldown-vite@^7.3.1", @@ -183,8 +182,8 @@ } }, "engines": { - "bun": ">= 1.4", + "bun": ">= 1.4.3", "node": ">= 18" }, - "packageManager": "bun@1.4.1" + "packageManager": "bun@1.4.3" } From e0fadb42bc35d132b59a1f05ed663539056b72ab Mon Sep 17 00:00:00 2001 From: Urban Krepel Date: Thu, 22 Oct 2026 12:45:16 +0200 Subject: [PATCH 04/17] chore: type-check with tsrs Run `typecheck` with tsrs (oxc-project/tsrs), a Rust port of the TypeScript 7 checker. It reports the same diagnostics as tsc here and is ~1.8x faster with ~2.2x less memory. The version is pinned exactly while tsrs is pre-1.0. Binaries exist for macOS arm64 and Linux; elsewhere use `bunx tsc --noEmit`. Co-Authored-By: Claude Opus 5.5 --- bun.lock | 13 +++++++++++++ package.json | 3 ++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/bun.lock b/bun.lock index ebc1105..0296ad1 100644 --- a/bun.lock +++ b/bun.lock @@ -31,6 +31,7 @@ "oxlint-tsgolint": "^0.15.0", "react": "^19.2.4", "tsdown": "^0.22.14", + "tsrs": "0.18.0-ts7.1.0-dev.20260929", "type-fest": "^5.4.4", "typescript": "^7.0.2", "vite": "npm:rolldown-vite@^7.3.1", @@ -306,6 +307,16 @@ "@tanstack/react-query": ["@tanstack/react-query@5.90.21", "", { "dependencies": { "@tanstack/query-core": "5.90.20" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg=="], + "@tsrs/darwin-arm64": ["@tsrs/darwin-arm64@0.18.0-ts7.1.0-dev.20260929", "", { "os": "darwin", "cpu": "arm64" }, "sha512-sAirMpaGA9uRQm2W+t4zC7PjnijNuFa4PCSIfLKKo0JyAUCRfnMeev0UGBQ9F3g+9Nz187tvikUs5Irh5+eajA=="], + + "@tsrs/linux-arm64-gnu": ["@tsrs/linux-arm64-gnu@0.18.0-ts7.1.0-dev.20260929", "", { "os": "linux", "cpu": "arm64" }, "sha512-zm9/z9yylig68JiZF4Aq0Cm26n09KEU8YoQRlG7tdDWTBNAIViYw8knxnWYsjMWVZ2XiU4mYq1xdrOKuOgABHg=="], + + "@tsrs/linux-arm64-musl": ["@tsrs/linux-arm64-musl@0.18.0-ts7.1.0-dev.20260929", "", { "os": "linux", "cpu": "arm64" }, "sha512-Lw7VVU/YQ9JcarODICEiwzMLdFqZprqh5RGTgbS5Uc6I95P635/g5xA1pOC8RVs4efBvAR30J54iDG3l5JEYiw=="], + + "@tsrs/linux-x64-gnu": ["@tsrs/linux-x64-gnu@0.18.0-ts7.1.0-dev.20260929", "", { "os": "linux", "cpu": "x64" }, "sha512-EdBwBgNICGu1r1h23ixdcKwsMo6E9ll7ZsY3mNERNYqE1Pwdtcat99d5E7zB1J+oy/Xk6YqiizaB6nolIjpDTw=="], + + "@tsrs/linux-x64-musl": ["@tsrs/linux-x64-musl@0.18.0-ts7.1.0-dev.20260929", "", { "os": "linux", "cpu": "x64" }, "sha512-LHk+AOAtXuTlhJhOJ2+elriEIX17ycXljojeNxrrzckOipTG1LksoVWuCSas2nu00EnWAzrdKpt9oqPylUKZfQ=="], + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], "@types/argparse": ["@types/argparse@1.0.38", "", {}, "sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA=="], @@ -778,6 +789,8 @@ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "tsrs": ["tsrs@0.18.0-ts7.1.0-dev.20260929", "", { "optionalDependencies": { "@tsrs/darwin-arm64": "0.18.0-ts7.1.0-dev.20260929", "@tsrs/linux-arm64-gnu": "0.18.0-ts7.1.0-dev.20260929", "@tsrs/linux-arm64-musl": "0.18.0-ts7.1.0-dev.20260929", "@tsrs/linux-x64-gnu": "0.18.0-ts7.1.0-dev.20260929", "@tsrs/linux-x64-musl": "0.18.0-ts7.1.0-dev.20260929" }, "bin": { "tsrs": "./bin/tsrs" } }, "sha512-9T0b4Uam/yMFIK16GUx0fUrJqYZnJ/gvSmV9JB8NTyDePvwCI45WFWxyEV/UMV3KfdNRJG/PBsm3fSIB9WmWsA=="], + "type-fest": ["type-fest@5.9.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-yANm3Jr3GiJ1qgJlxGAVxTOIcEOk1rhQHamlXtnrCK7EHP4HeM9OGxtMg/W7HFdrVzw/ZWJKGVIJusVH85sLtw=="], "typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], diff --git a/package.json b/package.json index 626fb36..9b18354 100644 --- a/package.json +++ b/package.json @@ -101,7 +101,7 @@ "gen:next": "rm -rf ./test/generated/next && mkdir -p ./test/generated/next && bun run start generate --config ./test/config.mjs --output ./test/generated/next --no-prettier", "test:generated-eq": "vitest run ./src/generators/generated-output-eq.test.ts", "gen:verify": "bun run gen:base && bun run gen:next && bun run test:generated-eq", - "typecheck": "tsc --noEmit", + "typecheck": "tsrs --noEmit", "lint": "oxlint --type-aware --fix", "lint:check": "oxlint --type-aware", "format": "oxfmt", @@ -141,6 +141,7 @@ "oxlint-tsgolint": "^0.15.0", "react": "^19.2.4", "tsdown": "^0.22.14", + "tsrs": "0.18.0-ts7.1.0-dev.20260929", "type-fest": "^5.4.4", "typescript": "^7.0.2", "vite": "npm:rolldown-vite@^7.3.1", From 1500f2a031ebe0dc97d311e6d65d041db543fe73 Mon Sep 17 00:00:00 2001 From: Urban Krepel Date: Thu, 22 Oct 2026 12:53:06 +0200 Subject: [PATCH 05/17] refactor: remove `any` from src Replace every `any` with a real type or `unknown`: - Runtime library: request `data` is `unknown`, error handlers are `ErrorHandler` (as useAclCheck already uses), interceptor tuples are `unknown[]`, and RestClient's constructor takes `RestInterceptor<[]>` since it attaches them without arguments. - InvalidationMap/InvalidationMapFunc default to `unknown`; method syntax keeps callbacks with annotated parameters assignable. - Generator and CLI helpers use SchemaResolver, OpenAPIV3 and yargs types; the yargs middleware merges the loaded config into argv as yargs would. Co-Authored-By: Claude Opus 5.5 --- src/generators/generate/generateConfigs.ts | 3 +- src/generators/generate/generateQueries.ts | 6 ++-- src/generators/types/common.ts | 5 ++- src/generators/types/openapi.ts | 7 ++-- src/generators/utils/object.utils.ts | 6 ++-- src/helpers/cli.helper.ts | 6 ++-- src/helpers/yargs.helper.ts | 37 +++++++++++----------- src/lib/config/queryConfig.context.tsx | 13 +++++--- src/lib/react-query.types.ts | 3 +- src/lib/rest/native-rest-client.ts | 5 ++- src/lib/rest/rest-client.ts | 24 +++++++------- src/lib/rest/rest-client.types.ts | 15 ++++----- src/lib/rest/rest-interceptor.ts | 3 +- 13 files changed, 68 insertions(+), 65 deletions(-) diff --git a/src/generators/generate/generateConfigs.ts b/src/generators/generate/generateConfigs.ts index e3f92e1..6c92de4 100644 --- a/src/generators/generate/generateConfigs.ts +++ b/src/generators/generate/generateConfigs.ts @@ -14,6 +14,7 @@ import { } from "@/generators/const/deps.const"; import { ZOD_IMPORT } from "@/generators/const/zod.const"; import { BuilderConfig, DynamicColumnsConfig, DynamicInputsConfig } from "@/generators/types/builder-config"; +import { SchemaResolver } from "@/generators/core/SchemaResolver.class"; import { Endpoint } from "@/generators/types/endpoint"; import { GenerateType, GenerateTypeParams, Import } from "@/generators/types/generate"; import { @@ -229,7 +230,7 @@ function renderColumnsConfig(columnsConfig: DynamicColumnsConfig) { return lines.join("\n"); } -function renderMutationContent(resolver: any, endpoint: Endpoint, tag: string) { +function renderMutationContent(resolver: SchemaResolver, endpoint: Endpoint, tag: string) { const hasAclCheck = resolver.options.checkAcl && endpoint.acl; const hasMutationEffects = resolver.options.mutationEffects; const hasMutationDefaultOnError = resolver.options.mutationDefaultOnError; diff --git a/src/generators/generate/generateQueries.ts b/src/generators/generate/generateQueries.ts index b2bbba8..69112e4 100644 --- a/src/generators/generate/generateQueries.ts +++ b/src/generators/generate/generateQueries.ts @@ -16,6 +16,8 @@ import { } from "@/generators/const/endpoints.const"; import { CONFIG_PACKAGE_IMPORT_PATH, REST_PACKAGE_IMPORT_PATH } from "@/generators/const/package.const"; import { QUERIES_MODULE_NAME, QUERY_HOOKS, QUERY_IMPORT } from "@/generators/const/queries.const"; +import { OpenAPIV3 } from "openapi-types"; + import { SchemaResolver } from "@/generators/core/SchemaResolver.class"; import { Endpoint, EndpointParameter } from "@/generators/types/endpoint"; import { GenerateType, GenerateTypeParams, Import } from "@/generators/types/generate"; @@ -421,8 +423,8 @@ function renderEndpointParamDescription(endpointParam: ReturnType = Record; +export type ObjectLiteral = Record; -export type List = ReadonlyArray; +export type List = ReadonlyArray; export type Length = L["length"]; diff --git a/src/generators/types/openapi.ts b/src/generators/types/openapi.ts index f1c4d6c..beee82d 100644 --- a/src/generators/types/openapi.ts +++ b/src/generators/types/openapi.ts @@ -1,9 +1,8 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ import { OpenAPIV3 } from "openapi-types"; export type CompositeType = "oneOf" | "anyOf" | "allOf" | "enum" | "array" | "empty-object" | "object" | "record"; -export type SingleType = Exclude; +export type SingleType = Exclude; export type PrimitiveType = "string" | "number" | "integer" | "boolean"; @@ -16,8 +15,8 @@ export interface OperationAclInfo { interface OperationObjectAdditions { "x-acl"?: OperationAclInfo[]; - "x-media-upload"?: any; - "x-media-download"?: any; + "x-media-upload"?: unknown; + "x-media-download"?: unknown; } export type OperationObject = OpenAPIV3.OperationObject; diff --git a/src/generators/utils/object.utils.ts b/src/generators/utils/object.utils.ts index 29fa794..47ce60e 100644 --- a/src/generators/utils/object.utils.ts +++ b/src/generators/utils/object.utils.ts @@ -8,10 +8,10 @@ export function getNested< From extends ObjectLiteral, Return = HasNestedPath extends 1 ? From[Path] : Get, >(obj: From, path: Path): Return { - let target = obj || {}; + let target: unknown = obj || {}; const props = path.split("."); for (let i = 0, len = props.length; i < len; ++i) { - target = target[props[i] as keyof typeof target]; + target = (target as ObjectLiteral)[props[i]]; if (target === undefined || target === null) { break; } @@ -26,7 +26,7 @@ export function pick(obj: T, paths: Object.keys(obj).forEach((key) => { if (!paths.includes(key as K)) return; - result[key as keyof Pick] = obj[key]; + result[key as keyof Pick] = obj[key as K]; }); return result; diff --git a/src/helpers/cli.helper.ts b/src/helpers/cli.helper.ts index 8352306..e7c6cf7 100644 --- a/src/helpers/cli.helper.ts +++ b/src/helpers/cli.helper.ts @@ -1,6 +1,6 @@ import { styleText } from "node:util"; -/* eslint-disable no-console, @typescript-eslint/no-explicit-any */ +/* eslint-disable no-console */ import type { Prompt as PromptSyncPrompt } from "prompt-sync"; /** @@ -19,9 +19,9 @@ async function prompt(...args: Parameters) { * @param value * @param defaultValue */ -export function logVariable(name: string, value: any, defaultValue?: string | number) { +export function logVariable(name: string, value: unknown, defaultValue?: string | number) { if (defaultValue !== undefined && defaultValue !== value) { - console.log(`${styleText("yellow", `${name}:`.padEnd(20))}${styleText("magenta", value)}`); + console.log(`${styleText("yellow", `${name}:`.padEnd(20))}${styleText("magenta", String(value))}`); } else { console.log(`${`${name}:`.padEnd(20)}${value}`); } diff --git a/src/helpers/yargs.helper.ts b/src/helpers/yargs.helper.ts index 0eda8d5..51b94c0 100644 --- a/src/helpers/yargs.helper.ts +++ b/src/helpers/yargs.helper.ts @@ -1,6 +1,5 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ import "reflect-metadata"; -import type { Options } from "yargs"; +import type { Argv, Options } from "yargs"; interface IOptionProperties extends Options { envAlias?: string; @@ -25,28 +24,33 @@ export function YargOption(properties: IOptionProperties) { }; } -export function getYargsOption(target: any): Record { - const options = Reflect.getMetadata(optionsKey, target.prototype); +export function getYargsOption(target: new () => T): Record { + const options: Record | undefined = Reflect.getMetadata(optionsKey, target.prototype); if (!options) { - throw new Error(`Options for ${(target as any).name} were not defined`); + throw new Error(`Options for ${target.name} were not defined`); } return options; } -export function getBuilder(options: any) { - return async (y: any) => { - return y.options(getYargsOptions(options)).middleware(async (_argv: any) => { - return (await loadYargsConfig(options, _argv as any)) as any; +export function getBuilder(options: new () => T) { + return async (y: Argv) => { + return y.options(getYargsOptions(options)).middleware(async (argv) => { + // yargs replaces argv with what async middleware resolves to. + Object.assign(argv, await loadYargsConfig(options, argv)); }, true); }; } -export function getYargsOptions(target: any): Record { - return Object.entries(getYargsOption(target)).reduce( +function getYargsOptionEntries(target: new () => T): [string, IOptionProperties][] { + return Object.entries(getYargsOption(target) as Record); +} + +export function getYargsOptions(target: new () => T): Record { + return getYargsOptionEntries(target).reduce( (a, [property, options]) => { a[property as keyof T] = Object.fromEntries( Object.entries(options).filter(([optionName]) => !["envAlias", "default"].includes(optionName)), - ); + ) as Options; return a; }, {} as Record, @@ -56,12 +60,9 @@ export function getYargsOptions(target: any): Record { export async function loadYargsConfig(cls: new () => T, _argv: Record): Promise { const argv: T = new cls(); - for (const [name, o] of Object.entries(getYargsOption(cls))) { - argv[name as keyof typeof argv] = - // yargs is always right - _argv[name] ?? - // fallback to default - o.default; + for (const [name, o] of getYargsOptionEntries(cls)) { + // yargs is always right; fall back to the default + argv[name as keyof T] = (_argv[name] ?? o.default) as T[keyof T]; } return argv; diff --git a/src/lib/config/queryConfig.context.tsx b/src/lib/config/queryConfig.context.tsx index 84b4e4c..e86cb39 100644 --- a/src/lib/config/queryConfig.context.tsx +++ b/src/lib/config/queryConfig.context.tsx @@ -5,10 +5,15 @@ import type { QueryKey } from "@tanstack/react-query"; export type QueryModule = string | number | symbol; -export type InvalidationMapFunc = (data: TData, variables: TVariables) => QueryKey[]; -export type InvalidationMap = Partial< - Record> ->; +// Method syntax keeps parameters bivariant, so callbacks with annotated parameters fit the `unknown` defaults. +export type InvalidationMapFunc = { + invalidate(data: TData, variables: TVariables): QueryKey[]; +}["invalidate"]; +export type InvalidationMap< + TQueryModule extends QueryModule = QueryModule, + TData = unknown, + TVariables = unknown, +> = Partial>>; export namespace OpenApiQueryConfig { export interface Type { diff --git a/src/lib/react-query.types.ts b/src/lib/react-query.types.ts index de05f53..5ae3297 100644 --- a/src/lib/react-query.types.ts +++ b/src/lib/react-query.types.ts @@ -8,8 +8,7 @@ import { import { ApplicationException, GeneralErrorCodes } from "./rest/error-handling"; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type Function = (...args: any) => any; +type Function = (...args: never) => unknown; type IfAny = 0 extends 1 & T ? Y : N; type IsAny = IfAny; type IsUnknown = IsAny extends never ? (unknown extends T ? Y : N) : N; diff --git a/src/lib/rest/native-rest-client.ts b/src/lib/rest/native-rest-client.ts index 3acc93f..bc4b043 100644 --- a/src/lib/rest/native-rest-client.ts +++ b/src/lib/rest/native-rest-client.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ import { z } from "zod"; import { ErrorHandler } from "./error-handling"; @@ -22,7 +21,7 @@ type NativeResult = IsRawRes extends true ? NativeR export class NativeRestClient implements RestTransport { private readonly interceptors: RestTransportInterceptor[]; - private readonly errorHandler?: ErrorHandler; + private readonly errorHandler?: ErrorHandler; constructor({ config, @@ -36,7 +35,7 @@ export class NativeRestClient implements RestTransport { fetch?: TransportFetch; }; interceptors?: RestTransportInterceptor[]; - errorHandler?: ErrorHandler; + errorHandler?: ErrorHandler; } = {}) { this.baseURL = config?.baseURL ?? ""; this.headers = config?.headers; diff --git a/src/lib/rest/rest-client.ts b/src/lib/rest/rest-client.ts index 0897b67..cfc1785 100644 --- a/src/lib/rest/rest-client.ts +++ b/src/lib/rest/rest-client.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ import axios, { AxiosInstance, AxiosRequestConfig, CreateAxiosDefaults } from "axios"; import { z } from "zod"; @@ -8,7 +7,7 @@ import { RestInterceptor } from "./rest-interceptor"; export class RestClient implements IRestClient { private readonly client: AxiosInstance; - private readonly errorHandler: ErrorHandler; + private readonly errorHandler: ErrorHandler; constructor({ config, @@ -16,25 +15,26 @@ export class RestClient implements IRestClient { errorHandler, }: { config?: CreateAxiosDefaults; - interceptors?: RestInterceptor[]; - errorHandler?: ErrorHandler; + // Attached with no arguments, so each must take none. + interceptors?: RestInterceptor<[]>[]; + errorHandler?: ErrorHandler; } = {}) { this.client = axios.create(config); - this.errorHandler = errorHandler ?? (SharedErrorHandler as unknown as ErrorHandler); + this.errorHandler = errorHandler ?? SharedErrorHandler; this.attachInterceptors(interceptors); } - public attachInterceptors(interceptors?: RestInterceptor[], ...args: T) { + public attachInterceptors(interceptors?: RestInterceptor[], ...args: T) { if (interceptors != null) { interceptors.forEach((interceptor) => this.attachInterceptor(interceptor, ...args)); } } - public attachInterceptor(interceptor: RestInterceptor, ...args: T) { + public attachInterceptor(interceptor: RestInterceptor, ...args: T) { interceptor.addInterceptor(this.client, ...args); } - public ejectInterceptor(interceptor: RestInterceptor) { + public ejectInterceptor(interceptor: RestInterceptor) { interceptor.removeInterceptor(this.client); } @@ -49,7 +49,7 @@ export class RestClient implements IRestClient { public async post( requestInfo: RequestInfo, url: string, - data?: any, + data?: unknown, requestConfig?: AxiosRequestConfig & RequestConfig, ): Promise> { return this.makeRequest(requestInfo, { ...requestConfig, method: "post", url, data }); @@ -58,7 +58,7 @@ export class RestClient implements IRestClient { public async patch( requestInfo: RequestInfo, url: string, - data?: any, + data?: unknown, requestConfig?: AxiosRequestConfig & RequestConfig, ): Promise> { return this.makeRequest(requestInfo, { ...requestConfig, method: "patch", url, data }); @@ -67,7 +67,7 @@ export class RestClient implements IRestClient { public async put( requestInfo: RequestInfo, url: string, - data?: any, + data?: unknown, requestConfig?: AxiosRequestConfig & RequestConfig, ): Promise> { return this.makeRequest(requestInfo, { ...requestConfig, method: "put", url, data }); @@ -76,7 +76,7 @@ export class RestClient implements IRestClient { public async delete( requestInfo: RequestInfo, url: string, - data?: any, + data?: unknown, requestConfig?: AxiosRequestConfig & RequestConfig, ): Promise> { return this.makeRequest(requestInfo, { ...requestConfig, method: "delete", url, data }); diff --git a/src/lib/rest/rest-client.types.ts b/src/lib/rest/rest-client.types.ts index eefa2e7..b349149 100644 --- a/src/lib/rest/rest-client.types.ts +++ b/src/lib/rest/rest-client.types.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ import { AxiosRequestConfig, AxiosResponse } from "axios"; import { z } from "zod"; @@ -22,11 +21,11 @@ export type Response = IsRawRes exten : ZOutput; export interface RestClient { - attachInterceptors(interceptors?: RestInterceptor[], ...args: T): void; + attachInterceptors(interceptors?: RestInterceptor[], ...args: T): void; - attachInterceptor(interceptor: RestInterceptor, ...args: T): void; + attachInterceptor(interceptor: RestInterceptor, ...args: T): void; - ejectInterceptor(interceptor: RestInterceptor): void; + ejectInterceptor(interceptor: RestInterceptor): void; get( requestInfo: RequestInfo, @@ -37,28 +36,28 @@ export interface RestClient { post( requestInfo: RequestInfo, url: string, - data?: any, + data?: unknown, requestConfig?: AxiosRequestConfig & RequestConfig, ): Promise>; patch( requestInfo: RequestInfo, url: string, - data?: any, + data?: unknown, requestConfig?: AxiosRequestConfig & RequestConfig, ): Promise>; put( requestInfo: RequestInfo, url: string, - data?: any, + data?: unknown, requestConfig?: AxiosRequestConfig & RequestConfig, ): Promise>; delete( requestInfo: RequestInfo, url: string, - data?: any, + data?: unknown, requestConfig?: AxiosRequestConfig & RequestConfig, ): Promise>; } diff --git a/src/lib/rest/rest-interceptor.ts b/src/lib/rest/rest-interceptor.ts index 69c7eb2..ef53f86 100644 --- a/src/lib/rest/rest-interceptor.ts +++ b/src/lib/rest/rest-interceptor.ts @@ -1,7 +1,6 @@ import { AxiosInstance } from "axios"; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export class RestInterceptor { +export class RestInterceptor { private interceptorIdMap: { client: AxiosInstance; interceptorId: number }[] = []; constructor(private applyInterceptor: (client: AxiosInstance, ...args: T) => number) {} From d208bdd70cfe4c7237b423927633047eee446bbe Mon Sep 17 00:00:00 2001 From: Urban Krepel Date: Thu, 22 Oct 2026 13:37:42 +0200 Subject: [PATCH 06/17] build: publish v4 tags and ship an unstripped native addon The publish workflow only ran for v1-v3 tags. Stripping the release dylib (`strip = "symbols"`, and `"debuginfo"` too) produces a binary that recent macOS refuses to load ("mis-aligned LINKEDIT string pool"), which would make the CLI silently fall back to JS. Ship it unstripped (~0.4 MB larger). Co-Authored-By: Claude Opus 5.5 --- .github/workflows/publish.yml | 1 + native/Cargo.toml | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d902529..d003c49 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -6,6 +6,7 @@ on: - v1.* - v2.* - v3.* + - v4.* permissions: id-token: write diff --git a/native/Cargo.toml b/native/Cargo.toml index 3a0c137..b317106 100644 --- a/native/Cargo.toml +++ b/native/Cargo.toml @@ -12,7 +12,8 @@ codegen-units = 1 lto = "fat" opt-level = 3 panic = "abort" -strip = "symbols" +# Stripped dylibs fail to load on recent macOS ("mis-aligned LINKEDIT string pool"). +strip = false [profile.dev] opt-level = 2 From cdd7e9d596f5865d06446efef958fc39cf447fb6 Mon Sep 17 00:00:00 2001 From: Urban Krepel Date: Thu, 22 Oct 2026 14:56:10 +0200 Subject: [PATCH 07/17] fix: accept any ErrorHandler in RestClient and NativeRestClient 4.0.0-rc.1 typed the clients' errorHandler as ErrorHandler, which rejects handlers with specific codes such as ErrorHandler<"BAD_REQUEST" | "UNAUTHORIZED">. The clients only call rethrowError, so type them with ErrorRethrower (exported), which every ErrorHandler satisfies. Co-Authored-By: Claude Opus 5.5 --- src/errors.ts | 8 +++++++- src/index.ts | 8 +++++++- src/lib/rest/error-handling.ts | 3 +++ src/lib/rest/native-rest-client.ts | 6 +++--- src/lib/rest/rest-client.ts | 6 +++--- 5 files changed, 23 insertions(+), 8 deletions(-) diff --git a/src/errors.ts b/src/errors.ts index 1181212..3a80f92 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1,3 +1,9 @@ export { ApplicationException, ErrorHandler, SharedErrorHandler, DomainErrorRegistry } from "./lib/rest/error-handling"; -export type { GeneralErrorCodes, ErrorHandlerOptions, ErrorEntry, DomainErrorEntry } from "./lib/rest/error-handling"; +export type { + GeneralErrorCodes, + ErrorHandlerOptions, + ErrorEntry, + DomainErrorEntry, + ErrorRethrower, +} from "./lib/rest/error-handling"; export { RestUtils } from "./lib/rest/rest.utils"; diff --git a/src/index.ts b/src/index.ts index e6684fe..8d1bd77 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,7 +19,13 @@ export { RestUtils } from "./lib/rest/rest.utils"; // Error handling export { ApplicationException, ErrorHandler, SharedErrorHandler, DomainErrorRegistry } from "./lib/rest/error-handling"; -export type { GeneralErrorCodes, ErrorHandlerOptions, ErrorEntry, DomainErrorEntry } from "./lib/rest/error-handling"; +export type { + GeneralErrorCodes, + ErrorHandlerOptions, + ErrorEntry, + DomainErrorEntry, + ErrorRethrower, +} from "./lib/rest/error-handling"; // React Query types export type { AppQueryOptions, AppMutationOptions, AppInfiniteQueryOptions } from "./lib/react-query.types"; diff --git a/src/lib/rest/error-handling.ts b/src/lib/rest/error-handling.ts index 1a14fa0..b899fd3 100644 --- a/src/lib/rest/error-handling.ts +++ b/src/lib/rest/error-handling.ts @@ -236,3 +236,6 @@ export class ErrorHandler { // can be used for endpoints that only need general error handling export const SharedErrorHandler = new ErrorHandler({ entries: [] }); + +/** What a REST client needs from an error handler; every `ErrorHandler` satisfies it. */ +export type ErrorRethrower = Pick, "rethrowError">; diff --git a/src/lib/rest/native-rest-client.ts b/src/lib/rest/native-rest-client.ts index bc4b043..73ed276 100644 --- a/src/lib/rest/native-rest-client.ts +++ b/src/lib/rest/native-rest-client.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { ErrorHandler } from "./error-handling"; +import { ErrorRethrower } from "./error-handling"; import { NativeHttpError, @@ -21,7 +21,7 @@ type NativeResult = IsRawRes extends true ? NativeR export class NativeRestClient implements RestTransport { private readonly interceptors: RestTransportInterceptor[]; - private readonly errorHandler?: ErrorHandler; + private readonly errorHandler?: ErrorRethrower; constructor({ config, @@ -35,7 +35,7 @@ export class NativeRestClient implements RestTransport { fetch?: TransportFetch; }; interceptors?: RestTransportInterceptor[]; - errorHandler?: ErrorHandler; + errorHandler?: ErrorRethrower; } = {}) { this.baseURL = config?.baseURL ?? ""; this.headers = config?.headers; diff --git a/src/lib/rest/rest-client.ts b/src/lib/rest/rest-client.ts index cfc1785..f95daea 100644 --- a/src/lib/rest/rest-client.ts +++ b/src/lib/rest/rest-client.ts @@ -1,13 +1,13 @@ import axios, { AxiosInstance, AxiosRequestConfig, CreateAxiosDefaults } from "axios"; import { z } from "zod"; -import { ErrorHandler, GeneralErrorCodes, SharedErrorHandler } from "./error-handling"; +import { ErrorRethrower, GeneralErrorCodes, SharedErrorHandler } from "./error-handling"; import { RestClient as IRestClient, RequestConfig, RequestInfo, Response } from "./rest-client.types"; import { RestInterceptor } from "./rest-interceptor"; export class RestClient implements IRestClient { private readonly client: AxiosInstance; - private readonly errorHandler: ErrorHandler; + private readonly errorHandler: ErrorRethrower; constructor({ config, @@ -17,7 +17,7 @@ export class RestClient implements IRestClient { config?: CreateAxiosDefaults; // Attached with no arguments, so each must take none. interceptors?: RestInterceptor<[]>[]; - errorHandler?: ErrorHandler; + errorHandler?: ErrorRethrower; } = {}) { this.client = axios.create(config); this.errorHandler = errorHandler ?? SharedErrorHandler; From a76130dedf57f9e9778bce480d0d833814de0847 Mon Sep 17 00:00:00 2001 From: Urban Krepel Date: Thu, 22 Oct 2026 15:09:01 +0200 Subject: [PATCH 08/17] feat: type InvalidationMap callbacks from the generated mutations 4.0.0-rc.1 gave invalidation callbacks `unknown` data and variables, so callbacks like `(data, { officeId, id }) => ...` no longer type-checked. queryModules.ts now also exports `QueryModuleMutations`: for each module, `MutationEffectsOf` over its mutation hooks. `InvalidationMap` types each module's callback with that module's mutation `data` (union) and `variables` merged into one object, where keys only some mutations take are optional. Without the second parameter callbacks keep `unknown`. Both renderers emit identical output; a new test type-checks a generated petstore client against a typed map for both renderers and namespace modes. Co-Authored-By: Claude Opus 5.5 --- README.md | 17 ++++ native/src/render.rs | 79 +++++++++++++-- src/config.ts | 9 +- .../generate/generateQueryModules.ts | 32 ++++++- .../generated-invalidation-map.test.ts | 96 +++++++++++++++++++ src/index.ts | 9 +- src/lib/config/queryConfig.context.tsx | 47 ++++++++- src/query.ts | 9 +- 8 files changed, 281 insertions(+), 17 deletions(-) create mode 100644 src/generators/generated-invalidation-map.test.ts diff --git a/README.md b/README.md index 963fa7c..f537587 100644 --- a/README.md +++ b/README.md @@ -283,6 +283,23 @@ import { OpenApiQueryConfig } from "@povio/openapi-codegen-cli/query"; ; ``` +### Typed invalidation maps + +With `mutationEffects` (the default), `queryModules.ts` exports `QueryModuleMutations`: for each module, the `data` and `variables` of its mutation hooks. Pass it to `InvalidationMap` to type each module's callback from the spec. + +```ts +import type { InvalidationMap } from "@povio/openapi-codegen-cli/query"; +import { QueryModule, type QueryModuleMutations } from "@/data/queryModules"; + +export const invalidationMap: InvalidationMap = { + // `data` is any of the module's mutation responses; variables that only some + // of its mutations take (here `id`) are optional. + [QueryModule.Items]: (data, { officeId, id }) => [["items", officeId, id]], +}; +``` + +Without `QueryModuleMutations`, callbacks receive `unknown`; annotate their parameters to read them. + ### Runtime response validation Use `OpenApiQueryConfig.Provider` to allow generated GET query hooks to return invalid response data while still logging the response Zod error to the console. Non-GET requests still throw on invalid response data. diff --git a/native/src/render.rs b/native/src/render.rs index 833a78d..f8c9345 100644 --- a/native/src/render.rs +++ b/native/src/render.rs @@ -2083,7 +2083,8 @@ fn render_query_modules(endpoints: &[Value], options: &GenerateOptions) -> Strin .get("queries") .map(|config| config.namespace_suffix.as_str()) .unwrap_or("Queries"); - let mut tags = IndexSet::default(); + // Each tag's mutation hooks, for the `QueryModuleMutations` interface. + let mut tags: IndexMap<&str, Vec> = IndexMap::default(); for endpoint in endpoints { let tag = endpoint .get("tags") @@ -2091,13 +2092,61 @@ fn render_query_modules(endpoints: &[Value], options: &GenerateOptions) -> Strin .and_then(|tags| tags.first()) .and_then(Value::as_str) .unwrap_or(&options.default_tag); - tags.insert(tag); + let hooks = tags.entry(tag).or_default(); + if is_mutation_endpoint(endpoint) { + let (_, cap) = query_names(endpoint); + if endpoint.get("method").and_then(Value::as_str) == Some("get") { + hooks.push(format!("use{cap}Mutation")); + } else { + hooks.push(format!("use{cap}")); + } + } + } + let mutation_tags = tags + .iter() + .filter(|(_, hooks)| !hooks.is_empty()) + .collect::>(); + let mut lines = Vec::new(); + if !mutation_tags.is_empty() { + lines.push(format!( + "import type {{ MutationEffectsOf }} from \"{}\";", + query_types_import_path(options) + )); + let queries_suffix = output_suffix(options, "queries", "queries"); + for (tag, _) in &mutation_tags { + let namespace = format!("{}{suffix}", capitalize(tag)); + let file = decapitalize(tag); + let from = format!( + "{}{file}/{file}.{queries_suffix}", + root_import_root(options) + ); + lines.push(if options.ts_namespaces { + format!("import type {{ {namespace} }} from \"{from}\";") + } else { + format!("import type * as {namespace} from \"{from}\";") + }); + } + lines.push(String::new()); } - let mut lines = vec!["export const enum QueryModule {".to_string()]; - for tag in tags { + lines.push("export const enum QueryModule {".to_string()); + for tag in tags.keys() { lines.push(format!(" {tag} = \"{}{suffix}\",", capitalize(tag))); } lines.push("}".into()); + lines.push(String::new()); + lines.push("export interface QueryModuleMutations {".into()); + for (tag, hooks) in &mutation_tags { + let namespace = format!("{}{suffix}", capitalize(tag)); + let hooks = hooks + .iter() + .map(|hook| format!("typeof {namespace}.{hook}")) + .collect::>() + .join(" | "); + lines.push(format!( + " [QueryModule.{tag}]: MutationEffectsOf<{hooks}>;" + )); + } + lines.push("}".into()); lines.join("\n") } @@ -2732,11 +2781,7 @@ fn render_query_module( lines.push(format!( "import {{ {} }} from \"{}\";", query_types.join(", "), - if options.query_types_import_path == "@povio/openapi-codegen-cli" { - "@povio/openapi-codegen-cli/query" - } else { - &options.query_types_import_path - } + query_types_import_path(options) )); if endpoints .iter() @@ -3201,6 +3246,22 @@ fn output_suffix<'a>(options: &'a GenerateOptions, kind: &str, default: &'a str) .unwrap_or(default) } +fn query_types_import_path(options: &GenerateOptions) -> &str { + if options.query_types_import_path == "@povio/openapi-codegen-cli" { + "@povio/openapi-codegen-cli/query" + } else { + &options.query_types_import_path + } +} + +/// Like `import_root`, for files at the output root (`queryModules.ts`). +fn root_import_root(options: &GenerateOptions) -> String { + match options.import_path.as_str() { + "relative" => "./".into(), + _ => import_root(options), + } +} + fn import_root(options: &GenerateOptions) -> String { match options.import_path.as_str() { "relative" => "../".into(), diff --git a/src/config.ts b/src/config.ts index 5b24965..f213e8e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,6 +1,13 @@ export { OpenApiRouter } from "./lib/config/router.context"; export { OpenApiQueryConfig } from "./lib/config/queryConfig.context"; -export type { InvalidationMap, InvalidationMapFunc, QueryModule } from "./lib/config/queryConfig.context"; +export type { + InvalidationMap, + InvalidationMapFunc, + MergedMutationVariables, + MutationEffectsOf, + MutationEffectsTypes, + QueryModule, +} from "./lib/config/queryConfig.context"; export { OpenApiWorkspaceContext, useWorkspaceContext } from "./lib/config/workspace.context"; export { configureTranslations, ns, resources } from "./lib/config/i18n"; export type { TranslateFunction } from "./lib/config/i18n"; diff --git a/src/generators/generate/generateQueryModules.ts b/src/generators/generate/generateQueryModules.ts index 371b685..7ff1174 100644 --- a/src/generators/generate/generateQueryModules.ts +++ b/src/generators/generate/generateQueryModules.ts @@ -1,5 +1,9 @@ import { GenerateType, GenerateTypeParams } from "@/generators/types/generate"; +import { getQueryName } from "@/generators/utils/generate/generate.query.utils"; +import { getImportPath } from "@/generators/utils/generate/generate.imports.utils"; +import { getQueryTypesImportPath, getTagImportPath } from "@/generators/utils/generate/generate.utils"; import { getNamespaceName } from "@/generators/utils/namespace.utils"; +import { isMutation } from "@/generators/utils/query.utils"; export function generateQueryModules({ resolver, data }: Omit) { const nativeContent = ( @@ -7,7 +11,7 @@ export function generateQueryModules({ resolver, data }: Omit { const endpoints = data.get(tag)?.endpoints; @@ -18,14 +22,40 @@ export function generateQueryModules({ resolver, data }: Omit getQueryName(endpoint, true)), }); }); + // Each module's mutation `data` and `variables`, for `InvalidationMap`. + const mutationModules = modules.filter((module) => module.mutationHooks.length > 0); const lines: string[] = []; + if (mutationModules.length > 0) { + lines.push(`import type { MutationEffectsOf } from "${getQueryTypesImportPath(resolver.options)}";`); + for (const module of mutationModules) { + const from = `${getImportPath(resolver.options, true)}${getTagImportPath({ + type: GenerateType.Queries, + tag: module.tag, + options: resolver.options, + })}`; + lines.push( + resolver.options.tsNamespaces + ? `import type { ${module.namespace} } from "${from}";` + : `import type * as ${module.namespace} from "${from}";`, + ); + } + lines.push(""); + } lines.push("export const enum QueryModule {"); for (const module of modules) { lines.push(` ${module.tag} = "${module.namespace}",`); } lines.push("}"); + lines.push(""); + lines.push("export interface QueryModuleMutations {"); + for (const module of mutationModules) { + const hooks = module.mutationHooks.map((hook) => `typeof ${module.namespace}.${hook}`).join(" | "); + lines.push(` [QueryModule.${module.tag}]: MutationEffectsOf<${hooks}>;`); + } + lines.push("}"); return lines.join("\n"); } diff --git a/src/generators/generated-invalidation-map.test.ts b/src/generators/generated-invalidation-map.test.ts new file mode 100644 index 0000000..c533f18 --- /dev/null +++ b/src/generators/generated-invalidation-map.test.ts @@ -0,0 +1,96 @@ +import { spawnSync } from "node:child_process"; +import { mkdtemp, mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { describe, expect, test } from "vitest"; +import { parse } from "yaml"; + +import { generateFilesFromNativeOpenAPI } from "../native/generateFilesFromNativeOpenAPI"; + +import { DEFAULT_GENERATE_OPTIONS } from "./const/options.const"; +import { generateCodeFromOpenAPIDoc } from "./generateCodeFromOpenAPIDoc"; +import { GenerateOptions } from "./types/options"; + +const root = process.cwd(); + +// Typed against the generated client: `petId` exists on some pet mutations, `username` on some user ones. +const invalidationMap = ` +import type { InvalidationMap } from "@povio/openapi-codegen-cli/query"; +import { QueryModule, type QueryModuleMutations } from "./data/queryModules"; + +export const invalidationMap: InvalidationMap = { + [QueryModule.pet]: (data, { petId }) => [["pet", petId, data]], + [QueryModule.user]: (_data, variables) => [["user", variables.username]], +}; + +export const unknownVariable: InvalidationMap = { + // @ts-expect-error -- no pet mutation has \`nope\` + [QueryModule.pet]: (_data, { nope }) => [[nope]], +}; + +export const wrongType: InvalidationMap = { + // @ts-expect-error -- \`petId\` is a number + [QueryModule.pet]: (_data, { petId }) => [[petId?.toUpperCase()]], +}; +`; + +describe("generated QueryModuleMutations", () => { + test.each( + [true, false].flatMap((tsNamespaces) => + (["typescript", "native"] as const).map((backend) => ({ tsNamespaces, backend })), + ), + )( + "types invalidation map callbacks ($backend, namespaces: $tsNamespaces)", + async ({ tsNamespaces, backend }) => { + const output = await mkdtemp(join(tmpdir(), "invalidation-map-")); + try { + const source = await readFile(join(root, "test/petstore.yaml"), "utf8"); + const options: GenerateOptions = { + ...DEFAULT_GENERATE_OPTIONS, + output: join(output, "data"), + tsNamespaces, + importPath: "relative", + }; + const files = + backend === "typescript" + ? generateCodeFromOpenAPIDoc(parse(source), options) + : generateFilesFromNativeOpenAPI(source, true, options); + expect(files).toBeDefined(); + for (const file of files ?? []) { + await mkdir(dirname(file.fileName), { recursive: true }); + await writeFile(file.fileName, file.content); + } + await symlink(join(root, "node_modules"), join(output, "node_modules"), "dir"); + await writeFile(join(output, "invalidationMap.ts"), invalidationMap); + await writeFile( + join(output, "tsconfig.json"), + JSON.stringify({ + compilerOptions: { + strict: true, + noEmit: true, + skipLibCheck: true, + target: "esnext", + module: "preserve", + jsx: "react-jsx", + paths: { + "@povio/openapi-codegen-cli": [join(root, "src/index.ts")], + "@povio/openapi-codegen-cli/*": [join(root, "src/*.ts")], + }, + }, + include: ["**/*.ts"], + exclude: ["node_modules"], + }), + ); + + const typecheck = spawnSync("bun", [join(root, "node_modules/typescript/bin/tsc"), "-p", "tsconfig.json"], { + cwd: output, + encoding: "utf8", + }); + expect(typecheck.status, `${typecheck.stdout}\n${typecheck.stderr}`).toBe(0); + } finally { + await rm(output, { recursive: true, force: true }); + } + }, + 60_000, + ); +}); diff --git a/src/index.ts b/src/index.ts index 8d1bd77..0307201 100644 --- a/src/index.ts +++ b/src/index.ts @@ -35,7 +35,14 @@ export type { MutationEffectsOptions } from "./lib/react-query/useMutationEffect // Config export { OpenApiRouter } from "./lib/config/router.context"; export { OpenApiQueryConfig } from "./lib/config/queryConfig.context"; -export type { InvalidationMap, InvalidationMapFunc, QueryModule } from "./lib/config/queryConfig.context"; +export type { + InvalidationMap, + InvalidationMapFunc, + MergedMutationVariables, + MutationEffectsOf, + MutationEffectsTypes, + QueryModule, +} from "./lib/config/queryConfig.context"; export { OpenApiWorkspaceContext, useWorkspaceContext } from "./lib/config/workspace.context"; // i18n resources (for consumer apps to merge into their i18n config) diff --git a/src/lib/config/queryConfig.context.tsx b/src/lib/config/queryConfig.context.tsx index e86cb39..559c109 100644 --- a/src/lib/config/queryConfig.context.tsx +++ b/src/lib/config/queryConfig.context.tsx @@ -1,7 +1,7 @@ import { createContext, use, useMemo } from "react"; import { PropsWithChildren } from "react"; -import type { QueryKey } from "@tanstack/react-query"; +import type { QueryKey, UseMutationResult } from "@tanstack/react-query"; export type QueryModule = string | number | symbol; @@ -9,11 +9,50 @@ export type QueryModule = string | number | symbol; export type InvalidationMapFunc = { invalidate(data: TData, variables: TVariables): QueryKey[]; }["invalidate"]; + +/** The `data` and `variables` one mutation passes to its module's invalidation callback. */ +export interface MutationEffectsTypes { + data: unknown; + variables: unknown; +} + +/** `data` and `variables` of mutation hooks, e.g. `MutationEffectsOf`. */ +export type MutationEffectsOf = THook extends ( + ...args: never +) => UseMutationResult + ? { data: TData; variables: TVariables } + : never; + +type ObjectMembers = T extends object ? T : never; +type KeysOfUnion = T extends unknown ? keyof T : never; +type ValueOfUnion = T extends unknown ? (K extends keyof T ? T[K] : undefined) : never; +type MergedObjects = { [K in keyof T]: ValueOfUnion } & { + [K in Exclude, keyof T>]?: ValueOfUnion; +}; + +/** + * The variables of a module's mutations as one object: keys some mutations lack become optional. + * Mutations without variables (`void`) stay in the union, as their callbacks receive `undefined`. + */ +export type MergedMutationVariables = [ObjectMembers] extends [never] + ? T + : MergedObjects> | Exclude; + +type InvalidationMapFuncFor = InvalidationMapFunc< + T extends MutationEffectsTypes ? T["data"] : never, + MergedMutationVariables +>; + +/** + * Query keys to invalidate after a module's mutations. Pass the generated `QueryModuleMutations` as + * `TMutations` to type each module's callback with its mutations' `data` and `variables`. + */ export type InvalidationMap< TQueryModule extends QueryModule = QueryModule, - TData = unknown, - TVariables = unknown, -> = Partial>>; + TMutations extends { [K in TQueryModule]?: MutationEffectsTypes } = {}, +> = { + [K in TQueryModule]?: K extends keyof TMutations ? InvalidationMapFuncFor : InvalidationMapFunc; +}; export namespace OpenApiQueryConfig { export interface Type { diff --git a/src/query.ts b/src/query.ts index aa7d11e..d465577 100644 --- a/src/query.ts +++ b/src/query.ts @@ -2,4 +2,11 @@ export type { AppQueryOptions, AppMutationOptions, AppInfiniteQueryOptions } fro export { useMutationEffects } from "./lib/react-query/useMutationEffects"; export type { MutationEffectsOptions } from "./lib/react-query/useMutationEffects"; export { OpenApiQueryConfig } from "./lib/config/queryConfig.context"; -export type { InvalidationMap, InvalidationMapFunc, QueryModule } from "./lib/config/queryConfig.context"; +export type { + InvalidationMap, + InvalidationMapFunc, + MergedMutationVariables, + MutationEffectsOf, + MutationEffectsTypes, + QueryModule, +} from "./lib/config/queryConfig.context"; From 4538c19fa3fa73f99aa1f4884c13f7c78bb03493 Mon Sep 17 00:00:00 2001 From: Urban Krepel Date: Thu, 22 Oct 2026 15:25:42 +0200 Subject: [PATCH 09/17] feat: type InvalidationMap without naming the mutations rc.2 typed invalidation callbacks only through `InvalidationMap`, so existing `InvalidationMap` declarations still got `unknown`. The generated queryModules.ts now extends a global `OpenApiCodegenQueryModuleMutations` interface with QueryModuleMutations, and InvalidationMap defaults its mutations to it. A global interface, because interfaces re-exported from the bundled declaration chunks can't be augmented through the package entry points. useMutationEffects calls the callback through the untyped InvalidationMapFunc, as the module is only known at runtime. Verified against the packed package with InvalidationMap imported from the root, /query and /config entry points. Co-Authored-By: Claude Opus 5.5 --- README.md | 9 +++++---- native/src/render.rs | 7 +++++++ src/generators/generate/generateQueryModules.ts | 5 +++++ src/generators/generated-invalidation-map.test.ts | 9 ++++++--- src/lib/config/queryConfig.context.tsx | 12 +++++++++++- src/lib/react-query/useMutationEffects.ts | 9 +++++---- 6 files changed, 39 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index f537587..2fe7640 100644 --- a/README.md +++ b/README.md @@ -285,20 +285,21 @@ import { OpenApiQueryConfig } from "@povio/openapi-codegen-cli/query"; ### Typed invalidation maps -With `mutationEffects` (the default), `queryModules.ts` exports `QueryModuleMutations`: for each module, the `data` and `variables` of its mutation hooks. Pass it to `InvalidationMap` to type each module's callback from the spec. +With `mutationEffects` (the default), `queryModules.ts` exports `QueryModuleMutations` (for each module, the `data` and `variables` of its mutation hooks) and registers it globally, so `InvalidationMap` types each module's callback from the spec: ```ts import type { InvalidationMap } from "@povio/openapi-codegen-cli/query"; -import { QueryModule, type QueryModuleMutations } from "@/data/queryModules"; +import { QueryModule } from "@/data/queryModules"; -export const invalidationMap: InvalidationMap = { +export const invalidationMap: InvalidationMap = { // `data` is any of the module's mutation responses; variables that only some // of its mutations take (here `id`) are optional. [QueryModule.Items]: (data, { officeId, id }) => [["items", officeId, id]], }; ``` -Without `QueryModuleMutations`, callbacks receive `unknown`; annotate their parameters to read them. +The registration is global, so a program with several generated clients should pass the mutations explicitly +instead: `InvalidationMap`. ### Runtime response validation diff --git a/native/src/render.rs b/native/src/render.rs index f8c9345..b1038c9 100644 --- a/native/src/render.rs +++ b/native/src/render.rs @@ -2147,6 +2147,13 @@ fn render_query_modules(endpoints: &[Value], options: &GenerateOptions) -> Strin )); } lines.push("}".into()); + lines.push(String::new()); + // Registers the types globally, so `InvalidationMap` picks them up without naming them. + lines.push("declare global {".into()); + lines.push( + " interface OpenApiCodegenQueryModuleMutations extends QueryModuleMutations {}".into(), + ); + lines.push("}".into()); lines.join("\n") } diff --git a/src/generators/generate/generateQueryModules.ts b/src/generators/generate/generateQueryModules.ts index 7ff1174..ecf04e6 100644 --- a/src/generators/generate/generateQueryModules.ts +++ b/src/generators/generate/generateQueryModules.ts @@ -57,5 +57,10 @@ export function generateQueryModules({ resolver, data }: Omit;`); } lines.push("}"); + lines.push(""); + // Registers the types globally, so `InvalidationMap` picks them up without naming them. + lines.push("declare global {"); + lines.push(" interface OpenApiCodegenQueryModuleMutations extends QueryModuleMutations {}"); + lines.push("}"); return lines.join("\n"); } diff --git a/src/generators/generated-invalidation-map.test.ts b/src/generators/generated-invalidation-map.test.ts index c533f18..96f2d4a 100644 --- a/src/generators/generated-invalidation-map.test.ts +++ b/src/generators/generated-invalidation-map.test.ts @@ -14,21 +14,24 @@ import { GenerateOptions } from "./types/options"; const root = process.cwd(); // Typed against the generated client: `petId` exists on some pet mutations, `username` on some user ones. +// `InvalidationMap` gets the types through the generated global registration. const invalidationMap = ` import type { InvalidationMap } from "@povio/openapi-codegen-cli/query"; import { QueryModule, type QueryModuleMutations } from "./data/queryModules"; -export const invalidationMap: InvalidationMap = { +export const invalidationMap: InvalidationMap = { [QueryModule.pet]: (data, { petId }) => [["pet", petId, data]], [QueryModule.user]: (_data, variables) => [["user", variables.username]], }; -export const unknownVariable: InvalidationMap = { +export const explicitMutations: InvalidationMap = invalidationMap; + +export const unknownVariable: InvalidationMap = { // @ts-expect-error -- no pet mutation has \`nope\` [QueryModule.pet]: (_data, { nope }) => [[nope]], }; -export const wrongType: InvalidationMap = { +export const wrongType: InvalidationMap = { // @ts-expect-error -- \`petId\` is a number [QueryModule.pet]: (_data, { petId }) => [[petId?.toUpperCase()]], }; diff --git a/src/lib/config/queryConfig.context.tsx b/src/lib/config/queryConfig.context.tsx index 559c109..df76789 100644 --- a/src/lib/config/queryConfig.context.tsx +++ b/src/lib/config/queryConfig.context.tsx @@ -47,9 +47,19 @@ type InvalidationMapFuncFor = InvalidationMapFunc< * Query keys to invalidate after a module's mutations. Pass the generated `QueryModuleMutations` as * `TMutations` to type each module's callback with its mutations' `data` and `variables`. */ +declare global { + /** + * Mutation types per module. The generated `queryModules.ts` extends it with `QueryModuleMutations`, so + * `InvalidationMap` types its callbacks from the spec. Global, because interfaces re-exported + * from bundled declaration chunks can't be augmented through the package's entry points. + */ + // oxlint-disable-next-line typescript/no-empty-interface, typescript/no-empty-object-type -- extended by generated code + interface OpenApiCodegenQueryModuleMutations {} +} + export type InvalidationMap< TQueryModule extends QueryModule = QueryModule, - TMutations extends { [K in TQueryModule]?: MutationEffectsTypes } = {}, + TMutations extends { [K in TQueryModule]?: MutationEffectsTypes } = OpenApiCodegenQueryModuleMutations, > = { [K in TQueryModule]?: K extends keyof TMutations ? InvalidationMapFuncFor : InvalidationMapFunc; }; diff --git a/src/lib/react-query/useMutationEffects.ts b/src/lib/react-query/useMutationEffects.ts index 2df9fcf..6990f22 100644 --- a/src/lib/react-query/useMutationEffects.ts +++ b/src/lib/react-query/useMutationEffects.ts @@ -2,7 +2,7 @@ import { useCallback, useEffect } from "react"; import { QueryKey, useQueryClient } from "@tanstack/react-query"; -import { InvalidationMap, OpenApiQueryConfig, QueryModule } from "../config/queryConfig.context"; +import { InvalidationMap, InvalidationMapFunc, OpenApiQueryConfig, QueryModule } from "../config/queryConfig.context"; import { broadcastQueryInvalidation, setupCrossTabListener } from "./cross-tab-invalidation"; export interface MutationEffectsOptions { @@ -44,9 +44,10 @@ export function useMutationEffects item === keyB[index]); const isQueryKeyPrefix = (queryKey: QueryKey, prefixKey: QueryKey) => prefixKey.length <= queryKey.length && prefixKey.every((item, index) => item === queryKey[index]); - const mappedInvalidationKeys = - invalidationMap?.[currentModule]?.(data, variables) ?? - config.invalidationMap?.[currentModule]?.(data, variables); + // Callbacks are typed per module; here the module is only known at runtime. + const invalidate: InvalidationMapFunc | undefined = + invalidationMap?.[currentModule] ?? config.invalidationMap?.[currentModule]; + const mappedInvalidationKeys = invalidate?.(data, variables); const shouldInvalidateQuery = (queryKey: QueryKey) => { const isUpdateKey = updateKeys?.some((key) => isQueryKeyEqual(queryKey, key)); From 66933900eaae3bc2bfa19ad875d4d287e1ff2f06 Mon Sep 17 00:00:00 2001 From: Urban Krepel Date: Thu, 22 Oct 2026 15:44:26 +0200 Subject: [PATCH 10/17] feat!: remove builder configs generation Drop the `builderConfigs` option and the `*.configs.ts` files it generated, with its options (`filterParamName`, `dataResponseParamNames`, `dynamicInputsImportPath`, `dynamicColumnsImportPath`), the `configs` output type, the CLI flag and the native renderer's option. They are no longer used. Helpers only the configs generator used are removed too. BREAKING CHANGE: `builderConfigs` and its options are gone, and `configs.configs` is no longer part of the `configs` option. Co-Authored-By: Claude Opus 5.5 --- README.md | 1 - native/src/config.rs | 2 - native/src/lib.rs | 1 - openapi-codegen.config.mjs | 1 - scripts/renderer-parity-configs.ts | 5 - src/commands/generate.command.ts | 3 - src/commands/generate.ts | 1 - src/generators/const/buildConfigs.const.ts | 1 - src/generators/const/deps.const.ts | 4 - src/generators/const/options.const.ts | 9 - .../generate/generateConfigs.test.ts | 359 --------------- src/generators/generate/generateConfigs.ts | 419 ------------------ .../generate/generateQueries.test.ts | 13 - .../generateCodeFromOpenAPIDoc.test.ts | 11 - src/generators/generateCodeFromOpenAPIDoc.ts | 3 - src/generators/types/builder-config.ts | 55 --- src/generators/types/generate.ts | 1 - src/generators/types/options.ts | 11 +- .../utils/generate/generate.configs.utils.ts | 361 --------------- .../generate/generate.endpoints.utils.ts | 5 +- .../utils/generate/generate.imports.utils.ts | 39 -- .../utils/generate/generate.query.utils.ts | 18 - .../generateFilesFromNativeOpenAPI.test.ts | 1 - src/native/generateFilesFromNativeOpenAPI.ts | 7 +- test/config.ts | 1 - 25 files changed, 4 insertions(+), 1328 deletions(-) delete mode 100644 src/generators/const/buildConfigs.const.ts delete mode 100644 src/generators/generate/generateConfigs.test.ts delete mode 100644 src/generators/generate/generateConfigs.ts delete mode 100644 src/generators/types/builder-config.ts delete mode 100644 src/generators/utils/generate/generate.configs.utils.ts diff --git a/README.md b/README.md index 2fe7640..c9b9c23 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,6 @@ bunx openapi-codegen generate --config my-config.ts --acl Generate ACL related files (default: true) --checkAcl Add ACL check to queries (default: true) - --builderConfigs Generate configs for builders (default: false) --baseUrl (Requires `--restClientImportPath` to NOT be set) Base URL for the generated REST client; falls back to the OpenAPI spec if not provided ``` diff --git a/native/src/config.rs b/native/src/config.rs index 6c75a28..05f1755 100644 --- a/native/src/config.rs +++ b/native/src/config.rs @@ -96,8 +96,6 @@ pub struct GenerateOptions { pub parse_request_params: bool, #[serde(default)] pub inline_endpoints: bool, - #[serde(default)] - pub builder_configs: bool, #[serde(default = "default_true")] pub acl: bool, #[serde(default)] diff --git a/native/src/lib.rs b/native/src/lib.rs index 5a66ccd..d95143e 100644 --- a/native/src/lib.rs +++ b/native/src/lib.rs @@ -405,7 +405,6 @@ pub fn compile_data(source: String, yaml: bool, options_json: String) -> Result< let rendered_complete = options.native_compact && options.split_by_tags && !options.inline_endpoints - && !options.builder_configs && options.workspace_context.is_empty() && (!options.acl || (rendered_acl.len() == acl_tag_count && rendered_shared.contains_key("appAcl"))); diff --git a/openapi-codegen.config.mjs b/openapi-codegen.config.mjs index 71b8068..42d2202 100644 --- a/openapi-codegen.config.mjs +++ b/openapi-codegen.config.mjs @@ -4,7 +4,6 @@ const config = { output: "./test/generated/next", includeTags: ["auth"], replaceOptionalWithNullish: true, - builderConfigs: true, infiniteQueries: true, }; diff --git a/scripts/renderer-parity-configs.ts b/scripts/renderer-parity-configs.ts index c3665ad..83bf421 100644 --- a/scripts/renderer-parity-configs.ts +++ b/scripts/renderer-parity-configs.ts @@ -89,11 +89,6 @@ export const optionCases = { })), 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 = [ diff --git a/src/commands/generate.command.ts b/src/commands/generate.command.ts index defb9b4..890646b 100644 --- a/src/commands/generate.command.ts +++ b/src/commands/generate.command.ts @@ -121,9 +121,6 @@ class GenerateOptions implements GenerateParams { @YargOption({ envAlias: "axiosRequestConfig", type: "boolean" }) axiosRequestConfig?: boolean; - @YargOption({ envAlias: "builderConfigs", type: "boolean" }) - builderConfigs?: boolean; - @YargOption({ envAlias: "format", default: true, type: "boolean" }) format?: boolean; diff --git a/src/commands/generate.ts b/src/commands/generate.ts index 1cf395b..cf9fe8c 100644 --- a/src/commands/generate.ts +++ b/src/commands/generate.ts @@ -40,7 +40,6 @@ export type GenerateParams = { | "mutationScope" | "parseRequestParams" | "inlineEndpoints" - | "builderConfigs" | "modelsOnly" > >; diff --git a/src/generators/const/buildConfigs.const.ts b/src/generators/const/buildConfigs.const.ts deleted file mode 100644 index 40be537..0000000 --- a/src/generators/const/buildConfigs.const.ts +++ /dev/null @@ -1 +0,0 @@ -export const BUILD_CONFIG_SUFFIX = "config"; diff --git a/src/generators/const/deps.const.ts b/src/generators/const/deps.const.ts index 25482f8..df039a7 100644 --- a/src/generators/const/deps.const.ts +++ b/src/generators/const/deps.const.ts @@ -27,10 +27,6 @@ export const ABILITY_CONTEXT_IMPORT: Import = { bindings: [ABILITY_CONTEXT], from: ACL_PACKAGE_IMPORT_PATH, }; -export const BUILDERS_UTILS = { - dynamicInputs: "dynamicInputs", - dynamicColumns: "dynamicColumns", -}; // QueryModules export const QUERY_MODULE_ENUM = "QueryModule"; diff --git a/src/generators/const/options.const.ts b/src/generators/const/options.const.ts index 155b18d..461a9bd 100644 --- a/src/generators/const/options.const.ts +++ b/src/generators/const/options.const.ts @@ -37,10 +37,6 @@ export const DEFAULT_GENERATE_OPTIONS: GenerateOptions = { outputFileNameSuffix: "acl", namespaceSuffix: "Acl", }, - [GenerateType.Configs]: { - outputFileNameSuffix: "configs", - namespaceSuffix: "Configs", - }, }, baseUrl: "", modelsOnly: false, @@ -89,9 +85,4 @@ export const DEFAULT_GENERATE_OPTIONS: GenerateOptions = { abilityContextImportPath: "", aclCheckImportPath: "@povio/openapi-codegen-cli/acl", // Builder Configs options - builderConfigs: false, - filterParamName: "filter", - dataResponseParamNames: ["data", "items"], - dynamicInputsImportPath: "@povio/ui", - dynamicColumnsImportPath: "@povio/ui", }; diff --git a/src/generators/generate/generateConfigs.test.ts b/src/generators/generate/generateConfigs.test.ts deleted file mode 100644 index c1c1fe3..0000000 --- a/src/generators/generate/generateConfigs.test.ts +++ /dev/null @@ -1,359 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { OpenAPIV3 } from "openapi-types"; - -import { DEFAULT_GENERATE_OPTIONS } from "@/generators/const/options.const"; -import { generateCodeFromOpenAPIDoc } from "@/generators/generateCodeFromOpenAPIDoc"; - -const openApiDoc = { - openapi: "3.0.0", - info: { title: "Builder Configs Test", version: "1.0.0" }, - paths: { - "/items": { - get: { - tags: ["items"], - operationId: "list", - parameters: [ - { name: "page", in: "query", schema: { type: "integer" } }, - { name: "limit", in: "query", schema: { type: "integer" } }, - ], - responses: { - "200": { - description: "OK", - content: { "application/json": { schema: { $ref: "#/components/schemas/ItemsPage" } } }, - }, - }, - }, - post: { - tags: ["items"], - operationId: "createItem", - requestBody: { - required: true, - content: { "application/json": { schema: { $ref: "#/components/schemas/Item" } } }, - }, - responses: { - "201": { - description: "Created", - content: { "application/json": { schema: { $ref: "#/components/schemas/Item" } } }, - }, - }, - }, - }, - }, - components: { - schemas: { - Item: { - type: "object", - properties: { id: { type: "string" } }, - required: ["id"], - }, - ItemsPage: { - type: "object", - properties: { - items: { type: "array", items: { $ref: "#/components/schemas/Item" } }, - page: { type: "integer" }, - totalItems: { type: "integer" }, - limit: { type: "integer" }, - }, - required: ["items", "limit"], - }, - }, - }, -} as OpenAPIV3.Document; - -// Same as openApiDoc, but createItem has a no-conditions ACL ability so checkAcl must be -// called without an argument - tests checking acl:false reuse openApiDoc instead, since -// endpoint.acl metadata is extracted regardless of the acl/checkAcl options. -const openApiDocWithAcl = { - ...openApiDoc, - paths: { - ...openApiDoc.paths, - "/items": { - ...openApiDoc.paths["/items"], - post: { - ...openApiDoc.paths["/items"]!.post, - "x-acl": [{ action: "create", subject: "Item" }], - }, - }, - }, -} as OpenAPIV3.Document; - -describe("generateConfigs builderConfigs", () => { - it("imports the sibling Queries namespace referenced by usePaginate", () => { - const files = generateCodeFromOpenAPIDoc(openApiDoc, { - ...DEFAULT_GENERATE_OPTIONS, - output: "test-output", - builderConfigs: true, - mutationEffects: false, - acl: false, - checkAcl: false, - }); - - const configsFile = files.find((file) => file.fileName.endsWith("/items/items.configs.ts")); - - expect(configsFile?.content).toContain('import { ItemsQueries } from "./items.queries";'); - expect(configsFile?.content).toContain("paginated: ItemsQueries.useList"); - }); - - it("references the QueryModule const enum directly (no typeof) and types options with MutationEffectsOptions", () => { - const files = generateCodeFromOpenAPIDoc(openApiDoc, { - ...DEFAULT_GENERATE_OPTIONS, - output: "test-output", - builderConfigs: true, - mutationEffects: true, - acl: false, - checkAcl: false, - }); - - const configsFile = files.find((file) => file.fileName.endsWith("/items/items.configs.ts")); - - expect(configsFile?.content).toContain( - 'import { useMutationEffects, type MutationEffectsOptions } from "@povio/openapi-codegen-cli/query";', - ); - expect(configsFile?.content).not.toContain('from "@povio/openapi-codegen-cli";'); - expect(configsFile?.content).toContain("useMutationEffects"); - expect(configsFile?.content).not.toContain("useMutationEffects"); - expect(configsFile?.content).toContain("& MutationEffectsOptions)"); - }); - - it("does not emit an unused OpenApiWorkspaceContext import when workspaceContext is empty", () => { - const files = generateCodeFromOpenAPIDoc(openApiDoc, { - ...DEFAULT_GENERATE_OPTIONS, - output: "test-output", - builderConfigs: true, - mutationEffects: true, - acl: false, - checkAcl: false, - workspaceContext: [], - }); - - const configsFile = files.find((file) => file.fileName.endsWith("/items/items.configs.ts")); - - expect(configsFile?.content).not.toContain("OpenApiWorkspaceContext"); - }); - - it("calls runMutationEffects with the mutation result, variables and options, matching renderMutation", () => { - const files = generateCodeFromOpenAPIDoc(openApiDoc, { - ...DEFAULT_GENERATE_OPTIONS, - output: "test-output", - builderConfigs: true, - mutationEffects: true, - acl: false, - checkAcl: false, - }); - - const configsFile = files.find((file) => file.fileName.endsWith("/items/items.configs.ts")); - - expect(configsFile?.content).toContain("await runMutationEffects(resData, variables, options);"); - expect(configsFile?.content).not.toContain("await runMutationEffects();"); - // the wrapping onSuccess must come after ...options, or a caller-provided options.onSuccess - // would silently disable mutation effects by overriding it. - expect(configsFile?.content).toMatch(/\.\.\.options,[\s\S]*onSuccess: async \(resData, variables/); - }); - - it("does not pass an argument to checkAcl when the ability has no conditions", () => { - const files = generateCodeFromOpenAPIDoc(openApiDocWithAcl, { - ...DEFAULT_GENERATE_OPTIONS, - output: "test-output", - builderConfigs: true, - acl: true, - checkAcl: true, - }); - - const configsFile = files.find((file) => file.fileName.endsWith("/items/items.configs.ts")); - - expect(configsFile?.content).toContain("checkAcl(ItemsAcl.canUseCreate());"); - }); -}); - -describe("generateConfigs builderConfigs ACL conditions", () => { - // createRocket's ability has an officeId condition, so checkAcl must be called with the - // matching object - renderMutationContent must reuse the shared renderAclCheckCall helper - // instead of unconditionally forwarding every destructured mutation arg. - const officeScopedDoc = { - openapi: "3.0.0", - info: { title: "Builder Configs ACL Conditions Test", version: "1.0.0" }, - paths: { - "/offices/{officeId}/rockets": { - get: { - tags: ["rockets"], - operationId: "list", - parameters: [ - { name: "officeId", in: "path", required: true, schema: { type: "string" } }, - { name: "page", in: "query", schema: { type: "integer" } }, - { name: "limit", in: "query", schema: { type: "integer" } }, - ], - responses: { - "200": { - description: "OK", - content: { "application/json": { schema: { $ref: "#/components/schemas/RocketsPage" } } }, - }, - }, - }, - post: { - tags: ["rockets"], - operationId: "createRocket", - "x-acl": [{ action: "create", subject: "Rocket", conditions: { officeId: "$params.officeId" } }], - parameters: [{ name: "officeId", in: "path", required: true, schema: { type: "string" } }], - requestBody: { - required: true, - content: { "application/json": { schema: { $ref: "#/components/schemas/Rocket" } } }, - }, - responses: { - "201": { - description: "Created", - content: { "application/json": { schema: { $ref: "#/components/schemas/Rocket" } } }, - }, - }, - }, - }, - }, - components: { - schemas: { - Rocket: { - type: "object", - properties: { id: { type: "string" }, name: { type: "string" } }, - required: ["id"], - }, - RocketsPage: { - type: "object", - properties: { - items: { type: "array", items: { $ref: "#/components/schemas/Rocket" } }, - page: { type: "integer" }, - totalItems: { type: "integer" }, - limit: { type: "integer" }, - }, - required: ["items"], - }, - }, - }, - } as unknown as OpenAPIV3.Document; - - it("passes the matching conditions object to checkAcl when the ability has conditions", () => { - const files = generateCodeFromOpenAPIDoc(officeScopedDoc, { - ...DEFAULT_GENERATE_OPTIONS, - output: "test-output", - builderConfigs: true, - acl: true, - checkAcl: true, - }); - - const configsFile = files.find((file) => file.fileName.endsWith("/rockets/rockets.configs.ts")); - - expect(configsFile?.content).toContain("checkAcl(RocketsAcl.canUseCreate({ officeId }"); - }); -}); - -describe("generateConfigs builderConfigs media upload", () => { - // upload's endpoint function only accepts the DTO - the file itself is uploaded separately - // to uploadInstructions.url, exactly like renderMutation does for *.queries.ts. Passing file - // as a second argument to the endpoint call is a type error: "expected 1 argument, got 2". - const mediaUploadDoc = { - openapi: "3.0.0", - info: { title: "Builder Configs Media Upload Test", version: "1.0.0" }, - paths: { - "/media": { - get: { - tags: ["media"], - operationId: "list", - parameters: [ - { name: "page", in: "query", schema: { type: "integer" } }, - { name: "limit", in: "query", schema: { type: "integer" } }, - ], - responses: { - "200": { - description: "OK", - content: { "application/json": { schema: { $ref: "#/components/schemas/MediaPage" } } }, - }, - }, - }, - post: { - tags: ["media"], - operationId: "upload", - "x-media-upload": true, - requestBody: { - required: true, - content: { "application/json": { schema: { $ref: "#/components/schemas/UploadDto" } } }, - }, - responses: { - "201": { - description: "Created", - content: { "application/json": { schema: { $ref: "#/components/schemas/UploadInstructions" } } }, - }, - }, - }, - }, - }, - components: { - schemas: { - UploadDto: { type: "object", properties: { method: { type: "string" } } }, - UploadInstructions: { type: "object", properties: { url: { type: "string" } } }, - Media: { type: "object", properties: { id: { type: "string" } }, required: ["id"] }, - MediaPage: { - type: "object", - properties: { - items: { type: "array", items: { $ref: "#/components/schemas/Media" } }, - page: { type: "integer" }, - totalItems: { type: "integer" }, - limit: { type: "integer" }, - }, - required: ["items"], - }, - }, - }, - } as unknown as OpenAPIV3.Document; - - it("calls the endpoint with only the DTO and uploads the file separately to uploadInstructions.url", () => { - const files = generateCodeFromOpenAPIDoc(mediaUploadDoc, { - ...DEFAULT_GENERATE_OPTIONS, - output: "test-output", - builderConfigs: true, - acl: false, - checkAcl: false, - }); - - const configsFile = files.find((file) => file.fileName.endsWith("/media/media.configs.ts")); - - expect(configsFile?.content).toContain("const uploadInstructions = await MediaApi.upload(data);"); - expect(configsFile?.content).not.toContain("MediaApi.upload(data, file)"); - expect(configsFile?.content).toContain( - 'const method = (uploadInstructions.method?.toLowerCase() ?? "put") as "put" | "post";', - ); - expect(configsFile?.content).not.toContain("data?.method?.toLowerCase()"); - expect(configsFile?.content).toContain("await axios[method](uploadInstructions.url, dataToSend,"); - expect(configsFile?.content).toContain("return uploadInstructions;"); - }); - - it("imports axios as the default import for the upload call", () => { - const files = generateCodeFromOpenAPIDoc(mediaUploadDoc, { - ...DEFAULT_GENERATE_OPTIONS, - output: "test-output", - builderConfigs: true, - acl: false, - checkAcl: false, - }); - - const configsFile = files.find((file) => file.fileName.endsWith("/media/media.configs.ts")); - - expect(configsFile?.content).toContain('import axios, { type AxiosRequestConfig } from "axios";'); - }); - - it("imports the application client for native upload calls", () => { - const files = generateCodeFromOpenAPIDoc(mediaUploadDoc, { - ...DEFAULT_GENERATE_OPTIONS, - output: "test-output", - restClient: "native", - builderConfigs: true, - acl: false, - checkAcl: false, - }); - - const configsFile = files.find((file) => file.fileName.endsWith("/media/media.configs.ts")); - const queriesFile = files.find((file) => file.fileName.endsWith("/media/media.queries.ts")); - - for (const file of [configsFile, queriesFile]) { - expect(file?.content).toContain('import { AppRestClient } from "@/data/app-rest-client";'); - expect(file?.content).toContain("await AppRestClient.upload("); - expect(file?.content).not.toContain('from "axios"'); - } - }); -}); diff --git a/src/generators/generate/generateConfigs.ts b/src/generators/generate/generateConfigs.ts deleted file mode 100644 index 6c92de4..0000000 --- a/src/generators/generate/generateConfigs.ts +++ /dev/null @@ -1,419 +0,0 @@ -import { ACL_CHECK_HOOK } from "@/generators/const/acl.const"; -import { - AXIOS_DEFAULT_IMPORT_NAME, - AXIOS_REQUEST_CONFIG_TYPE, - getRequestConfigTypeName, -} from "@/generators/const/endpoints.const"; -import { REST_PACKAGE_IMPORT_PATH } from "@/generators/const/package.const"; -import { APP_REST_CLIENT_NAME } from "@/generators/const/deps.const"; -import { - MUTATION_EFFECTS, - QUERY_MODULE_ENUM, - QUERY_OPTIONS_TYPES, - BUILDERS_UTILS, -} from "@/generators/const/deps.const"; -import { ZOD_IMPORT } from "@/generators/const/zod.const"; -import { BuilderConfig, DynamicColumnsConfig, DynamicInputsConfig } from "@/generators/types/builder-config"; -import { SchemaResolver } from "@/generators/core/SchemaResolver.class"; -import { Endpoint } from "@/generators/types/endpoint"; -import { GenerateType, GenerateTypeParams, Import } from "@/generators/types/generate"; -import { - getImportedEndpointName, - mapEndpointParamsToFunctionParams, - renderMediaUploadMutationBody, -} from "@/generators/utils/generate/generate.endpoints.utils"; -import { getEndpointsImports } from "@/generators/utils/generate/generate.imports.utils"; -import { getBuilderConfigs } from "@/generators/utils/generate/generate.configs.utils"; -import { renderAclCheckCall } from "@/generators/utils/generate/generate.acl.utils"; -import { getNamespaceName } from "@/generators/utils/namespace.utils"; -import { - getAppRestClientImportPath, - getQueryModulesImportPath, - getQueryTypesImportPath, -} from "@/generators/utils/generate/generate.utils"; -import { getEndpointTag } from "@/generators/utils/tag.utils"; -import { QUERY_HOOKS } from "@/generators/const/queries.const"; - -export function generateConfigs(generateTypeParams: GenerateTypeParams) { - const { configs, hasZodImport, modelsImports, queriesImports, aclImports } = getBuilderConfigs(generateTypeParams); - if (configs.length === 0) { - return; - } - - const { resolver, tag } = generateTypeParams; - - const endpoints = configs - .flatMap((config) => [ - config.create?.mutation, - config.update?.mutation, - config.delete?.mutation, - config.bulkDelete?.mutation, - ]) - .filter((m): m is Endpoint => typeof m !== "string" && m !== undefined); - - const hasMutation = endpoints.length > 0; - const hasAclCheck = resolver.options.checkAcl && endpoints.some((e) => e.acl); - const hasMutationEffects = resolver.options.mutationEffects && hasMutation; - const hasMutationDefaultOnError = resolver.options.mutationDefaultOnError && hasMutation; - const hasAxiosRequestConfig = resolver.options.axiosRequestConfig; - const nativeClient = resolver.options.restClient === "native"; - const hasAxiosDefaultImport = !nativeClient && endpoints.some((e) => e.mediaUpload); - const hasAxiosImport = !nativeClient && (hasAxiosRequestConfig || hasAxiosDefaultImport); - const axiosImport: Import = { - defaultImport: hasAxiosDefaultImport ? AXIOS_DEFAULT_IMPORT_NAME : undefined, - bindings: [], - typeBindings: hasAxiosImport ? [AXIOS_REQUEST_CONFIG_TYPE] : [], - from: "axios", - }; - const nativeImport: Import = { - bindings: [], - typeBindings: nativeClient && hasAxiosRequestConfig ? [getRequestConfigTypeName("native")] : [], - from: REST_PACKAGE_IMPORT_PATH, - }; - const appRestClientImport: Import = { - bindings: [APP_REST_CLIENT_NAME], - from: getAppRestClientImportPath(resolver.options), - }; - - const endpointsImports = getEndpointsImports({ - tag, - endpoints, - options: resolver.options, - }); - - const queryImport: Import = { - bindings: [QUERY_HOOKS.mutation], - from: "@tanstack/react-query", - }; - - const queryTypesImport: Import = { - bindings: [...(hasMutationDefaultOnError ? ["OpenApiQueryConfig"] : [])], - typeBindings: [QUERY_OPTIONS_TYPES.mutation], - from: getQueryTypesImportPath(resolver.options), - }; - - const mutationEffectsImport: Import = { - bindings: [MUTATION_EFFECTS.hookName], - typeBindings: [MUTATION_EFFECTS.optionsType], - from: resolver.options.mutationEffectsImportPath, - }; - - const queryModulesImport: Import = { - bindings: [QUERY_MODULE_ENUM], - from: getQueryModulesImportPath(resolver.options), - }; - - const aclCheckImport: Import = { - bindings: [ACL_CHECK_HOOK], - from: resolver.options.aclCheckImportPath, - }; - - const hasDynamicInputsImport = configs.some( - (config) => config.readAll.filters || config.create?.inputDefs || config.update?.inputDefs, // || config.bulkDelete?.inputDefs, - ); - const dynamicInputsImport: Import = { - bindings: [BUILDERS_UTILS.dynamicInputs], - from: resolver.options.dynamicInputsImportPath, - }; - - const hasDynamicColumnsImport = configs.some((config) => config.readAll.columns); - const dynamicColumnsImport: Import = { - bindings: [BUILDERS_UTILS.dynamicColumns], - from: resolver.options.dynamicColumnsImportPath, - }; - - const lines: string[] = []; - if (hasAxiosImport) { - lines.push(renderImport(axiosImport)); - } - if (nativeImport.typeBindings?.length) lines.push(renderImport(nativeImport)); - if (nativeClient && endpoints.some((endpoint) => endpoint.mediaUpload)) { - lines.push(renderImport(appRestClientImport)); - } - if (hasZodImport) { - lines.push(renderImport(ZOD_IMPORT)); - } - if (hasDynamicInputsImport) { - lines.push(renderImport(dynamicInputsImport)); - } - if (hasDynamicColumnsImport) { - lines.push(renderImport(dynamicColumnsImport)); - } - for (const modelsImport of modelsImports) { - lines.push(renderImport(modelsImport)); - } - for (const queriesImport of queriesImports) { - lines.push(renderImport(queriesImport)); - } - // Endpoints are needed for inlined mutations - for (const endpointsImport of endpointsImports) { - lines.push(renderImport(endpointsImport)); - } - - if (hasMutation) { - lines.push(renderImport(queryImport)); - lines.push(renderImport(queryTypesImport)); - if (hasMutationEffects) { - lines.push(renderImport(queryModulesImport)); - lines.push(renderImport(mutationEffectsImport)); - } - if (hasAclCheck) { - lines.push(renderImport(aclCheckImport)); - } - } - - for (const aclImport of aclImports) { - lines.push(renderImport(aclImport)); - } - lines.push(""); - - if (resolver.options.tsNamespaces) { - lines.push( - `export namespace ${getNamespaceName({ type: GenerateType.Configs, tag, options: resolver.options })} {`, - ); - } - - for (const config of configs) { - lines.push(renderBuilderConfig(config, generateTypeParams)); - lines.push(""); - } - - if (resolver.options.tsNamespaces) { - lines.push("}"); - } - - return lines.join("\n").trimEnd() + "\n"; -} - -function renderImport(importData: Import) { - const namedImports = [ - ...importData.bindings, - ...(importData.typeBindings ?? []).map((binding) => (importData.typeOnly ? binding : `type ${binding}`)), - ]; - const names = [ - ...(importData.defaultImport ? [importData.defaultImport] : []), - ...(namedImports.length > 0 ? [`{ ${namedImports.join(", ")} }`] : []), - ].join(", "); - return `import${importData.typeOnly ? " type" : ""} ${names} from "${importData.from}";`; -} - -function renderInputsConfig(inputsConfig: DynamicInputsConfig) { - const lines: string[] = []; - lines.push("{"); - lines.push(` schema: ${inputsConfig.schema},`); - lines.push(" options: {"); - lines.push(" inputs: {"); - for (const key of Object.keys(inputsConfig.options.inputs)) { - lines.push(` ${key}: true,`); - } - lines.push(" },"); - lines.push(" },"); - lines.push("}"); - return lines.join("\n"); -} - -function renderColumnsConfig(columnsConfig: DynamicColumnsConfig) { - const lines: string[] = []; - lines.push("{"); - lines.push(` schema: ${columnsConfig.schema},`); - lines.push(" options: {"); - lines.push(" columns: {"); - for (const key of Object.keys(columnsConfig.options.columns)) { - lines.push(` ${key}: true,`); - } - lines.push(" },"); - if (columnsConfig.options.sortable) { - lines.push(` sortable: ${columnsConfig.options.sortable},`); - } - lines.push(" },"); - lines.push("}"); - return lines.join("\n"); -} - -function renderMutationContent(resolver: SchemaResolver, endpoint: Endpoint, tag: string) { - const hasAclCheck = resolver.options.checkAcl && endpoint.acl; - const hasMutationEffects = resolver.options.mutationEffects; - const hasMutationDefaultOnError = resolver.options.mutationDefaultOnError; - const hasAxiosRequestConfig = resolver.options.axiosRequestConfig; - const endpointTag = getEndpointTag(endpoint, resolver.options); - - const endpointParams = mapEndpointParamsToFunctionParams(resolver, endpoint, { - includeFileParam: true, - modelNamespaceTag: endpointTag, - }); - - const endpointParamsStr = endpointParams.map((p) => `${p.name}${p.required ? "" : "?"}: ${p.type}`).join("; "); - - const destructuredMutationArgs = endpointParams.map((p) => p.name).join(", "); - // The file itself isn't part of the endpoint function's call signature - it's uploaded - // separately to the URL the endpoint call returns. See renderMediaUploadMutationBody. - const resolvedEndpointArgs = mapEndpointParamsToFunctionParams(resolver, endpoint, { modelNamespaceTag: endpointTag }) - .map((p) => p.name) - .join(", "); - const endpointFunction = getImportedEndpointName(endpoint, resolver.options); - - const mutationVariablesType = endpoint.mediaUpload - ? `{ ${endpointParamsStr}${endpointParamsStr ? "; " : ""}abortController?: AbortController; onUploadProgress?: (progress: { loaded: number; total: number }) => void }` - : `{ ${endpointParamsStr} }`; - - const lines: string[] = []; - lines.push( - `(options?: AppMutationOptions${hasMutationEffects ? ` & ${MUTATION_EFFECTS.optionsType}` : ""}${hasAxiosRequestConfig ? `, config?: ${getRequestConfigTypeName(resolver.options.restClient)}` : ""}) => {`, - ); - if (hasMutationDefaultOnError) { - lines.push(" const queryConfig = OpenApiQueryConfig.useConfig();"); - } - - if (hasMutationEffects) { - lines.push( - ` const { runMutationEffects } = useMutationEffects<${QUERY_MODULE_ENUM}.${endpointTag}>({ currentModule: ${QUERY_MODULE_ENUM}.${tag} });`, - ); - } - if (hasAclCheck) { - lines.push(` const { checkAcl } = ${ACL_CHECK_HOOK}();`); - } - lines.push(""); - lines.push(` return ${QUERY_HOOKS.mutation}({`); - - const mutationFnArg = destructuredMutationArgs - ? `{ ${destructuredMutationArgs}${endpoint.mediaUpload ? `${destructuredMutationArgs ? ", " : ""}abortController, onUploadProgress` : ""} }` - : ""; - - lines.push(` mutationFn: ${endpoint.mediaUpload ? "async " : ""}(${mutationFnArg}) => {`); - if (hasAclCheck) { - lines.push(renderAclCheckCall(resolver, endpoint, undefined, " ")); - } - if (endpoint.mediaUpload) { - lines.push( - ...renderMediaUploadMutationBody({ resolver, endpointFunction, resolvedEndpointArgs }).map( - (line) => ` ${line}`, - ), - ); - } else { - lines.push( - ` return ${endpointFunction}(${destructuredMutationArgs}${hasAxiosRequestConfig ? `${destructuredMutationArgs ? ", " : ""}config` : ""});`, - ); - } - lines.push(" },"); - lines.push(" ...options,"); - if (hasMutationDefaultOnError) { - lines.push(" onError: options?.onError ?? queryConfig.onError,"); - } - if (hasMutationEffects) { - lines.push(" onSuccess: async (resData, variables, onMutateResult, context) => {"); - lines.push(" await runMutationEffects(resData, variables, options);"); - lines.push(" options?.onSuccess?.(resData, variables, onMutateResult, context);"); - lines.push(" },"); - } - lines.push(" });"); - lines.push("}"); - return lines - .map((line) => " " + line) - .join("\n") - .trimStart(); -} - -function renderBuilderConfig(config: BuilderConfig, params: GenerateTypeParams) { - const { resolver, tag } = params; - const lines: string[] = []; - lines.push(`export const ${config.name} = {`); - lines.push(" meta: {"); - lines.push(` title: "${config.title}",`); - lines.push(" },"); - lines.push(" readAll: {"); - if (config.readAll.acl) { - lines.push(` acl: ${config.readAll.acl},`); - } - lines.push(` schema: ${config.readAll.columns.schema},`); - lines.push(` paginated: ${config.readAll.paginated},`); - if (config.readAll.infinite) { - lines.push(` infinite: ${config.readAll.infinite},`); - } - if (config.readAll.filters) { - lines.push(" filters: {"); - lines.push(` schema: ${config.readAll.filters.schema},`); - lines.push( - ` filterDefs: ${BUILDERS_UTILS.dynamicInputs}(${renderInputsConfig(config.readAll.filters)})`, - ); - lines.push(" },"); - } - lines.push(` columns: ${BUILDERS_UTILS.dynamicColumns}(${renderColumnsConfig(config.readAll.columns)}),`); - lines.push(" },"); - - if (config.read) { - lines.push(" read: {"); - if (config.read.acl) { - lines.push(` acl: ${config.read.acl},`); - } - lines.push(` schema: ${config.read.schema},`); - lines.push(` query: ${config.read.query},`); - lines.push(" },"); - } - - if (config.create) { - lines.push(" create: {"); - if (config.create.acl) { - lines.push(` acl: ${config.create.acl},`); - } - if (config.create.schema) { - lines.push(` schema: ${config.create.schema},`); - } - lines.push( - ` mutation: ${typeof config.create.mutation === "string" ? config.create.mutation : renderMutationContent(resolver, config.create.mutation, tag)},`, - ); - if (config.create.inputDefs) { - lines.push(` inputDefs: ${BUILDERS_UTILS.dynamicInputs}(${renderInputsConfig(config.create.inputDefs)})`); - } - lines.push(" },"); - } - - if (config.update) { - lines.push(" update: {"); - if (config.update.acl) { - lines.push(` acl: ${config.update.acl},`); - } - if (config.update.schema) { - lines.push(` schema: ${config.update.schema},`); - } - lines.push( - ` mutation: ${typeof config.update.mutation === "string" ? config.update.mutation : renderMutationContent(resolver, config.update.mutation, tag)},`, - ); - if (config.update.inputDefs) { - lines.push(` inputDefs: ${BUILDERS_UTILS.dynamicInputs}(${renderInputsConfig(config.update.inputDefs)})`); - } - lines.push(" },"); - } - - if (config.delete) { - lines.push(" delete: {"); - if (config.delete.acl) { - lines.push(` acl: ${config.delete.acl},`); - } - lines.push( - ` mutation: ${typeof config.delete.mutation === "string" ? config.delete.mutation : renderMutationContent(resolver, config.delete.mutation, tag)},`, - ); - lines.push(" },"); - } - - if (config.bulkDelete) { - lines.push(" bulkDelete: {"); - if (config.bulkDelete.acl) { - lines.push(` acl: ${config.bulkDelete.acl},`); - } - if (config.bulkDelete.schema) { - lines.push(` schema: ${config.bulkDelete.schema},`); - } - lines.push( - ` mutation: ${typeof config.bulkDelete.mutation === "string" ? config.bulkDelete.mutation : renderMutationContent(resolver, config.bulkDelete.mutation, tag)},`, - ); - if (config.bulkDelete.inputDefs) { - lines.push( - ` inputDefs: ${BUILDERS_UTILS.dynamicInputs}(${renderInputsConfig(config.bulkDelete.inputDefs)})`, - ); - } - lines.push(" },"); - } - - lines.push("};"); - return lines.join("\n"); -} diff --git a/src/generators/generate/generateQueries.test.ts b/src/generators/generate/generateQueries.test.ts index 7104b2d..658dc37 100644 --- a/src/generators/generate/generateQueries.test.ts +++ b/src/generators/generate/generateQueries.test.ts @@ -174,7 +174,6 @@ describe("generateQueries workspaceContext", () => { acl: false, checkAcl: false, mutationEffects: false, - builderConfigs: false, prefetchQueries: false, }); @@ -206,7 +205,6 @@ describe("generateQueries workspaceContext", () => { acl: false, checkAcl: false, mutationEffects: false, - builderConfigs: false, prefetchQueries: false, }); @@ -230,7 +228,6 @@ describe("generateQueries workspaceContext", () => { ...DEFAULT_GENERATE_OPTIONS, output: "test-output", workspaceContext: ["officeId", "positionId", "id"], - builderConfigs: false, prefetchQueries: false, }); @@ -262,7 +259,6 @@ describe("generateQueries workspaceContext", () => { workspaceContext: ["officeId"], acl: false, checkAcl: false, - builderConfigs: false, prefetchQueries: false, }); @@ -291,7 +287,6 @@ describe("generateQueries workspaceContext", () => { ...DEFAULT_GENERATE_OPTIONS, output: "test-output", workspaceContext: ["officeId"], - builderConfigs: false, prefetchQueries: false, }); @@ -318,7 +313,6 @@ describe("generateQueries workspaceContext", () => { acl: false, checkAcl: false, mutationEffects: false, - builderConfigs: false, prefetchQueries: false, }); @@ -342,7 +336,6 @@ describe("generateQueries workspaceContext", () => { acl: false, checkAcl: false, mutationEffects: false, - builderConfigs: false, prefetchQueries: false, }); @@ -361,7 +354,6 @@ describe("generateQueries workspaceContext", () => { checkAcl: false, mutationEffects: false, mutationDefaultOnError: true, - builderConfigs: false, prefetchQueries: false, }); @@ -440,7 +432,6 @@ describe("generateQueries mutationEffects + infiniteQuery", () => { infiniteQueries: true, acl: false, checkAcl: false, - builderConfigs: false, prefetchQueries: false, }); @@ -478,7 +469,6 @@ describe("generateQueries mutationEffects + infiniteQuery", () => { prefetchQueries: true, acl: false, checkAcl: false, - builderConfigs: false, }); const queriesFile = files.find((file) => file.fileName.endsWith("/items/items.queries.ts")); @@ -504,7 +494,6 @@ describe("generateQueries mutationEffects + infiniteQuery", () => { mutationEffectsImportPath: "@/data/useMutationEffects", aclCheckImportPath: "@/data/acl/useAclCheck", zodImportPath: "@/data/zod.extended", - builderConfigs: false, }); const queriesFile = files.find((file) => file.fileName.endsWith("/workspace/workspace.queries.ts")); @@ -525,7 +514,6 @@ describe("generateQueries mutationEffects + infiniteQuery", () => { tsNamespaces: true, inlineEndpoints: false, workspaceContext: [], - builderConfigs: false, }; const tsFiles = generateCodeFromOpenAPIDoc(structuredClone(openApiDoc), options); const nativeFiles = generateFilesFromNativeOpenAPI(JSON.stringify(openApiDoc), false, options); @@ -548,7 +536,6 @@ describe("generateQueries mutationEffects + infiniteQuery", () => { output: "test-output", restClient: "native", axiosRequestConfig: true, - builderConfigs: false, }); const appClient = files.find((file) => file.fileName.endsWith("/app-rest-client.ts")); diff --git a/src/generators/generateCodeFromOpenAPIDoc.test.ts b/src/generators/generateCodeFromOpenAPIDoc.test.ts index 81f5e12..12c2515 100644 --- a/src/generators/generateCodeFromOpenAPIDoc.test.ts +++ b/src/generators/generateCodeFromOpenAPIDoc.test.ts @@ -327,7 +327,6 @@ describe("generateCodeFromOpenAPIDoc - modelsInCommon regressions", () => { ...DEFAULT_GENERATE_OPTIONS, output: "output", modelsInCommon: true, - builderConfigs: false, prefetchQueries: false, mutationEffects: false, } as GenerateOptions; @@ -823,7 +822,6 @@ describe("generateCodeFromOpenAPIDoc - shared parameter schema namespace", () => const files = generateCodeFromOpenAPIDoc(sharedParamSchemaDoc, { ...(DEFAULT_GENERATE_OPTIONS as GenerateOptions), - builderConfigs: true, acl: false, checkAcl: false, }); @@ -833,9 +831,6 @@ describe("generateCodeFromOpenAPIDoc - shared parameter schema namespace", () => const adminQueries = files.find(({ fileName }) => fileName.endsWith("/pushNotificationAdmin/pushNotificationAdmin.queries.ts"), )?.content; - const adminConfigs = files.find(({ fileName }) => - fileName.endsWith("/pushNotificationAdmin/pushNotificationAdmin.configs.ts"), - )?.content; test("api file uses the owning namespace (CommonModels) for the shared parameter schema", () => { expect(adminApi).toBeDefined(); @@ -848,10 +843,4 @@ describe("generateCodeFromOpenAPIDoc - shared parameter schema namespace", () => expect(adminQueries).toContain("CommonModels.TokenFiltersDto"); expect(adminQueries).not.toContain("PushNotificationAdminModels.TokenFiltersDto"); }); - - test("configs file uses the owning namespace (CommonModels) for the shared parameter schema", () => { - expect(adminConfigs).toBeDefined(); - expect(adminConfigs).toContain("CommonModels.TokenFiltersDto"); - expect(adminConfigs).not.toContain("PushNotificationAdminModels.TokenFiltersDto"); - }); }); diff --git a/src/generators/generateCodeFromOpenAPIDoc.ts b/src/generators/generateCodeFromOpenAPIDoc.ts index 9ee31f4..85b09e5 100644 --- a/src/generators/generateCodeFromOpenAPIDoc.ts +++ b/src/generators/generateCodeFromOpenAPIDoc.ts @@ -1,7 +1,6 @@ import { OpenAPIV3 } from "openapi-types"; import { getDataFromOpenAPIDoc } from "./core/getDataFromOpenAPIDoc"; import { generateAcl } from "./generate/generateAcl"; -import { generateConfigs } from "./generate/generateConfigs"; import { generateEndpoints } from "./generate/generateEndpoints"; import { generateModels } from "./generate/generateModels"; import { generateQueries } from "./generate/generateQueries"; @@ -48,14 +47,12 @@ export function generateCodeFromOpenAPIDoc( ...(shouldGenerateEndpoints ? [GenerateType.Endpoints] : []), GenerateType.Queries, ...(resolver.options.acl ? [GenerateType.Acl] : []), - ...(resolver.options.builderConfigs ? [GenerateType.Configs] : []), ]; const generateFunctions: Record string | undefined> = { [GenerateType.Models]: generateModels, [GenerateType.Endpoints]: generateEndpoints, [GenerateType.Queries]: generateQueries, [GenerateType.Acl]: generateAcl, - [GenerateType.Configs]: generateConfigs, }; data.forEach((_, tag) => { diff --git a/src/generators/types/builder-config.ts b/src/generators/types/builder-config.ts deleted file mode 100644 index 6e90838..0000000 --- a/src/generators/types/builder-config.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { Endpoint } from "./endpoint"; - -export interface DynamicInputsConfig { - schema: string; - options: { - inputs: Record; - }; -} - -export interface DynamicColumnsConfig { - schema: string; - options: { - columns: Record; - sortable?: string; - }; -} - -export interface BuilderConfig { - name: string; - title: string; - readAll: { - acl?: string; - paginated: string; - infinite?: string; - filters?: DynamicInputsConfig; - columns: DynamicColumnsConfig; - }; - read?: { - acl?: string; - schema: string; - query: string; - }; - create?: { - acl?: string; - schema: string; - mutation: string | Endpoint; - inputDefs?: DynamicInputsConfig; - }; - update?: { - acl?: string; - schema: string; - mutation: string | Endpoint; - inputDefs?: DynamicInputsConfig; - }; - delete?: { - acl?: string; - mutation: string | Endpoint; - }; - bulkDelete?: { - acl?: string; - schema: string; - mutation: string | Endpoint; - inputDefs?: DynamicInputsConfig; - }; -} diff --git a/src/generators/types/generate.ts b/src/generators/types/generate.ts index e632851..80f4960 100644 --- a/src/generators/types/generate.ts +++ b/src/generators/types/generate.ts @@ -29,7 +29,6 @@ export enum GenerateType { Endpoints = "endpoints", Queries = "queries", Acl = "acl", - Configs = "configs", } export type GenerateData = Map< diff --git a/src/generators/types/options.ts b/src/generators/types/options.ts index 987a9f3..1b07714 100644 --- a/src/generators/types/options.ts +++ b/src/generators/types/options.ts @@ -58,14 +58,6 @@ interface ACLGenerateOptions { aclCheckImportPath: string; } -interface BuilderConfigsGenerateOptions { - builderConfigs?: boolean; - filterParamName: string; - dataResponseParamNames: string[]; - dynamicInputsImportPath: string; - dynamicColumnsImportPath: string; -} - interface GenerateConfig { outputFileNameSuffix: string; namespaceSuffix: string; @@ -99,5 +91,4 @@ export interface GenerateOptions EndpointsGenerateOptions, QueriesGenerateOptions, InfiniteQueriesGenerateOptions, - ACLGenerateOptions, - BuilderConfigsGenerateOptions {} + ACLGenerateOptions {} diff --git a/src/generators/utils/generate/generate.configs.utils.ts b/src/generators/utils/generate/generate.configs.utils.ts deleted file mode 100644 index f93ba5d..0000000 --- a/src/generators/utils/generate/generate.configs.utils.ts +++ /dev/null @@ -1,361 +0,0 @@ -import { OpenAPIV3 } from "openapi-types"; - -import { BUILD_CONFIG_SUFFIX } from "@/generators/const/buildConfigs.const"; -import { JSON_APPLICATION_FORMAT } from "@/generators/const/endpoints.const"; -import { ANY_SCHEMA } from "@/generators/const/zod.const"; -import { SchemaResolver } from "@/generators/core/SchemaResolver.class"; -import { BuilderConfig } from "@/generators/types/builder-config"; -import { Endpoint, EndpointParameter, ExtendedEndpoint } from "@/generators/types/endpoint"; -import { GenerateTypeParams } from "@/generators/types/generate"; -import { GenerateOptions } from "@/generators/types/options"; -import { - getPathSegments, - isBulkDeleteEndpoint, - isCreateEndpoint, - isDeleteEndpoint, - isPathSegmentParam, - isReadAllEndpoint, - isReadEndpoint, - isUpdateEndpoint, -} from "@/generators/utils/endpoint.utils"; -import { isReferenceObject } from "@/generators/utils/openapi-schema.utils"; -import { camelToSpaceSeparated, capitalize, kebabToCamel } from "@/generators/utils/string.utils"; -import { isNamedZodSchema } from "@/generators/utils/zod-schema.utils"; - -import { getImportedAbilityFunctionName } from "./generate.acl.utils"; -import { getEndpointBody } from "./generate.endpoints.utils"; -import { - getAclImports, - getInfiniteQueriesImports, - getModelsImports, - getQueriesImports, - mergeImports, -} from "./generate.imports.utils"; -import { getImportedInfiniteQueryName, getImportedQueryName } from "./generate.query.utils"; -import { getImportedZodSchemaName } from "./generate.zod.utils"; - -export function getBuilderConfigs({ data, tag, resolver }: GenerateTypeParams) { - const endpoints = data.get(tag)?.endpoints; - if (!endpoints || endpoints.length === 0) { - return { - configs: [], - hasZodImport: false, - modelsImports: [], - queriesImports: [], - aclImports: [], - }; - } - - const extendedEndpoints: ExtendedEndpoint[] = endpoints.map((endpoint) => ({ - ...endpoint, - pathSegments: getPathSegments(endpoint.path), - })); - const readAllEndpoints = extendedEndpoints.filter((endpoint) => isReadAllEndpoint(endpoint, resolver.options)); - const namedReadAllEndpoints = resolveBuilderConfigNames(readAllEndpoints); - - let hasZodImport = false; - const importedZodSchemas: string[] = []; - const importedEndpoints: Endpoint[] = []; - const importedInfiniteEndpoints: Endpoint[] = []; - - const configs = Object.entries(namedReadAllEndpoints).map(([name, readAllEndpoint]) => { - // Read all config - const columnsConfig = getColumnsConfig(resolver, readAllEndpoint); - if (!columnsConfig) { - return; - } - - importedEndpoints.push(readAllEndpoint); - importedInfiniteEndpoints.push(readAllEndpoint); - - if (columnsConfig.zodSchema) { - if (isNamedZodSchema(columnsConfig.zodSchema)) { - importedZodSchemas.push(columnsConfig.zodSchema); - } else { - hasZodImport = true; - } - } - if (columnsConfig.sortableEnumSchemaName) { - importedZodSchemas.push(columnsConfig.sortableEnumSchemaName); - } - - const filter = readAllEndpoint.parameters.find((param) => param.name === resolver.options.filterParamName); - if (filter) { - importedZodSchemas.push(filter.zodSchema); - } - - const config: BuilderConfig = { - name, - title: capitalize(camelToSpaceSeparated(name.replace(/config$/i, ""))), - readAll: { - acl: getAclConfig(readAllEndpoint, resolver.options), - paginated: getImportedQueryName(readAllEndpoint, resolver.options), - infinite: resolver.options.infiniteQueries - ? getImportedInfiniteQueryName(readAllEndpoint, resolver.options) - : undefined, - filters: getInputsConfig(resolver, filter), - columns: columnsConfig.columns, - }, - }; - - // Read config - const readEndpoint = extendedEndpoints.find((endpoint) => isReadEndpoint(endpoint, readAllEndpoint)); - if (readEndpoint) { - importedEndpoints.push(readEndpoint); - - let responseSchema = readEndpoint.response; - if (isNamedZodSchema(responseSchema)) { - importedZodSchemas.push(responseSchema); - responseSchema = getImportedZodSchemaName(resolver, responseSchema); - } else { - hasZodImport = true; - } - - config.read = { - acl: getAclConfig(readEndpoint, resolver.options), - schema: responseSchema, - query: getImportedQueryName(readEndpoint, resolver.options), - }; - } - - // Create config - const createEndpoint = extendedEndpoints.find((endpoint) => isCreateEndpoint(endpoint, readAllEndpoint)); - if (createEndpoint) { - importedEndpoints.push(createEndpoint); - - const body = getEndpointBody(createEndpoint); - if (body) { - importedZodSchemas.push(body.zodSchema); - - config.create = { - acl: getAclConfig(createEndpoint, resolver.options), - schema: getImportedZodSchemaName(resolver, body.zodSchema), - mutation: createEndpoint, - inputDefs: getInputsConfig(resolver, body), - }; - } - } - - // Update config - const updateEndpoint = extendedEndpoints.find((endpoint) => isUpdateEndpoint(endpoint, readAllEndpoint)); - if (updateEndpoint) { - importedEndpoints.push(updateEndpoint); - - const body = getEndpointBody(updateEndpoint); - if (body) { - importedZodSchemas.push(body.zodSchema); - - config.update = { - acl: getAclConfig(updateEndpoint, resolver.options), - schema: getImportedZodSchemaName(resolver, body.zodSchema), - mutation: updateEndpoint, - inputDefs: getInputsConfig(resolver, body), - }; - } - } - - // Delete config - const deleteEndpoint = extendedEndpoints.find((endpoint) => isDeleteEndpoint(endpoint, readAllEndpoint)); - if (deleteEndpoint) { - importedEndpoints.push(deleteEndpoint); - - const body = getEndpointBody(deleteEndpoint); - if (body) { - importedZodSchemas.push(body.zodSchema); - - config.delete = { - acl: getAclConfig(deleteEndpoint, resolver.options), - mutation: deleteEndpoint, - }; - } - } - - // Bulk delete config - const bulkDeleteEndpoint = extendedEndpoints.find((endpoint) => isBulkDeleteEndpoint(endpoint, readAllEndpoint)); - if (bulkDeleteEndpoint) { - importedEndpoints.push(bulkDeleteEndpoint); - - const body = getEndpointBody(bulkDeleteEndpoint); - if (body) { - importedZodSchemas.push(body.zodSchema); - - config.bulkDelete = { - acl: getAclConfig(bulkDeleteEndpoint, resolver.options), - schema: getImportedZodSchemaName(resolver, body.zodSchema), - mutation: bulkDeleteEndpoint, - inputDefs: getInputsConfig(resolver, body), - }; - } - } - - return config; - }); - - const modelsImports = getModelsImports({ resolver, tag, zodSchemas: importedZodSchemas }); - const queriesImports = getQueriesImports({ tag, endpoints: importedEndpoints, options: resolver.options }); - const infiniteQueriesImports = resolver.options.infiniteQueries - ? getInfiniteQueriesImports({ - tag, - endpoints: importedInfiniteEndpoints, - options: resolver.options, - }) - : []; - const aclImports = getAclImports({ - tag, - endpoints: importedEndpoints.filter((endpoint) => endpoint.acl), - options: resolver.options, - }); - - return { - configs: configs.filter(Boolean) as BuilderConfig[], - hasZodImport, - modelsImports, - queriesImports: mergeImports(resolver.options, queriesImports, infiniteQueriesImports), - aclImports, - }; -} - -function resolveBuilderConfigNames(endpoints: ExtendedEndpoint[]): Record { - const sortedEndpoints = endpoints - .map((endpoint) => ({ - ...endpoint, - namePathSegments: endpoint.pathSegments.filter((segment) => !isPathSegmentParam(segment)), - })) - .toSorted((a, b) => a.namePathSegments.length - b.namePathSegments.length); - - const namedEndpoints: Record = {}; - for (const endpoint of sortedEndpoints) { - const namePathSegments = endpoint.namePathSegments; - - let name = - namePathSegments.length > 0 - ? `${kebabToCamel(namePathSegments.pop()!)}${capitalize(BUILD_CONFIG_SUFFIX)}` - : BUILD_CONFIG_SUFFIX; - while (namedEndpoints[name]) { - if (namePathSegments.length === 0) { - throw new Error(`Can't uniquely resolve builder config name: ${name}`); - } - name = `${kebabToCamel(namePathSegments.pop()!)}${capitalize(name)}`; - } - namedEndpoints[name] = endpoint; - } - - return namedEndpoints; -} - -function getAclConfig(endpoint: Endpoint, options: GenerateOptions) { - return options.acl && endpoint.acl ? getImportedAbilityFunctionName(endpoint, options) : undefined; -} - -function getInputsConfig(resolver: SchemaResolver, endpointParameter?: EndpointParameter) { - if (!endpointParameter) { - return; - } - - let schema: OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject | undefined; - if (endpointParameter.type === "Body") { - schema = endpointParameter.bodyObject?.content?.[JSON_APPLICATION_FORMAT]?.schema; - } else { - schema = endpointParameter.parameterObject?.schema; - } - - if (!schema) { - return; - } - - const schemaObj = resolver.resolveObject(schema); - const inputs = Object.keys(schemaObj?.properties ?? {}).reduce((acc, key) => ({ ...acc, [key]: true }), {}); - return { schema: getImportedZodSchemaName(resolver, endpointParameter.zodSchema), options: { inputs } }; -} - -function resolveReadAllItemZodSchema( - resolver: SchemaResolver, - endpoint: Endpoint, - itemSchema: OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject, - itemsPropertyKey: string, -): string { - if (isReferenceObject(itemSchema)) { - return resolver.getZodSchemaNameByRef(itemSchema.$ref); - } - - if (isNamedZodSchema(endpoint.response)) { - return `${getImportedZodSchemaName(resolver, endpoint.response)}.shape.${itemsPropertyKey}.element`; - } - - return ANY_SCHEMA; -} - -function getColumnsConfig(resolver: SchemaResolver, endpoint: Endpoint) { - const endpointResponse = endpoint.responseObject; - if (!endpointResponse) { - return; - } - - const schema = endpointResponse.content?.[JSON_APPLICATION_FORMAT]?.schema; - if (!schema) { - return; - } - - const properties = getSchemaProperties(resolver, schema); - const arrayPropertyKeys = Object.keys(properties).filter( - (key) => resolver.resolveObject(properties[key]).type === "array", - ); - arrayPropertyKeys.sort((a, b) => { - const aIndex = resolver.options.dataResponseParamNames.indexOf(a); - const bIndex = resolver.options.dataResponseParamNames.indexOf(b); - if (aIndex !== -1 && bIndex !== -1) return aIndex - bIndex; - if (aIndex !== -1) return -1; - if (bIndex !== -1) return 1; - return a.localeCompare(b); - }); - const propertyKey = arrayPropertyKeys[0]; - if (!propertyKey) { - return; - } - - const dataSchemaObj = resolver.resolveObject(properties[propertyKey]); - if (!dataSchemaObj || dataSchemaObj.type !== "array") { - return; - } - - const itemSchema = dataSchemaObj.items; - const itemSchemaObj = resolver.resolveObject(itemSchema); - if (!itemSchemaObj) { - return; - } - - const zodSchema = resolveReadAllItemZodSchema(resolver, endpoint, itemSchema, propertyKey); - const columns = Object.keys(itemSchemaObj?.properties ?? {}).reduce((acc, key) => ({ ...acc, [key]: true }), {}); - - const sortableEnumSchemaName = endpoint.parameters.find( - (param) => param.parameterSortingEnumSchemaName, - )?.parameterSortingEnumSchemaName; - - return { - columns: { - schema: getImportedZodSchemaName(resolver, zodSchema), - options: { - columns, - sortable: sortableEnumSchemaName ? getImportedZodSchemaName(resolver, sortableEnumSchemaName) : undefined, - }, - }, - zodSchema, - sortableEnumSchemaName, - }; -} - -function getSchemaProperties( - resolver: SchemaResolver, - schema: OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject, -): { [name: string]: OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject } { - const schemaObj = resolver.resolveObject(schema); - if (schemaObj.properties) { - return schemaObj.properties; - } else if ("allOf" in schemaObj && schemaObj.allOf) { - return schemaObj.allOf.reduce( - (acc, cur) => ({ ...acc, ...getSchemaProperties(resolver, cur) }), - {} as { [name: string]: OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject }, - ); - } - return {}; -} diff --git a/src/generators/utils/generate/generate.endpoints.utils.ts b/src/generators/utils/generate/generate.endpoints.utils.ts index 14d8f66..070b598 100644 --- a/src/generators/utils/generate/generate.endpoints.utils.ts +++ b/src/generators/utils/generate/generate.endpoints.utils.ts @@ -197,9 +197,8 @@ function createEndpointConfig(endpoint: Endpoint) { } /** Renders the body of a media-upload mutationFn: call the endpoint (without the file arg) to - * get upload instructions, then upload the file itself to the returned URL. Shared between - * renderMutation (*.queries.ts) and renderMutationContent (*.configs.ts / builderConfigs) so - * both mutation paths stay in sync. Lines are relative to the caller's own indent. */ + * get upload instructions, then upload the file itself to the returned URL. Lines are relative + * to the caller's own indent. */ export function renderMediaUploadMutationBody({ resolver, endpointFunction, diff --git a/src/generators/utils/generate/generate.imports.utils.ts b/src/generators/utils/generate/generate.imports.utils.ts index 4853fd2..94c7c2b 100644 --- a/src/generators/utils/generate/generate.imports.utils.ts +++ b/src/generators/utils/generate/generate.imports.utils.ts @@ -9,7 +9,6 @@ import { getEndpointTag } from "@/generators/utils/tag.utils"; import { getAbilityFunctionName } from "./generate.acl.utils"; import { getEndpointName } from "./generate.endpoints.utils"; -import { getInfiniteQueryName, getQueryName } from "./generate.query.utils"; import { getTagImportPath } from "./generate.utils"; import { getZodSchemaInferedTypeName } from "./generate.zod.utils"; @@ -70,44 +69,6 @@ export function getEndpointsImports({ }); } -export function getQueriesImports({ - tag, - endpoints, - options, -}: { - tag: string; - endpoints: Endpoint[]; - options: GenerateOptions; -}) { - return getImports({ - type: GenerateType.Queries, - tag, - entities: endpoints, - getTag: (endpoint) => getEndpointTag(endpoint, options), - getEntityName: getQueryName, - options, - }); -} - -export function getInfiniteQueriesImports({ - tag, - endpoints, - options, -}: { - tag: string; - endpoints: Endpoint[]; - options: GenerateOptions; -}) { - return getImports({ - type: GenerateType.Queries, - tag, - entities: endpoints, - getTag: (endpoint) => getEndpointTag(endpoint, options), - getEntityName: getInfiniteQueryName, - options, - }); -} - export function getAclImports({ tag, endpoints, diff --git a/src/generators/utils/generate/generate.query.utils.ts b/src/generators/utils/generate/generate.query.utils.ts index dc69ada..3d24228 100644 --- a/src/generators/utils/generate/generate.query.utils.ts +++ b/src/generators/utils/generate/generate.query.utils.ts @@ -1,10 +1,6 @@ import { Endpoint } from "@/generators/types/endpoint"; -import { GenerateType } from "@/generators/types/generate"; -import { GenerateOptions } from "@/generators/types/options"; -import { getNamespaceName } from "@/generators/utils/namespace.utils"; import { isMutation, isQuery } from "@/generators/utils/query.utils"; import { capitalize, snakeToCamel } from "@/generators/utils/string.utils"; -import { getEndpointTag } from "@/generators/utils/tag.utils"; const operationNameByEndpoint = new WeakMap(); const capitalizedOperationNameByEndpoint = new WeakMap(); @@ -42,17 +38,3 @@ export const getPrefetchQueryName = (endpoint: Endpoint) => `prefetch${getCapita export const getPrefetchInfiniteQueryName = (endpoint: Endpoint) => `prefetch${getCapitalizedOperationName(endpoint)}Infinite`; - -export const getImportedQueryName = (endpoint: Endpoint, options: GenerateOptions) => { - const namespacePrefix = options.tsNamespaces - ? `${getNamespaceName({ type: GenerateType.Queries, tag: getEndpointTag(endpoint, options), options })}.` - : ""; - return `${namespacePrefix}${getQueryName(endpoint)}`; -}; - -export const getImportedInfiniteQueryName = (endpoint: Endpoint, options: GenerateOptions) => { - const namespacePrefix = options.tsNamespaces - ? `${getNamespaceName({ type: GenerateType.Queries, tag: getEndpointTag(endpoint, options), options })}.` - : ""; - return `${namespacePrefix}${getInfiniteQueryName(endpoint)}`; -}; diff --git a/src/native/generateFilesFromNativeOpenAPI.test.ts b/src/native/generateFilesFromNativeOpenAPI.test.ts index 688d952..748b25c 100644 --- a/src/native/generateFilesFromNativeOpenAPI.test.ts +++ b/src/native/generateFilesFromNativeOpenAPI.test.ts @@ -27,7 +27,6 @@ describe("supportsCompleteNativeRender", () => { it.each([ ["unsplit output", { splitByTags: false }], ["inline endpoints", { inlineEndpoints: true }], - ["builder configs", { builderConfigs: true }], ["workspace context", { workspaceContext: ["officeId"] }], ])("does not claim full native support for %s", (_name, override) => { expect(supportsCompleteNativeRender({ ...supportedOptions, ...override })).toBe(false); diff --git a/src/native/generateFilesFromNativeOpenAPI.ts b/src/native/generateFilesFromNativeOpenAPI.ts index 3dd037b..4233be9 100644 --- a/src/native/generateFilesFromNativeOpenAPI.ts +++ b/src/native/generateFilesFromNativeOpenAPI.ts @@ -84,12 +84,7 @@ export function generateFilesFromNativeOpenAPI( } export function supportsCompleteNativeRender(options: GenerateOptions) { - return ( - options.splitByTags && - !options.inlineEndpoints && - !options.builderConfigs && - (options.workspaceContext?.length ?? 0) === 0 - ); + return options.splitByTags && !options.inlineEndpoints && (options.workspaceContext?.length ?? 0) === 0; } function taggedFile(options: GenerateOptions, tag: string, type: GenerateType, content: string) { diff --git a/test/config.ts b/test/config.ts index 531b4c2..82af2cc 100644 --- a/test/config.ts +++ b/test/config.ts @@ -5,7 +5,6 @@ export const config: OpenAPICodegenConfig = { output: "./output", includeTags: ["auth"], replaceOptionalWithNullish: true, - builderConfigs: true, infiniteQueries: true, }; From dbfcaadf51275ecdcb1eacf01c0632f52d789864 Mon Sep 17 00:00:00 2001 From: Urban Krepel Date: Thu, 22 Oct 2026 15:52:01 +0200 Subject: [PATCH 11/17] fix: type bare InvalidationMap and nested mutation variables A bare `InvalidationMap` (no type arguments, as the TOS FE declares it) was keyed by any module key, so it never matched the registered mutation types and callbacks still got `unknown`. - queryModules.ts also registers its QueryModule enum globally (OpenApiCodegenQueryModules), and InvalidationMap's default module type is that enum (RegisteredQueryModule), falling back to any key. - Variables merge at every depth: request-body fields only some mutations send become optional (`variables?.data?.projectId`). - Common keys are mapped through Extract; a homomorphic `keyof T` mapping distributed over the union and skipped the merge. Checked against the TOS FE's invalidationMap.ts: 16 errors -> 4, all in one callback that names its first parameter `variables` although it is `data`. Co-Authored-By: Claude Opus 5.5 --- native/src/render.rs | 3 +- src/config.ts | 1 + .../generate/generateQueryModules.ts | 3 +- .../generated-invalidation-map.test.ts | 11 ++++-- src/index.ts | 1 + src/lib/config/queryConfig.context.tsx | 38 ++++++++++++++----- src/query.ts | 1 + 7 files changed, 44 insertions(+), 14 deletions(-) diff --git a/native/src/render.rs b/native/src/render.rs index b1038c9..c6368dc 100644 --- a/native/src/render.rs +++ b/native/src/render.rs @@ -2148,8 +2148,9 @@ fn render_query_modules(endpoints: &[Value], options: &GenerateOptions) -> Strin } lines.push("}".into()); lines.push(String::new()); - // Registers the types globally, so `InvalidationMap` picks them up without naming them. + // Registers the types globally, so a bare `InvalidationMap` is keyed and typed by this client. lines.push("declare global {".into()); + lines.push(" interface OpenApiCodegenQueryModules { queryModule: QueryModule }".into()); lines.push( " interface OpenApiCodegenQueryModuleMutations extends QueryModuleMutations {}".into(), ); diff --git a/src/config.ts b/src/config.ts index f213e8e..43721d2 100644 --- a/src/config.ts +++ b/src/config.ts @@ -7,6 +7,7 @@ export type { MutationEffectsOf, MutationEffectsTypes, QueryModule, + RegisteredQueryModule, } from "./lib/config/queryConfig.context"; export { OpenApiWorkspaceContext, useWorkspaceContext } from "./lib/config/workspace.context"; export { configureTranslations, ns, resources } from "./lib/config/i18n"; diff --git a/src/generators/generate/generateQueryModules.ts b/src/generators/generate/generateQueryModules.ts index ecf04e6..42be263 100644 --- a/src/generators/generate/generateQueryModules.ts +++ b/src/generators/generate/generateQueryModules.ts @@ -58,8 +58,9 @@ export function generateQueryModules({ resolver, data }: Omit` picks them up without naming them. + // Registers the types globally, so a bare `InvalidationMap` is keyed and typed by this client. lines.push("declare global {"); + lines.push(" interface OpenApiCodegenQueryModules { queryModule: QueryModule }"); lines.push(" interface OpenApiCodegenQueryModuleMutations extends QueryModuleMutations {}"); lines.push("}"); return lines.join("\n"); diff --git a/src/generators/generated-invalidation-map.test.ts b/src/generators/generated-invalidation-map.test.ts index 96f2d4a..7f8798a 100644 --- a/src/generators/generated-invalidation-map.test.ts +++ b/src/generators/generated-invalidation-map.test.ts @@ -14,18 +14,23 @@ import { GenerateOptions } from "./types/options"; const root = process.cwd(); // Typed against the generated client: `petId` exists on some pet mutations, `username` on some user ones. -// `InvalidationMap` gets the types through the generated global registration. +// A bare `InvalidationMap` gets the module keys and types through the generated global registration. const invalidationMap = ` -import type { InvalidationMap } from "@povio/openapi-codegen-cli/query"; +import type { InvalidationMap, MergedMutationVariables } from "@povio/openapi-codegen-cli/query"; import { QueryModule, type QueryModuleMutations } from "./data/queryModules"; -export const invalidationMap: InvalidationMap = { +export const invalidationMap: InvalidationMap = { [QueryModule.pet]: (data, { petId }) => [["pet", petId, data]], [QueryModule.user]: (_data, variables) => [["user", variables.username]], }; +export const withModule: InvalidationMap = invalidationMap; export const explicitMutations: InvalidationMap = invalidationMap; +// Request-body fields only some mutations send are optional too. +declare const nested: MergedMutationVariables<{ data: { projectId: string } } | { id: string; data: { name: string } }>; +export const projectId: string | undefined = nested.data.projectId; + export const unknownVariable: InvalidationMap = { // @ts-expect-error -- no pet mutation has \`nope\` [QueryModule.pet]: (_data, { nope }) => [[nope]], diff --git a/src/index.ts b/src/index.ts index 0307201..f840b11 100644 --- a/src/index.ts +++ b/src/index.ts @@ -42,6 +42,7 @@ export type { MutationEffectsOf, MutationEffectsTypes, QueryModule, + RegisteredQueryModule, } from "./lib/config/queryConfig.context"; export { OpenApiWorkspaceContext, useWorkspaceContext } from "./lib/config/workspace.context"; diff --git a/src/lib/config/queryConfig.context.tsx b/src/lib/config/queryConfig.context.tsx index df76789..1173c3b 100644 --- a/src/lib/config/queryConfig.context.tsx +++ b/src/lib/config/queryConfig.context.tsx @@ -26,13 +26,22 @@ export type MutationEffectsOf = THook extends ( type ObjectMembers = T extends object ? T : never; type KeysOfUnion = T extends unknown ? keyof T : never; type ValueOfUnion = T extends unknown ? (K extends keyof T ? T[K] : undefined) : never; -type MergedObjects = { [K in keyof T]: ValueOfUnion } & { - [K in Exclude, keyof T>]?: ValueOfUnion; +type IsUnion = T extends unknown ? ([TAll] extends [T] ? false : true) : never; +// Objects whose fields merge; arrays, functions and dates are kept as they are. +type MergeableMembers = T extends readonly unknown[] | ((...args: never) => unknown) | Date + ? never + : ObjectMembers; +type MergedValue = + true extends IsUnion> ? MergedObjects> | Exclude> : T; +// `Extract` rather than `keyof T`: a homomorphic mapped type would map each union member separately. +type MergedObjects = { [K in Extract]: MergedValue> } & { + [K in Exclude, keyof T>]?: MergedValue>; }; /** - * The variables of a module's mutations as one object: keys some mutations lack become optional. - * Mutations without variables (`void`) stay in the union, as their callbacks receive `undefined`. + * The variables of a module's mutations as one object: keys some mutations lack become optional, at any + * depth (e.g. fields of request bodies only some mutations send). Mutations without variables (`void`) + * stay in the union, as their callbacks receive `undefined`. */ export type MergedMutationVariables = [ObjectMembers] extends [never] ? T @@ -43,10 +52,6 @@ type InvalidationMapFuncFor = InvalidationMapFunc< MergedMutationVariables >; -/** - * Query keys to invalidate after a module's mutations. Pass the generated `QueryModuleMutations` as - * `TMutations` to type each module's callback with its mutations' `data` and `variables`. - */ declare global { /** * Mutation types per module. The generated `queryModules.ts` extends it with `QueryModuleMutations`, so @@ -55,10 +60,25 @@ declare global { */ // oxlint-disable-next-line typescript/no-empty-interface, typescript/no-empty-object-type -- extended by generated code interface OpenApiCodegenQueryModuleMutations {} + + /** The generated `QueryModule` enum, registered by `queryModules.ts` as `{ queryModule: QueryModule }`. */ + // oxlint-disable-next-line typescript/no-empty-interface, typescript/no-empty-object-type -- extended by generated code + interface OpenApiCodegenQueryModules {} } +/** The generated `QueryModule` enum once `queryModules.ts` registers it, else any module key. */ +export type RegisteredQueryModule = OpenApiCodegenQueryModules extends { + queryModule: infer TModule extends QueryModule; +} + ? TModule + : QueryModule; + +/** + * Query keys to invalidate after a module's mutations. Without type arguments it is keyed by the generated + * `QueryModule` and typed by its `QueryModuleMutations`, so a bare `InvalidationMap` is typed from the spec. + */ export type InvalidationMap< - TQueryModule extends QueryModule = QueryModule, + TQueryModule extends QueryModule = RegisteredQueryModule, TMutations extends { [K in TQueryModule]?: MutationEffectsTypes } = OpenApiCodegenQueryModuleMutations, > = { [K in TQueryModule]?: K extends keyof TMutations ? InvalidationMapFuncFor : InvalidationMapFunc; diff --git a/src/query.ts b/src/query.ts index d465577..22e628c 100644 --- a/src/query.ts +++ b/src/query.ts @@ -9,4 +9,5 @@ export type { MutationEffectsOf, MutationEffectsTypes, QueryModule, + RegisteredQueryModule, } from "./lib/config/queryConfig.context"; From 67745e90e095a255b688608c6ab48cb2e0d64daa Mon Sep 17 00:00:00 2001 From: Urban Krepel Date: Fri, 23 Oct 2026 07:14:59 +0200 Subject: [PATCH 12/17] chore: bump Bun to 1.4.4 packageManager, engines, .tool-versions, @types/bun and the CI workflows. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/check.yml | 2 +- .github/workflows/publish.yml | 4 ++-- .github/workflows/renderer-parity.yml | 4 ++-- .tool-versions | 2 +- bun.lock | 6 +++--- package.json | 6 +++--- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 2266213..bb59b7e 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -20,7 +20,7 @@ jobs: - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: 1.4.3 + bun-version: 1.4.4 - uses: dtolnay/rust-toolchain@stable diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d003c49..01722ce 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -22,7 +22,7 @@ jobs: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: 1.4.3 + bun-version: 1.4.4 - uses: dtolnay/rust-toolchain@stable - run: bun install --frozen-lockfile - run: bun run build:native @@ -43,7 +43,7 @@ jobs: - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: 1.4.3 + bun-version: 1.4.4 - run: bun install --frozen-lockfile - name: Set package version from tag diff --git a/.github/workflows/renderer-parity.yml b/.github/workflows/renderer-parity.yml index f86dc58..ef0f53a 100644 --- a/.github/workflows/renderer-parity.yml +++ b/.github/workflows/renderer-parity.yml @@ -23,7 +23,7 @@ jobs: - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: 1.4.3 + bun-version: 1.4.4 - uses: dtolnay/rust-toolchain@stable if: matrix.renderer == 'native' @@ -63,7 +63,7 @@ jobs: - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: 1.4.3 + bun-version: 1.4.4 - run: bun install --frozen-lockfile diff --git a/.tool-versions b/.tool-versions index 333cc5b..3688e5c 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1 +1 @@ -bun 1.4.3 +bun 1.4.4 diff --git a/bun.lock b/bun.lock index 0296ad1..ac47a49 100644 --- a/bun.lock +++ b/bun.lock @@ -18,7 +18,7 @@ "@casl/ability": "6.8.0", "@casl/react": "5.0.1", "@tanstack/react-query": "~5.90.21", - "@types/bun": "1.4.3", + "@types/bun": "1.4.4", "@types/node": "^25.3.0", "@types/prompt-sync": "^4.2.3", "@types/react": "^19.2.14", @@ -329,7 +329,7 @@ "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], - "@types/bun": ["@types/bun@1.4.3", "", { "dependencies": { "bun-types": "1.4.3" } }, "sha512-N67Pqy8JXbAY/j0tNraS3V5mubGvc6rrrNDmDCtZLlFzccbUJPDb0tvgUBC5q4m8ybkP93Jcn1QB+RYQ6PvBhw=="], + "@types/bun": ["@types/bun@1.4.4", "", { "dependencies": { "bun-types": "1.4.4" } }, "sha512-9wkRDXUXK124pEnAutE6n/l18j+i9GC5IAHK8TmX/2nlySETmky0OU6yL5U8pW1AoKh++AN9auDCr647BfurjQ=="], "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], @@ -511,7 +511,7 @@ "browserslist": ["browserslist@4.28.8", "", { "dependencies": { "baseline-browser-mapping": "^2.11.12", "caniuse-lite": "^1.0.30001809", "electron-to-chromium": "^1.5.402", "node-releases": "^2.0.53", "update-browserslist-db": "^1.3.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA=="], - "bun-types": ["bun-types@1.4.3", "", { "dependencies": { "@types/node": "*", "undici-types": "*" } }, "sha512-tinRJNGOmUN+tEAsn2y2e942n9JHRHR42iUYzDH75NmhJqVBokGSQkkfSFdNWwsANIdrK6ACH9t01M452YyTwg=="], + "bun-types": ["bun-types@1.4.4", "", { "dependencies": { "@types/node": "*", "undici-types": "*" } }, "sha512-yjrJh5oGX7mMPKGd19YXyIjmEuj4G4zNC30bs7PM9Az7NQ3ndF3j6hbeRdvJTRQEhJ/FYQwW0a2moweEi2hMxQ=="], "cac": ["cac@7.0.0", "", {}, "sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ=="], diff --git a/package.json b/package.json index 9b18354..4bac1a0 100644 --- a/package.json +++ b/package.json @@ -128,7 +128,7 @@ "@casl/ability": "6.8.0", "@casl/react": "5.0.1", "@tanstack/react-query": "~5.90.21", - "@types/bun": "1.4.3", + "@types/bun": "1.4.4", "@types/node": "^25.3.0", "@types/prompt-sync": "^4.2.3", "@types/react": "^19.2.14", @@ -183,8 +183,8 @@ } }, "engines": { - "bun": ">= 1.4.3", + "bun": ">= 1.4.4", "node": ">= 18" }, - "packageManager": "bun@1.4.3" + "packageManager": "bun@1.4.4" } From 79383cd51027eab9614500671a3aca85859549ff Mon Sep 17 00:00:00 2001 From: Urban Krepel Date: Fri, 23 Oct 2026 07:25:32 +0200 Subject: [PATCH 13/17] fix: type lazySchemas render helpers with PropertyRefNames Merging main brought in #105, whose lazy-schema helpers still took the property reference names as `&HashMap<&str, String>`. This branch replaced that map with `PropertyRefNames` (built once per render), so the native crate didn't compile on the PR's merge ref. Co-Authored-By: Claude Opus 5.5 --- native/src/render.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/native/src/render.rs b/native/src/render.rs index 06c0823..10bd615 100644 --- a/native/src/render.rs +++ b/native/src/render.rs @@ -590,7 +590,7 @@ fn render_schema_docs<'a>( schema_refs: &Map, generated_objects: &'a Map, enum_objects: &'a HashMap, - property_ref_names: &HashMap<&str, String>, + property_ref_names: &PropertyRefNames, ) -> (Vec, Option<&'a Value>) { let schema_object = generated_objects .get(name) @@ -656,7 +656,7 @@ fn render_lazy_common_models( enum_objects: &HashMap, circular_schemas: &[String], suffix: &str, - property_ref_names: &HashMap<&str, String>, + property_ref_names: &PropertyRefNames, namespace: &str, mut lines: Vec, options: &GenerateOptions, @@ -723,7 +723,7 @@ fn render_lazy_schema_parts( enum_objects: &HashMap, circular_schemas: &[String], suffix: &str, - property_ref_names: &HashMap<&str, String>, + property_ref_names: &PropertyRefNames, schema_names: &HashSet<&str>, namespace: &str, options: &GenerateOptions, From e68cca051bce2b4438fdac13991c19d6fc29adac Mon Sep 17 00:00:00 2001 From: Urban Krepel Date: Fri, 23 Oct 2026 07:57:43 +0200 Subject: [PATCH 14/17] fix: merge invalidation callback data and treat void as undefined Callback `data` was the union of a module's mutation responses, and `variables` kept `void` for mutations without variables. Both made callback parameters unions of distinct types, which strict lint rules (e.g. TOS's single-value-type check) reject and which forced narrowing before reading a field. - `data` merges like `variables` (MergedMutationData): fields only some responses have become optional, at any depth. - `void` becomes `undefined`, which is what callbacks receive for mutations without variables or response body. Checked against TOS: the variable-type check reports 0 violations, FE tsc and all FE tests pass. Co-Authored-By: Claude Opus 5.5 --- src/config.ts | 2 ++ .../generated-invalidation-map.test.ts | 7 +++++- src/index.ts | 2 ++ src/lib/config/queryConfig.context.tsx | 23 +++++++++++++------ src/query.ts | 2 ++ 5 files changed, 28 insertions(+), 8 deletions(-) diff --git a/src/config.ts b/src/config.ts index 43721d2..4facb01 100644 --- a/src/config.ts +++ b/src/config.ts @@ -3,6 +3,8 @@ export { OpenApiQueryConfig } from "./lib/config/queryConfig.context"; export type { InvalidationMap, InvalidationMapFunc, + MergedMutationData, + MergedMutationValues, MergedMutationVariables, MutationEffectsOf, MutationEffectsTypes, diff --git a/src/generators/generated-invalidation-map.test.ts b/src/generators/generated-invalidation-map.test.ts index 7f8798a..384ee1d 100644 --- a/src/generators/generated-invalidation-map.test.ts +++ b/src/generators/generated-invalidation-map.test.ts @@ -16,7 +16,7 @@ const root = process.cwd(); // Typed against the generated client: `petId` exists on some pet mutations, `username` on some user ones. // A bare `InvalidationMap` gets the module keys and types through the generated global registration. const invalidationMap = ` -import type { InvalidationMap, MergedMutationVariables } from "@povio/openapi-codegen-cli/query"; +import type { InvalidationMap, MergedMutationData, MergedMutationVariables } from "@povio/openapi-codegen-cli/query"; import { QueryModule, type QueryModuleMutations } from "./data/queryModules"; export const invalidationMap: InvalidationMap = { @@ -31,6 +31,11 @@ export const explicitMutations: InvalidationMap; export const projectId: string | undefined = nested.data.projectId; +// Responses merge the same way, and \`void\` (no variables or body) becomes \`undefined\`. +declare const response: MergedMutationData<{ data: { id: string } } | { count: number } | void>; +export const responseCount: number | undefined = response?.count; +export const noResponse: MergedMutationData = undefined; + export const unknownVariable: InvalidationMap = { // @ts-expect-error -- no pet mutation has \`nope\` [QueryModule.pet]: (_data, { nope }) => [[nope]], diff --git a/src/index.ts b/src/index.ts index f840b11..0532f10 100644 --- a/src/index.ts +++ b/src/index.ts @@ -38,6 +38,8 @@ export { OpenApiQueryConfig } from "./lib/config/queryConfig.context"; export type { InvalidationMap, InvalidationMapFunc, + MergedMutationData, + MergedMutationValues, MergedMutationVariables, MutationEffectsOf, MutationEffectsTypes, diff --git a/src/lib/config/queryConfig.context.tsx b/src/lib/config/queryConfig.context.tsx index 1173c3b..ba6a233 100644 --- a/src/lib/config/queryConfig.context.tsx +++ b/src/lib/config/queryConfig.context.tsx @@ -38,17 +38,26 @@ type MergedObjects = { [K in Extract]: MergedValue, keyof T>]?: MergedValue>; }; +// `void` (a mutation without variables or response body) is `undefined` at runtime. +type VoidAsUndefined = [void] extends [T] ? Exclude | undefined : T; + /** - * The variables of a module's mutations as one object: keys some mutations lack become optional, at any - * depth (e.g. fields of request bodies only some mutations send). Mutations without variables (`void`) - * stay in the union, as their callbacks receive `undefined`. + * A union of a module's mutation values as one object: keys some mutations lack become optional, at any + * depth (e.g. fields of request bodies only some mutations send). `void` becomes `undefined`, which is + * what callbacks receive for mutations without one. */ -export type MergedMutationVariables = [ObjectMembers] extends [never] - ? T - : MergedObjects> | Exclude; +export type MergedMutationValues = [ObjectMembers] extends [never] + ? VoidAsUndefined + : MergedObjects> | VoidAsUndefined>; + +/** The variables of a module's mutations as one object; see `MergedMutationValues`. */ +export type MergedMutationVariables = MergedMutationValues; + +/** The responses of a module's mutations as one object; see `MergedMutationValues`. */ +export type MergedMutationData = MergedMutationValues; type InvalidationMapFuncFor = InvalidationMapFunc< - T extends MutationEffectsTypes ? T["data"] : never, + MergedMutationData, MergedMutationVariables >; diff --git a/src/query.ts b/src/query.ts index 22e628c..5bbafc7 100644 --- a/src/query.ts +++ b/src/query.ts @@ -5,6 +5,8 @@ export { OpenApiQueryConfig } from "./lib/config/queryConfig.context"; export type { InvalidationMap, InvalidationMapFunc, + MergedMutationData, + MergedMutationValues, MergedMutationVariables, MutationEffectsOf, MutationEffectsTypes, From bfe8858c8d8128a11070eb67203c87d729a6bad2 Mon Sep 17 00:00:00 2001 From: Urban Krepel Date: Fri, 23 Oct 2026 08:15:51 +0200 Subject: [PATCH 15/17] fix: remove trailing whitespace from generated files Templates left spaces at line ends: JSDoc openers (`/** `), `@property` lines with an empty description, `mutationFn: (...) => { ` and indented blank lines. Formatters such as `bun fmt` keep whitespace inside comments, so consumers saw thousands of whitespace-only lines. Both renderers' files now go through trimTrailingWhitespace before they are returned, which also covers descriptions taken from the spec. A test checks petstore and the configuration fixture with both renderers. Co-Authored-By: Claude Opus 5.5 --- .../generate/generateQueries.test.ts | 2 +- src/generators/generateCodeFromOpenAPIDoc.ts | 3 +- .../generated-trailing-whitespace.test.ts | 34 +++++++++++++++++++ src/generators/utils/string.utils.ts | 3 ++ src/native/generateFilesFromNativeOpenAPI.ts | 3 +- 5 files changed, 42 insertions(+), 3 deletions(-) create mode 100644 src/generators/generated-trailing-whitespace.test.ts diff --git a/src/generators/generate/generateQueries.test.ts b/src/generators/generate/generateQueries.test.ts index 658dc37..120b5aa 100644 --- a/src/generators/generate/generateQueries.test.ts +++ b/src/generators/generate/generateQueries.test.ts @@ -324,7 +324,7 @@ describe("generateQueries workspaceContext", () => { expect(queriesFile?.content).toContain( "const { officeId: officeIdWorkspace } = useWorkspaceContext<{ officeId?: string }>();", ); - expect(queriesFile?.content).toContain("mutationFn: ({ officeId, data }) => { "); + expect(queriesFile?.content).toContain("mutationFn: ({ officeId, data }) => {"); expect(queriesFile?.content).toContain("const normalizeOfficeId = officeId ?? officeIdWorkspace;"); expect(queriesFile?.content).toContain("return WorkspaceApi.createPosition(normalizeOfficeId, data)"); }); diff --git a/src/generators/generateCodeFromOpenAPIDoc.ts b/src/generators/generateCodeFromOpenAPIDoc.ts index 85b09e5..fcb8c2b 100644 --- a/src/generators/generateCodeFromOpenAPIDoc.ts +++ b/src/generators/generateCodeFromOpenAPIDoc.ts @@ -15,6 +15,7 @@ import { getZodExtendedFiles, } from "./utils/generate-files.utils"; import { getTagFileName } from "./utils/generate/generate.utils"; +import { trimTrailingWhitespace } from "./utils/string.utils"; import { shouldInlineEndpointsForTag } from "./utils/tag.utils"; import { Profiler } from "../helpers/profile.helper"; import { getDataFromNativeOpenAPIDoc } from "../native/getDataFromNativeOpenAPIDoc"; @@ -81,5 +82,5 @@ export function generateCodeFromOpenAPIDoc( ); } - return generateFilesData; + return generateFilesData.map((file) => ({ ...file, content: trimTrailingWhitespace(file.content) })); } diff --git a/src/generators/generated-trailing-whitespace.test.ts b/src/generators/generated-trailing-whitespace.test.ts new file mode 100644 index 0000000..fb889a1 --- /dev/null +++ b/src/generators/generated-trailing-whitespace.test.ts @@ -0,0 +1,34 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, test } from "vitest"; +import { parse } from "yaml"; + +import { generateFilesFromNativeOpenAPI } from "../native/generateFilesFromNativeOpenAPI"; + +import { DEFAULT_GENERATE_OPTIONS } from "./const/options.const"; +import { generateCodeFromOpenAPIDoc } from "./generateCodeFromOpenAPIDoc"; +import { GenerateOptions } from "./types/options"; + +// Formatters such as `bun fmt` keep whitespace inside comments, so generated files must not contain any. +describe("generated files have no trailing whitespace", () => { + test.each( + ["test/petstore.yaml", "test/configuration.yaml"].flatMap((input) => + [{}, { tsNamespaces: false }, { modelsInCommon: true, checkAcl: true, infiniteQueries: true }].flatMap( + (overrides) => (["typescript", "native"] as const).map((backend) => ({ input, overrides, backend })), + ), + ), + )("$input ($backend, $overrides)", ({ input, overrides, backend }) => { + const source = readFileSync(input, "utf8"); + const options: GenerateOptions = { ...DEFAULT_GENERATE_OPTIONS, output: "output", ...overrides }; + const files = + backend === "typescript" + ? generateCodeFromOpenAPIDoc(parse(source), options) + : generateFilesFromNativeOpenAPI(source, true, options); + expect(files?.length).toBeGreaterThan(0); + const linesWithTrailingWhitespace = (files ?? []).flatMap(({ fileName, content }) => + content + .split("\n") + .flatMap((line, index) => (/[ \t]$/.test(line) ? [`${fileName}:${index + 1}: ${JSON.stringify(line)}`] : [])), + ); + expect(linesWithTrailingWhitespace).toEqual([]); + }); +}); diff --git a/src/generators/utils/string.utils.ts b/src/generators/utils/string.utils.ts index 824e1a8..9dbec29 100644 --- a/src/generators/utils/string.utils.ts +++ b/src/generators/utils/string.utils.ts @@ -64,3 +64,6 @@ export const removeWord = (source: string, wordToRemove: string) => { }; export const isString = (str: string | undefined): str is string => str !== undefined; + +/** Removes spaces and tabs at the end of every line (formatters such as `bun fmt` keep them in comments). */ +export const trimTrailingWhitespace = (text: string) => text.replace(/[ \t]+$/gm, ""); diff --git a/src/native/generateFilesFromNativeOpenAPI.ts b/src/native/generateFilesFromNativeOpenAPI.ts index 4233be9..5ba78e8 100644 --- a/src/native/generateFilesFromNativeOpenAPI.ts +++ b/src/native/generateFilesFromNativeOpenAPI.ts @@ -2,6 +2,7 @@ import { GenerateFileData, GenerateType } from "@/generators/types/generate"; import { GenerateOptions } from "@/generators/types/options"; import { getOutputFileName } from "@/generators/utils/file.utils"; import { getTagFileName } from "@/generators/utils/generate/generate.utils"; +import { trimTrailingWhitespace } from "@/generators/utils/string.utils"; import { compileNativeData } from "./native-bindings"; @@ -80,7 +81,7 @@ export function generateFilesFromNativeOpenAPI( } } - return files; + return files.map((file) => ({ ...file, content: trimTrailingWhitespace(file.content) })); } export function supportsCompleteNativeRender(options: GenerateOptions) { From bedc6e09a045d87184bf06fba1b8c8d5d7f15a1f Mon Sep 17 00:00:00 2001 From: Urban Krepel Date: Fri, 23 Oct 2026 09:34:17 +0200 Subject: [PATCH 16/17] fix(native): match the TypeScript renderer on large specs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a 733-path production spec, the native renderer's output differed from the TypeScript renderer's in 66 of 614 files with the default options, and in many more with the frontend options. Both option sets are now byte-identical. - Inline enums with a component enum's values are named by that enum and listed with the other component enums, after the endpoint schemas. Enum codes leave out `null`, both in the resolver's dependency edges and when model imports are collected. - Query and body `@param` docs list every schema constraint, plus the media type's example. - Model imports in namespace mode follow each owner's first use. With `modelsInCommon`, imports go through the module's proxy. Queries import the native transport types before the upload client. - Top-level array schemas document their items' properties (`[0].x`). - Extracted enums keep the owner their component usages gave them. With `modelsInModules` they go to each of those modules, and proxies no longer re-export enums used only by generated schemas. - Recursive properties use `z.lazy(() => …)`, like the TypeScript renderer since #91. `additionalProperties: false` adds no index signature. - A generated schema's dependencies also follow refs that are already registered, and same-valued component enums when `extractEnums` is on. test/renderer-regressions.yaml reproduces each case with neutral names. It joins the parity fixtures, so every configuration scenario checks it. Co-Authored-By: Claude Opus 5.5 --- native/src/config.rs | 5 +- native/src/endpoints.rs | 81 +++++-- native/src/lib.rs | 16 ++ native/src/render.rs | 187 +++++++++++----- native/src/resolver.rs | 9 +- native/src/zod.rs | 58 ++++- scripts/renderer-parity-cases.ts | 1 + test/renderer-regressions.yaml | 370 +++++++++++++++++++++++++++++++ 8 files changed, 645 insertions(+), 82 deletions(-) create mode 100644 test/renderer-regressions.yaml diff --git a/native/src/config.rs b/native/src/config.rs index d23b874..c350c4d 100644 --- a/native/src/config.rs +++ b/native/src/config.rs @@ -173,6 +173,9 @@ mod runtime_import_tests { #[test] fn mutation_effects_default_uses_query_subpath() { let options: super::GenerateOptions = serde_json::from_str("{}").unwrap(); - assert_eq!(options.mutation_effects_import_path, "@povio/openapi-codegen-cli/query"); + assert_eq!( + options.mutation_effects_import_path, + "@povio/openapi-codegen-cli/query" + ); } } diff --git a/native/src/endpoints.rs b/native/src/endpoints.rs index b71f586..b09692f 100644 --- a/native/src/endpoints.rs +++ b/native/src/endpoints.rs @@ -102,11 +102,10 @@ impl<'a> EndpointExtractor<'a> { } fn generated_dependency_names_from_references(&self, references: &[String]) -> Vec { - let registered = self.first_tags.borrow(); let mut names = Vec::new(); let mut expanded = HashSet::default(); for reference in references { - self.add_generated_dependency(reference, ®istered, &mut expanded, &mut names); + self.add_generated_dependency(reference, &mut expanded, &mut names); } names } @@ -130,7 +129,6 @@ impl<'a> EndpointExtractor<'a> { fn add_generated_dependency( &self, reference: &str, - registered: &HashMap, expanded: &mut HashSet, names: &mut Vec, ) { @@ -141,10 +139,14 @@ impl<'a> EndpointExtractor<'a> { if !names.contains(&name) { names.push(name); } - if registered.contains_key(reference) || !expanded.insert(reference.to_string()) { + if !expanded.insert(reference.to_string()) { return; } - if let Some(canonical) = self.canonical_enum_refs.get(reference) { + if let Some(canonical) = self + .canonical_enum_refs + .get(reference) + .filter(|_| self.options.extract_enums) + { let canonical_name = schema_name( canonical.rsplit('/').next().unwrap_or_default(), &self.options.schema_suffix, @@ -155,7 +157,7 @@ impl<'a> EndpointExtractor<'a> { } if let Some(children) = self.resolver.ordered_dependencies.get(reference) { for child in children { - self.add_generated_dependency(child, registered, expanded, names); + self.add_generated_dependency(child, expanded, names); } } } @@ -297,14 +299,23 @@ impl<'a> EndpointExtractor<'a> { .compile_endpoint_schema(schema, true, &indexed.tag)?; let fallback = response_schema_name(status_code, &indexed.name, unique_name, &indexed.tag); + let named = naming_schema(&self.compiler, schema); + if let Some(reference) = named + .as_ref() + .and_then(|named| named.get("$ref")) + .and_then(Value::as_str) + { + self.register_reference_usage(reference, &indexed.tag); + } + let naming = named.as_ref().unwrap_or(schema); let resolved = resolve_schema_name( - schema, + naming, schema_object, code.clone(), Some(&fallback), self.options, ); - if schema.get("$ref").is_none() && complexity(schema_object) >= 2 { + if naming.get("$ref").is_none() && complexity(schema_object) >= 2 { self.set_generated_schema(resolved.clone(), code, &indexed.tag); self.set_generated_object(&resolved, schema_object); self.set_generated_dependencies(&resolved, dependencies); @@ -582,9 +593,18 @@ impl<'a> EndpointExtractor<'a> { }; let fallback = snake_to_camel(&format!("{operation_name}_Body")); let actual = self.resolve(schema).unwrap_or(schema); + let named = naming_schema(&self.compiler, schema); + if let Some(reference) = named + .as_ref() + .and_then(|named| named.get("$ref")) + .and_then(Value::as_str) + { + self.register_reference_usage(reference, &indexed.tag); + } + let naming = named.as_ref().unwrap_or(schema); let resolved = - resolve_schema_name(schema, actual, code.clone(), Some(&fallback), self.options); - if schema.get("$ref").is_none() && complexity(actual) >= 2 { + resolve_schema_name(naming, actual, code.clone(), Some(&fallback), self.options); + if naming.get("$ref").is_none() && complexity(actual) >= 2 { self.set_generated_schema(resolved.clone(), code, &indexed.tag); self.set_generated_object(&resolved, actual); self.set_generated_dependencies(&resolved, dependencies); @@ -703,14 +723,23 @@ impl<'a> EndpointExtractor<'a> { chain }; let composed_code = format!("{code}{effective_chain}"); + let named = naming_schema(&self.compiler, schema); + if let Some(reference) = named + .as_ref() + .and_then(|named| named.get("$ref")) + .and_then(Value::as_str) + { + self.register_reference_usage(reference, &indexed.tag); + } + let naming = named.as_ref().unwrap_or(schema); let zod = resolve_schema_name( - schema, + naming, actual, composed_code.clone(), Some(&fallback), self.options, ); - if schema.get("$ref").is_none() && complexity(actual) >= 2 { + if naming.get("$ref").is_none() && complexity(actual) >= 2 { self.set_generated_schema(zod.clone(), composed_code, &indexed.tag); self.set_generated_object(&zod, actual); self.set_generated_dependencies(&zod, dependencies); @@ -770,6 +799,21 @@ impl<'a> EndpointExtractor<'a> { self.document.pointer(reference.strip_prefix('#')?) } + /// Records that `tag` uses the component enum `reference` without generating its schema here, as the + /// TypeScript renderer does for inline enums it names by a matching component enum: the enum is then + /// emitted with the other component enums, after the endpoint schemas. + fn register_reference_usage(&self, reference: &str, tag: &str) { + self.runtime_tags + .borrow_mut() + .entry(reference.to_string()) + .or_default() + .insert(tag.to_string()); + self.first_tags + .borrow_mut() + .entry(reference.to_string()) + .or_insert_with(|| tag.to_string()); + } + fn register_reference_schema(&self, schema: &Value, tag: &str) -> Result<(), String> { let Some(reference) = schema.get("$ref").and_then(Value::as_str) else { return Ok(()); @@ -919,6 +963,17 @@ fn canonical_enum_refs(document: &Value) -> HashMap { result } +/// A schema to name an endpoint schema by: an inline enum matching a component enum names like its `$ref`. +fn naming_schema(compiler: &ZodCompiler, schema: &Value) -> Option { + compiler + .matching_component_enum_ref(schema) + .map(|reference| { + let mut object = Map::new(); + object.insert("$ref".into(), Value::String(reference)); + Value::Object(object) + }) +} + fn resolve_schema_name( original: &Value, actual: &Value, @@ -1058,7 +1113,7 @@ fn capitalize(value: &str) -> String { fn is_success(status: u16) -> bool { (200..300).contains(&status) } -fn is_parameter_media(name: &str) -> bool { +pub(crate) fn is_parameter_media(name: &str) -> bool { (name.contains("application/") && name.contains("json")) || name.contains("text/") || matches!( diff --git a/native/src/lib.rs b/native/src/lib.rs index d95143e..f2e7322 100644 --- a/native/src/lib.rs +++ b/native/src/lib.rs @@ -339,7 +339,23 @@ pub fn compile_data(source: String, yaml: bool, options_json: String) -> Result< } } } + // Like the TypeScript renderer, extracted enums keep the owner their component usages gave them, + // and with `modelsInModules` go to the modules of all those usages. + let extracted_tags = compiler.extracted_schema_tags(); + if options.models_in_modules { + for (name, tags) in compiler.extracted_schema_usage_tags() { + let tags = if tags.is_empty() { + extracted_tags.get(name).cloned().into_iter().collect() + } else { + tags.iter().cloned().collect() + }; + usage_tags.insert(name.clone(), tags); + } + } for (name, tags) in &usage_tags { + if extracted_tags.contains_key(name) { + continue; + } let owner = if tags.len() == 1 { tags.iter() .next() diff --git a/native/src/render.rs b/native/src/render.rs index 10bd615..12c1c81 100644 --- a/native/src/render.rs +++ b/native/src/render.rs @@ -157,6 +157,16 @@ fn render_local_models( enum_name_by_code.entry(code).or_insert(name.as_str()); } } + // Inline enums with a component enum's values are rendered as that enum. + if options.extract_enums { + for (name, value) in schemas { + if let Some(code) = value.as_str().filter(|code| code.starts_with("z.enum(")) + && schema_refs.contains_key(name) + { + enum_name_by_code.entry(code).or_insert(name.as_str()); + } + } + } for (tag, mut tag_schemas) in by_tag { let mut imports: IndexMap> = IndexMap::default(); for schema_name in tag_schemas.keys() { @@ -177,7 +187,10 @@ fn render_local_models( } } } - if let Some(schema) = resolve_document_ref(document, reference) { + // An enum component declares its values; it does not use another enum. + if let Some(schema) = resolve_document_ref(document, reference) + .filter(|schema| schema.get("enum").is_none()) + { let mut enum_codes = Vec::new(); collect_inline_enum_codes(schema, &mut enum_codes); for code in enum_codes { @@ -290,11 +303,7 @@ fn collect_inline_enum_codes(value: &Value, codes: &mut Vec) { .for_each(|value| collect_inline_enum_codes(value, codes)), Value::Object(object) => { if !object.contains_key("$ref") { - if let Some(values) = object.get("enum").and_then(Value::as_array) { - let code = format!( - "z.enum({})", - Value::Array(values.clone()).to_string().replace(',', ", ") - ); + if let Some(code) = crate::zod::enum_code(value) { if !codes.contains(&code) { codes.push(code); } @@ -429,11 +438,20 @@ fn render_proxy_modules( ); } } + // Generated schemas reach components only: JavaScript collects their refs, not the + // extracted enums they use. + let is_component = ref_by_name.contains_key(name.as_str()); if let Some(code) = schemas.get(&name).and_then(Value::as_str) { for candidate in schema_identifiers(code) { + let extracted_enum = !ref_by_name.contains_key(candidate) + && schemas + .get(candidate) + .and_then(Value::as_str) + .is_some_and(|code| code.starts_with("z.enum(")); if candidate != name && schemas.contains_key(candidate) && !direct_names.contains(candidate) + && (is_component || !extracted_enum) { enqueue(candidate, &mut used, &mut seen, &mut queue); } @@ -950,7 +968,11 @@ fn render_schema_type(schema: &Value, suffix: &str, options: &GenerateOptions) - format!("{property_name}{optional}: {property_type}") }) .collect::>(); - if let Some(additional) = schema.get("additionalProperties") { + // Like JavaScript's truthiness check, `false` and `null` add no index signature. + if let Some(additional) = schema + .get("additionalProperties") + .filter(|additional| !matches!(additional, Value::Bool(false) | Value::Null)) + { let value_type = if additional.is_object() { render_schema_type(additional, suffix, options) } else { @@ -1117,6 +1139,19 @@ fn collect_property_docs( properties: &mut IndexMap, property_ref_names: &PropertyRefNames, ) { + if schema.get("type").and_then(Value::as_str) == Some("array") { + if let Some(items) = schema + .get("items") + .filter(|items| items.get("$ref").is_none()) + { + let key = if prefix.is_empty() { + "[0]".into() + } else { + format!("{prefix}.[0]") + }; + collect_property_docs(document, items, &key, properties, property_ref_names); + } + } 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) { @@ -1750,7 +1785,8 @@ fn render_endpoint_module( let mut imports: IndexMap> = IndexMap::default(); for schema in &used_schemas { if is_named_schema(schema) { - let owner = if options.models_in_modules { + // Like JavaScript, shared models are imported through the module's own proxy. + let owner = if options.models_in_modules || options.models_in_common { tag } else { schema_owners @@ -1774,7 +1810,7 @@ fn render_endpoint_module( .filter(|schema| is_named_schema(schema)) { let name = remove_suffix(schema, &options.schema_suffix); - let owner = if options.models_in_modules { + let owner = if options.models_in_modules || options.models_in_common { tag } else { schema_owners @@ -1844,10 +1880,20 @@ fn render_endpoint_module( if options.ts_namespaces { lines.push("}".into()); } + let mut schema_order = used_schemas.clone(); + schema_order.extend(endpoints.iter().flat_map(|endpoint| { + endpoint + .get("parameters") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|parameter| parameter.get("zodSchema").and_then(Value::as_str)) + })); resolve_model_namespace_owners( format!("{}\n", lines.join("\n").trim_end()), tag, model_owners, + &schema_order, options, ) } @@ -2692,7 +2738,7 @@ fn render_acl_module( { if let Some(schema) = condition.get("zodSchemaName").and_then(Value::as_str) { let name = remove_suffix(schema, &options.schema_suffix); - let owner = if options.models_in_modules { + let owner = if options.models_in_modules || options.models_in_common { tag } else { schema_owners @@ -2990,12 +3036,6 @@ fn render_query_module( }) .collect(); let mut lines = Vec::new(); - if uses_native_rest_client(options) && has_media_upload { - lines.push(format!( - "import {{ AppRestClient }} from \"{}\";", - rest_client_import_path(options) - )); - } if uses_native_rest_client(options) { let mut transport_types = Vec::new(); if !queries.is_empty() || options.axios_request_config { @@ -3017,6 +3057,12 @@ fn render_query_module( "import { type AxiosRequestConfig } from \"axios\";".to_string() }); } + if uses_native_rest_client(options) && has_media_upload { + lines.push(format!( + "import {{ AppRestClient }} from \"{}\";", + rest_client_import_path(options) + )); + } let mut query_imports = Vec::new(); if options.prefetch_queries && !queries.is_empty() { query_imports.push("QueryClient"); @@ -3118,7 +3164,7 @@ fn render_query_module( .filter(|schema| is_named_schema(schema)) { let name = remove_suffix(&schema, &options.schema_suffix); - let owner = if options.models_in_modules { + let owner = if options.models_in_modules || options.models_in_common { tag } else { schema_owners @@ -3276,7 +3322,18 @@ fn render_query_module( content = content.replace(&format!("{api_namespace}."), ""); content = content.replace(&format!("{acl_namespace}."), ""); } - resolve_model_namespace_owners(content, tag, model_owners, options) + let schema_order: Vec<&str> = endpoints + .iter() + .flat_map(|endpoint| { + endpoint + .get("parameters") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|parameter| parameter.get("zodSchema").and_then(Value::as_str)) + }) + .collect(); + resolve_model_namespace_owners(content, tag, model_owners, &schema_order, options) } /// Model name (with and without the schema suffix) -> owning tags, in `schema_owners` order. @@ -3325,10 +3382,13 @@ impl<'a> TagModelOwners<'a, '_> { } // Namespace-based modules can reference models shared by several tags. +/// `schema_order` lists the schemas whose models the TypeScript renderer imports, in its order: the +/// imports follow the first use of each owner there. fn resolve_model_namespace_owners( content: String, tag: &str, model_owners: &ModelOwners, + schema_order: &[&str], options: &GenerateOptions, ) -> String { if !options.ts_namespaces || options.models_in_common || options.models_in_modules { @@ -3467,29 +3527,40 @@ fn resolve_model_namespace_owners( } let local_import = format!("import {{ {local_namespace} }} from "); let mut lines = output.lines().map(str::to_string).collect::>(); - let insertion = lines + let local_position = lines .iter() - .position(|line| line.starts_with(&local_import)) + .position(|line| line.starts_with(&local_import)); + let insertion = local_position .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 local_line = local_position + .map(|position| lines.remove(position)) + .filter(|_| has_local); + let mut owner_order = IndexSet::default(); + for schema in schema_order.iter().filter(|schema| is_named_schema(schema)) { + owner_order.insert(owners.get(schema).unwrap_or(tag)); + } + let rank = |owner: &str| owner_order.get_index_of(owner).unwrap_or(usize::MAX); 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) - ), - ); + let mut entries = imports + .into_iter() + .map(|owner| { + let owner_tag = decapitalize(owner); + ( + rank(owner), + format!( + "import {{ {}{namespace_suffix} }} from \"{}{owner_tag}/{owner_tag}.{suffix}\";", + capitalize(owner), + import_root(options) + ), + ) + }) + .collect::>(); + if let Some(line) = local_line { + entries.push((rank(tag), line)); + } + entries.sort_by_key(|(rank, _)| *rank); + for (offset, (_, line)) in entries.into_iter().enumerate() { + lines.insert(insertion + offset, line); } format!("{}\n", lines.join("\n")) } @@ -4056,23 +4127,29 @@ fn parameter_description(parameter: &Value) -> String { { parts.push(description.to_string()); } - let schema = parameter - .pointer("/parameterObject/schema") + let media = parameter + .pointer("/bodyObject/content") + .and_then(Value::as_object) + .and_then(|content| { + content + .iter() + .find(|(name, _)| crate::endpoints::is_parameter_media(name)) + }) + .map(|(_, media)| media); + let schema = media + .and_then(|media| media.get("schema")) + .or_else(|| parameter.pointer("/parameterObject/schema")) .filter(|schema| schema.get("$ref").is_none()); if let Some(schema) = schema { - if let Some(minimum) = schema.get("minimum") { - parts.push(format!("Minimum: `{minimum}`")); - } - if let Some(maximum) = schema.get("maximum") { - parts.push(format!("Maximum: `{maximum}`")); - } - if let Some(default) = schema.get("default") { - parts.push(format!("Default: `{}`", display_json(default))); - } - if let Some(example) = schema.get("example") { - parts.push(format!("Example: `{}`", display_json(example))); + for (key, label) in DESCRIPTION_KEYS { + if let Some(value) = schema.get(*key) { + parts.push(format!("{label}: `{}`", display_js_value(value))); + } } } + if let Some(example) = media.and_then(|media| media.get("example")) { + parts.push(format!("Example: `{}`", display_js_value(example))); + } if parts.is_empty() { String::new() } else { @@ -4080,13 +4157,6 @@ fn parameter_description(parameter: &Value) -> String { } } -fn display_json(value: &Value) -> String { - value - .as_str() - .map(str::to_string) - .unwrap_or_else(|| value.to_string()) -} - fn is_mutation_endpoint(endpoint: &Value) -> bool { endpoint.get("method").and_then(Value::as_str) != Some("get") || endpoint @@ -4633,6 +4703,7 @@ const escapedRegex = z.string().regex(/\/[/]EmailAdminModels.BaseLogLevelEnumSch source.into(), "EmailAdmin", &ModelOwners::new(&owners, &options), + &[], &options, ); assert!(actual.contains("const value = CommonModels.BaseLogLevelEnumSchema;")); diff --git a/native/src/resolver.rs b/native/src/resolver.rs index bcce09e..e443195 100644 --- a/native/src/resolver.rs +++ b/native/src/resolver.rs @@ -171,9 +171,10 @@ fn collect_ordered_dependencies( }; let mut enum_ref_by_values: IndexMap = IndexMap::default(); for (name, schema) in schemas { - if let Some(values) = schema.get("enum").and_then(Value::as_array) { + // Keyed by the rendered code, which leaves out `null`, as the TypeScript resolver compares enums. + if let Some(code) = crate::zod::enum_code(schema) { enum_ref_by_values - .entry(Value::Array(values.clone()).to_string()) + .entry(code) .or_insert_with(|| format!("#/components/schemas/{name}")); } } @@ -228,8 +229,8 @@ fn collect_inline_enum_values(value: &Value, values: &mut HashSet) { } } Value::Object(object) => { - if let Some(enum_values) = object.get("enum").and_then(Value::as_array) { - values.insert(Value::Array(enum_values.clone()).to_string()); + if let Some(code) = crate::zod::enum_code(value) { + values.insert(code); } for value in object.values() { collect_inline_enum_values(value, values); diff --git a/native/src/zod.rs b/native/src/zod.rs index 4f1d673..84176c4 100644 --- a/native/src/zod.rs +++ b/native/src/zod.rs @@ -21,6 +21,7 @@ pub struct ZodCompiler<'a> { root_enums: HashMap, extracted_enums: IndexMap, extracted_enum_objects: Map, + extracted_enum_usage_tags: HashMap>, circular_getters: HashSet, runtime_tags: Rc>>>, } @@ -58,7 +59,7 @@ impl<'a> ZodCompiler<'a> { } } } - let (extracted_enums, extracted_enum_objects) = + let (extracted_enums, extracted_enum_objects, extracted_enum_usage_tags) = collect_extracted_enums(document, resolver, options, &root_enums); let circular_getters = collect_precompiled_circular_refs(document, resolver); Self { @@ -68,6 +69,7 @@ impl<'a> ZodCompiler<'a> { root_enums, extracted_enums, extracted_enum_objects, + extracted_enum_usage_tags, circular_getters, runtime_tags, } @@ -92,8 +94,10 @@ impl<'a> ZodCompiler<'a> { for (name, schema) in schemas { let zod_name = schema_name(name, &self.options.schema_suffix); let reference = format!("#/components/schemas/{name}"); + // Endpoints can use a component enum without referencing it (see `matching_component_enum_ref`). if self.options.exclude_redundant_zod_schemas && !self.resolver.schema_tags.contains_key(&reference) + && !first_tags.contains_key(&reference) { continue; } @@ -132,6 +136,31 @@ impl<'a> ZodCompiler<'a> { Ok((code, chain(actual, meta, self.options))) } + /// The `#/components/schemas/…` ref of the component enum an inline string enum has the values of. + /// The compiler renders such a schema by that enum's name, as the TypeScript renderer turns it into + /// a `$ref`; endpoint schemas are then named like that `$ref` instead of getting a `…Param`/`…Body` + /// alias. + pub fn matching_component_enum_ref(&self, schema: &Value) -> Option { + let object = schema.as_object()?; + if object.contains_key("$ref") || !self.options.extract_enums { + return None; + } + let values = object.get("enum")?.as_array()?; + let is_string = object + .get("type") + .and_then(Value::as_str) + .is_some_and(|schema_type| schema_type.eq_ignore_ascii_case("string")); + if !is_string || (self.options.models_in_modules && values.len() == 1) { + return None; + } + let name = self.root_enums.get(&enum_code(schema)?)?; + Some(format!( + "#/components/schemas/{}", + name.strip_suffix(&self.options.schema_suffix) + .unwrap_or(name) + )) + } + pub fn endpoint_chain(&self, schema: &Value, required: bool) -> String { let actual = self.resolve_schema(schema).unwrap_or(schema); chain( @@ -162,6 +191,11 @@ impl<'a> ZodCompiler<'a> { .collect() } + /// Every tag whose schemas use each extracted enum: its modules with `modelsInModules`. + pub fn extracted_schema_usage_tags(&self) -> &HashMap> { + &self.extracted_enum_usage_tags + } + pub fn schema_owner(&self, reference: &str) -> String { self.schema_tag(reference) } @@ -467,7 +501,7 @@ impl<'a> ZodCompiler<'a> { && schema_reaches(property, root, self.resolver) }); rendered.push(if circular { - format!("get {property_name}() {{ return {code} }}") + format!("{property_name}: z.lazy(() => {code})") } else { format!("{property_name}: {code}") }); @@ -746,7 +780,7 @@ fn chain(schema: &Value, meta: Meta, options: &GenerateOptions) -> String { } } -fn enum_code(schema: &Value) -> Option { +pub(crate) fn enum_code(schema: &Value) -> Option { let values = schema.get("enum")?.as_array()?; let values = values .iter() @@ -872,9 +906,13 @@ fn collect_extracted_enums( resolver: &Resolver<'_>, options: &GenerateOptions, root_enums: &HashMap, -) -> (IndexMap, Map) { +) -> ( + IndexMap, + Map, + HashMap>, +) { if !options.extract_enums { - return (IndexMap::default(), Map::new()); + return (IndexMap::default(), Map::new(), HashMap::default()); } let mut candidates: IndexMap = IndexMap::default(); collect_operation_enum_candidates(document, resolver, &mut candidates); @@ -985,7 +1023,15 @@ fn collect_extracted_enums( ) }) .collect(); - (preliminary, objects) + let usage_tags = preliminary + .iter() + .map(|(code, (name, _))| { + let mut tags = candidates[code].tags.iter().cloned().collect::>(); + tags.sort(); + (name.clone(), tags) + }) + .collect(); + (preliminary, objects, usage_tags) } fn sanitize_enum_segment(segment: &str) -> String { diff --git a/scripts/renderer-parity-cases.ts b/scripts/renderer-parity-cases.ts index a7ef906..8aa95e5 100644 --- a/scripts/renderer-parity-cases.ts +++ b/scripts/renderer-parity-cases.ts @@ -9,6 +9,7 @@ export const parityFixtures = [ { name: "petstore", file: "test/petstore.yaml" }, { name: "configuration", file: "test/configuration.yaml" }, { name: "petstore-health", file: "test/petstore.yaml", healthOnly: true }, + { name: "renderer-regressions", file: "test/renderer-regressions.yaml" }, ]; export function readParityFixture(fixture: (typeof parityFixtures)[number]) { diff --git a/test/renderer-regressions.yaml b/test/renderer-regressions.yaml new file mode 100644 index 0000000..2ebd16f --- /dev/null +++ b/test/renderer-regressions.yaml @@ -0,0 +1,370 @@ +openapi: 3.0.3 +info: + title: Renderer regressions + version: "1" + description: >- + Shapes from a large production spec where the native and TypeScript renderers used to differ. + Each path or schema notes the difference it covers. +paths: + # An inline path enum with a component enum's values is named by that enum, which is emitted with + # the other component enums, after the endpoint error schemas. + /tracking/providers/{provider}/refresh: + post: + operationId: TrackingController_refreshProvider + tags: [Tracking] + parameters: + - name: provider + in: path + required: true + schema: { type: string, enum: [alpha, beta, gamma] } + responses: + "204": { description: Refreshed } + "400": + description: Not found + content: + application/json: + schema: + type: object + required: [status, code, message] + properties: + status: { type: string, enum: [error] } + code: { type: string, enum: [tracking_not_found] } + message: { type: string } + /tracking/providers/{provider}/sync: + post: + operationId: TrackingController_syncProvider + tags: [Tracking] + parameters: + - name: provider + in: path + required: true + schema: { type: string, enum: [alpha, beta, gamma] } + responses: + "204": { description: Synced } + "400": + description: Invalid + content: + application/json: + schema: + type: object + required: [status, code, message] + properties: + status: { type: string, enum: [error] } + code: { type: string, enum: [invalid_direction] } + message: { type: string } + # Query parameter docs list every schema constraint; the response is owned by Common. + /tracking/labels: + get: + operationId: TrackingController_labels + tags: [Tracking] + parameters: + - name: query + in: query + required: false + schema: { nullable: true, type: string, minLength: 2, maxLength: 200 } + - name: limit + in: query + schema: { type: integer, minimum: 1, maximum: 100, default: 20, example: 10 } + responses: + "200": + description: Labels + content: + application/json: + schema: { $ref: "#/components/schemas/LabelPageDto" } + # Imports follow the first use of each owner: Common's response comes before the local filter. + /accounts/labels: + get: + operationId: AccountsController_labels + tags: [Accounts] + parameters: + - name: direction + in: query + schema: { $ref: "#/components/schemas/DirectionEnum" } + - name: filter + in: query + schema: + nullable: true + allOf: [{ $ref: "#/components/schemas/AccountLabelsFilterDto" }] + responses: + "200": + description: Labels + content: + application/json: + schema: { $ref: "#/components/schemas/LabelPageDto" } + /accounts/items: + get: + operationId: AccountsController_items + tags: [Accounts] + parameters: + - name: direction + in: query + schema: { $ref: "#/components/schemas/DirectionEnum" } + - name: metric + in: query + schema: { type: string, enum: [count, sum] } + responses: + "200": + description: Items + content: + application/json: + schema: { $ref: "#/components/schemas/AccountItemListDto" } + post: + operationId: AccountsController_createItem + tags: [Accounts] + requestBody: + required: true + description: The item to create + content: + application/json: + schema: { $ref: "#/components/schemas/CreateRemarkDto" } + example: '{"position":1}' + responses: + "201": { description: Created } + # Body parameter docs use the media type's schema and example. + /accounts/notes: + post: + operationId: AccountsController_createNote + tags: [Accounts] + requestBody: + content: + application/json: + schema: { type: string, minLength: 1, maxLength: 500 } + example: A note + responses: + "204": { description: Created } + # The extracted `status` enum is owned by the tag of the component that declares it, not by the + # tags whose inline error responses reuse it. + /scanner/results: + get: + operationId: ScannerController_results + tags: [Scanner] + responses: + "200": + description: Results + content: + application/json: + schema: { $ref: "#/components/schemas/ScannerErrorDto" } + # A later endpoint's composite response depends on the component enum with the same values as a + # property's enum, although an earlier endpoint already registered the property's schema. + /quotes/{quoteId}/items: + post: + operationId: QuoteItemsController_create + tags: [Quote Items] + parameters: + - { name: quoteId, in: path, required: true, schema: { type: string } } + responses: + "200": + description: Created + content: + application/json: + schema: { $ref: "#/components/schemas/QuoteItemDto" } + /quotes/{quoteId}/items/bulk: + post: + operationId: QuoteItemsController_createMany + tags: [Quote Items] + parameters: + - { name: quoteId, in: path, required: true, schema: { type: string } } + responses: + "200": + description: Created + content: + application/json: + schema: + type: array + items: { $ref: "#/components/schemas/QuoteItemDto" } + /positions/{positionId}/items: + get: + operationId: PositionItemsController_list + tags: [Position Items] + parameters: + - { name: positionId, in: path, required: true, schema: { type: string } } + responses: + "200": + description: Items + content: + application/json: + schema: { $ref: "#/components/schemas/PositionItemDto" } + # Two component enums share values; neither declaration uses the other. + /messages/outgoing: + get: + operationId: OutgoingMessagesController_list + tags: [Outgoing Messages] + responses: + "200": + description: Messages + content: + application/json: + schema: { $ref: "#/components/schemas/OutgoingMessageDto" } + /messages/incoming: + get: + operationId: IncomingMessagesController_list + tags: [Incoming Messages] + responses: + "200": + description: Messages + content: + application/json: + schema: { $ref: "#/components/schemas/IncomingMessageDto" } + # Recursive properties use `z.lazy`; `additionalProperties: false` adds no index signature. + /folders/tree: + get: + operationId: FoldersController_tree + tags: [Folders] + responses: + "200": + description: Tree + content: + application/json: + schema: { $ref: "#/components/schemas/FolderTreeDto" } + # The native transport types are imported before the rest client used for uploads. + /uploads/{uploadId}: + get: + operationId: UploadsController_get + tags: [Uploads] + parameters: + - { name: uploadId, in: path, required: true, schema: { type: string } } + responses: + "200": + description: Upload + content: + application/json: + schema: { $ref: "#/components/schemas/UploadDto" } + post: + operationId: UploadsController_upload + tags: [Uploads] + x-media-upload: true + parameters: + - { name: uploadId, in: path, required: true, schema: { type: string } } + requestBody: + content: + application/json: + schema: { $ref: "#/components/schemas/UploadRequestDto" } + responses: + "200": + description: Upload instructions + content: + application/json: + schema: { $ref: "#/components/schemas/UploadDto" } +components: + schemas: + ProviderNameEnum: + type: string + enum: [alpha, beta, gamma] + DirectionEnum: + type: string + enum: [Import, Export] + LabelPageDto: + type: object + required: [items, total] + properties: + items: + type: array + items: + type: object + required: [id, label] + properties: + id: { type: string } + label: { type: string } + total: { type: integer } + # Declared before the filter, but has no dependencies, so it sorts after it. + CreateRemarkDto: + type: object + required: [content] + properties: + content: + type: object + properties: + html: { nullable: true, type: string } + json: { nullable: true, type: object, additionalProperties: {} } + position: { nullable: true, type: integer } + # `null` in an enum does not stop it matching DirectionEnum. + AccountLabelsFilterDto: + type: object + properties: + search: { nullable: true, type: string } + direction: { nullable: true, type: string, enum: [Import, Export, null] } + # A top-level array documents its items' properties. + AccountItemListDto: + type: array + items: + type: object + required: [id, name] + properties: + id: { type: string } + name: { type: string, description: The item name } + tags: { type: array, items: { type: string } } + ScannerErrorDto: + type: object + required: [status, message] + properties: + status: { type: string, enum: [error] } + message: { type: string } + ItemTypeAEnum: + type: string + enum: [CHARGE, TEXT, DIVIDER] + ItemTypeBEnum: + type: string + enum: [CHARGE, TEXT, DIVIDER] + # The nullable status matches an extracted enum owned by Common, which the models then import. + QuoteItemDto: + type: object + required: [id, type] + properties: + id: { type: string } + type: { $ref: "#/components/schemas/ItemTypeBEnum" } + determinationStatus: { nullable: true, type: string, enum: [MATCHED, NO_MATCH, null] } + PositionItemDto: + type: object + required: [id, type] + properties: + id: { type: string } + type: { $ref: "#/components/schemas/ItemTypeAEnum" } + determinationStatus: { type: string, enum: [MATCHED, NO_MATCH] } + MessageStatusAEnum: + type: string + enum: [Pending, Uploaded, Failed] + MessageStatusBEnum: + type: string + enum: [Pending, Uploaded, Failed] + OutgoingMessageDto: + type: object + properties: + status: { $ref: "#/components/schemas/MessageStatusAEnum" } + IncomingMessageDto: + type: object + properties: + status: { $ref: "#/components/schemas/MessageStatusBEnum" } + FolderTreeDto: + type: object + required: [id, folders] + properties: + id: { type: string } + folders: + type: array + items: { $ref: "#/components/schemas/FolderTreeDto__schema0" } + FolderTreeDto__schema0: + type: object + required: [id, name, folders] + additionalProperties: false + properties: + id: { type: string } + name: { type: string } + symlink: + nullable: true + type: object + required: [targetId] + additionalProperties: false + properties: + targetId: { type: string } + folders: + type: array + items: { $ref: "#/components/schemas/FolderTreeDto__schema0" } + UploadDto: + type: object + properties: + id: { type: string } + url: { type: string } + UploadRequestDto: + type: object + properties: + fileName: { type: string } From 75ef908d9ef7b6bc032bb0dde72f93be01409702 Mon Sep 17 00:00:00 2001 From: Urban Krepel Date: Fri, 23 Oct 2026 11:22:18 +0200 Subject: [PATCH 17/17] feat: add includeSchemas to generate unused component schemas `excludeRedundantZodSchemas` (on by default) drops components that no included operation uses. Some specs carry such schemas on purpose, e.g. payloads that another app consumes, such as the BE's PDF payloads that a PDF renderer validates. `includeSchemas` lists component names to generate anyway, together with the schemas they depend on. It works in the config file and as a comma-separated `--includeSchemas` flag. Both renderers support it, and the parity fixture and option matrix cover it. `resolveConfig` now exports the CLI params type, which the runner used to duplicate. The native model proxies also list a component's extracted enums in extraction order, as the TypeScript renderer does. They used to follow the order of appearance in the schema code. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 ++ native/src/config.rs | 2 ++ native/src/render.rs | 27 +++++++++++++---- native/src/zod.rs | 15 ++++++++++ scripts/renderer-parity-configs.ts | 1 + src/commands/check.command.ts | 3 ++ src/commands/generate.command.ts | 3 ++ src/generators/const/options.const.ts | 1 + src/generators/core/resolveConfig.ts | 22 ++++++++------ .../core/zod/getZodSchemasFromOpenAPIDoc.ts | 13 +++++++++ .../generated-include-schemas.test.ts | 29 +++++++++++++++++++ src/generators/run/generate.runner.ts | 11 ++----- src/generators/types/options.ts | 2 ++ test/renderer-regressions.yaml | 15 ++++++++++ 14 files changed, 122 insertions(+), 24 deletions(-) create mode 100644 src/generators/generated-include-schemas.test.ts diff --git a/README.md b/README.md index f95fcb6..efe4d3e 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,7 @@ bunx openapi-codegen generate --config my-config.ts --excludeTags Comma-separated list of tags to exclude from generation --excludePathRegex Exclude operations whose paths match the given regular expression --excludeRedundantZodSchemas Exclude any redundant Zod schemas (default: true) + --includeSchemas Comma-separated component schemas to generate even when no operation uses them --tsNamespaces Wrap generated files in TypeScript namespaces (default: true) --importPath Module import style for generated files (default: 'ts'; options: 'ts' | 'relative' | 'absolute') @@ -166,6 +167,7 @@ bunx openapi-codegen generate --config my-config.ts --excludeTags Comma-separated list of tags to exclude from generation --excludePathRegex Exclude operations whose paths match the given regular expression --excludeRedundantZodSchemas Exclude any redundant Zod schemas (default: true) + --includeSchemas Comma-separated component schemas to generate even when no operation uses them ``` ## Development diff --git a/native/src/config.rs b/native/src/config.rs index c350c4d..238ade7 100644 --- a/native/src/config.rs +++ b/native/src/config.rs @@ -74,6 +74,8 @@ pub struct GenerateOptions { pub replace_optional_with_nullish: bool, #[serde(default = "default_true")] pub exclude_redundant_zod_schemas: bool, + #[serde(default)] + pub include_schemas: Vec, #[serde(default = "default_enum_suffix")] pub enum_suffix: String, #[serde(default)] diff --git a/native/src/render.rs b/native/src/render.rs index 12c1c81..19f1c8e 100644 --- a/native/src/render.rs +++ b/native/src/render.rs @@ -390,6 +390,11 @@ fn render_proxy_modules( .map(|reference| (name.as_str(), reference)) }) .collect(); + let schema_positions: HashMap<&str, usize> = schemas + .keys() + .enumerate() + .map(|(position, name)| (name.as_str(), position)) + .collect(); let mut by_tag: IndexMap> = IndexMap::default(); for endpoint in endpoints { let tag = endpoint @@ -439,23 +444,33 @@ fn render_proxy_modules( } } // Generated schemas reach components only: JavaScript collects their refs, not the - // extracted enums they use. + // extracted enums they use. A component's extracted enums follow its refs, in extraction + // order (the order of `schemas`). let is_component = ref_by_name.contains_key(name.as_str()); if let Some(code) = schemas.get(&name).and_then(Value::as_str) { + let mut extracted_enums = Vec::new(); for candidate in schema_identifiers(code) { + if candidate == name + || !schemas.contains_key(candidate) + || direct_names.contains(candidate) + { + continue; + } let extracted_enum = !ref_by_name.contains_key(candidate) && schemas .get(candidate) .and_then(Value::as_str) .is_some_and(|code| code.starts_with("z.enum(")); - if candidate != name - && schemas.contains_key(candidate) - && !direct_names.contains(candidate) - && (is_component || !extracted_enum) - { + if !extracted_enum { enqueue(candidate, &mut used, &mut seen, &mut queue); + } else if is_component { + extracted_enums.push(candidate); } } + extracted_enums.sort_by_key(|candidate| schema_positions.get(candidate).copied()); + for candidate in extracted_enums { + enqueue(candidate, &mut used, &mut seen, &mut queue); + } } } used.retain(|name| schemas.contains_key(name)); diff --git a/native/src/zod.rs b/native/src/zod.rs index 84176c4..acf7381 100644 --- a/native/src/zod.rs +++ b/native/src/zod.rs @@ -79,6 +79,19 @@ impl<'a> ZodCompiler<'a> { self.compile_components_with_tags(&HashMap::default()) } + /// The `includeSchemas` components and every schema they depend on. + fn included_schema_refs(&self) -> HashSet { + let mut refs = HashSet::default(); + for name in &self.options.include_schemas { + let reference = format!("#/components/schemas/{name}"); + if let Some(dependencies) = self.resolver.deep_dependencies.get(&reference) { + refs.extend(dependencies.iter().cloned()); + } + refs.insert(reference); + } + refs + } + pub fn compile_components_with_tags( &self, first_tags: &HashMap, @@ -91,6 +104,7 @@ impl<'a> ZodCompiler<'a> { else { return Ok(result); }; + let included = self.included_schema_refs(); for (name, schema) in schemas { let zod_name = schema_name(name, &self.options.schema_suffix); let reference = format!("#/components/schemas/{name}"); @@ -98,6 +112,7 @@ impl<'a> ZodCompiler<'a> { if self.options.exclude_redundant_zod_schemas && !self.resolver.schema_tags.contains_key(&reference) && !first_tags.contains_key(&reference) + && !included.contains(&reference) { continue; } diff --git a/scripts/renderer-parity-configs.ts b/scripts/renderer-parity-configs.ts index 7386a60..a5eb1c5 100644 --- a/scripts/renderer-parity-configs.ts +++ b/scripts/renderer-parity-configs.ts @@ -26,6 +26,7 @@ export const optionCases = { excludeTags: [{ excludeTags: ["pet"] }], excludePathRegex: [{ excludePathRegex: "^/pet" }], excludeRedundantZodSchemas: bool("excludeRedundantZodSchemas"), + includeSchemas: [{ includeSchemas: ["ReportPayloadDto", "Pet"] }], tsPath: [{ tsPath: "@/openapi" }], importPath: ["ts", "relative", "absolute"].map((importPath) => ({ importPath })) as Partial[], configs: [ diff --git a/src/commands/check.command.ts b/src/commands/check.command.ts index 7be7495..f93a5c9 100644 --- a/src/commands/check.command.ts +++ b/src/commands/check.command.ts @@ -31,6 +31,9 @@ class CheckOptions implements CheckParams { @YargOption({ envAlias: "excludeRedundantZodSchemas", type: "boolean" }) excludeRedundantZodSchemas?: boolean; + @YargOption({ envAlias: "includeSchemas" }) + includeSchemas?: string; + @YargOption({ envAlias: "verbose", default: false, type: "boolean" }) verbose?: boolean; } diff --git a/src/commands/generate.command.ts b/src/commands/generate.command.ts index 39cfcba..7f98393 100644 --- a/src/commands/generate.command.ts +++ b/src/commands/generate.command.ts @@ -49,6 +49,9 @@ class GenerateOptions implements GenerateParams { @YargOption({ envAlias: "excludeRedundantZodSchemas", type: "boolean" }) excludeRedundantZodSchemas?: boolean; + @YargOption({ envAlias: "includeSchemas" }) + includeSchemas?: string; + @YargOption({ envAlias: "importPath" }) importPath?: "ts" | "relative" | "absolute"; diff --git a/src/generators/const/options.const.ts b/src/generators/const/options.const.ts index 983839d..310f4ce 100644 --- a/src/generators/const/options.const.ts +++ b/src/generators/const/options.const.ts @@ -16,6 +16,7 @@ export const DEFAULT_GENERATE_OPTIONS: GenerateOptions = { excludeTags: [], excludePathRegex: "", excludeRedundantZodSchemas: true, + includeSchemas: [], tsNamespaces: true, treeShakeableNamespaces: false, tsPath: "@/data", diff --git a/src/generators/core/resolveConfig.ts b/src/generators/core/resolveConfig.ts index ae84691..e296e36 100644 --- a/src/generators/core/resolveConfig.ts +++ b/src/generators/core/resolveConfig.ts @@ -2,24 +2,28 @@ import { DEFAULT_GENERATE_OPTIONS } from "@/generators/const/options.const"; import { GenerateOptions } from "@/generators/types/options"; import { deepMerge } from "@/generators/utils/object.utils"; +type ListParams = + | "includeTags" + | "excludeTags" + | "includeSchemas" + | "inlineEndpointsExcludeModules" + | "workspaceContext"; + +/** CLI options, where list options are comma-separated strings. */ +export type ConfigParams = Partial & Record>; + export function resolveConfig({ fileConfig = {}, - params: { includeTags, excludeTags, inlineEndpointsExcludeModules, workspaceContext, ...options }, + params: { includeTags, excludeTags, includeSchemas, inlineEndpointsExcludeModules, workspaceContext, ...options }, }: { fileConfig?: Partial | null; - params: Partial< - Omit & { - includeTags: string; - excludeTags: string; - inlineEndpointsExcludeModules: string; - workspaceContext: string; - } - >; + params: ConfigParams; }) { const resolvedConfig = deepMerge(DEFAULT_GENERATE_OPTIONS, fileConfig ?? {}, { ...options, includeTags: includeTags?.split(","), excludeTags: excludeTags?.split(","), + includeSchemas: includeSchemas?.split(","), inlineEndpointsExcludeModules: inlineEndpointsExcludeModules?.split(","), workspaceContext: workspaceContext?.split(","), }); diff --git a/src/generators/core/zod/getZodSchemasFromOpenAPIDoc.ts b/src/generators/core/zod/getZodSchemasFromOpenAPIDoc.ts index 90dc1e4..47ec5c4 100644 --- a/src/generators/core/zod/getZodSchemasFromOpenAPIDoc.ts +++ b/src/generators/core/zod/getZodSchemasFromOpenAPIDoc.ts @@ -8,12 +8,14 @@ export function getZodSchemasFromOpenAPIDoc(resolver: SchemaResolver, profiler?: const p = profiler ?? new Profiler(false); const zodSchemas = {} as Record; const enumZodSchemas = {} as Record; + const includedSchemaRefs = getIncludedSchemaRefs(resolver); Object.entries(resolver.openApiDoc.components?.schemas ?? {}).forEach(([name, schema]) => p.runSync("zod.extract.schemaEntry", () => { const schemaData = resolver.getSchemaDataByName(name); if ( resolver.options.excludeRedundantZodSchemas && + !includedSchemaRefs.has(`#/components/schemas/${name}`) && (!schemaData || (schemaData.deepRefOperations.length === 0 && schemaData.tags.length === 0)) ) { return; @@ -47,6 +49,17 @@ export function getZodSchemasFromOpenAPIDoc(resolver: SchemaResolver, profiler?: return { zodSchemas, enumZodSchemas }; } +/** The `includeSchemas` components and every schema they depend on. */ +function getIncludedSchemaRefs(resolver: SchemaResolver) { + const refs = new Set(); + for (const name of resolver.options.includeSchemas) { + const ref = `#/components/schemas/${name}`; + refs.add(ref); + resolver.dependencyGraph.deepDependencyGraph[ref]?.forEach((dependency) => refs.add(dependency)); + } + return refs; +} + export function getEnumZodSchemasFromOpenAPIDoc(resolver: SchemaResolver) { const enumZodSchemas: EnumZodSchemaData[] = []; diff --git a/src/generators/generated-include-schemas.test.ts b/src/generators/generated-include-schemas.test.ts new file mode 100644 index 0000000..4a46e67 --- /dev/null +++ b/src/generators/generated-include-schemas.test.ts @@ -0,0 +1,29 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, test } from "vitest"; +import { parse } from "yaml"; + +import { generateFilesFromNativeOpenAPI } from "../native/generateFilesFromNativeOpenAPI"; + +import { DEFAULT_GENERATE_OPTIONS } from "./const/options.const"; +import { generateCodeFromOpenAPIDoc } from "./generateCodeFromOpenAPIDoc"; +import { GenerateOptions } from "./types/options"; + +// Payloads that only another app consumes are in the components, but no operation uses them. +describe("includeSchemas", () => { + const source = readFileSync("test/renderer-regressions.yaml", "utf8"); + const render = (backend: "typescript" | "native", overrides: Partial) => { + const options: GenerateOptions = { ...DEFAULT_GENERATE_OPTIONS, output: "output", ...overrides }; + const files = + backend === "typescript" + ? generateCodeFromOpenAPIDoc(parse(source), options) + : generateFilesFromNativeOpenAPI(source, true, options); + return (files ?? []).map(({ content }) => content).join("\n"); + }; + + test.each(["typescript", "native"] as const)("generates listed schemas and their dependencies (%s)", (backend) => { + expect(render(backend, {})).not.toContain("ReportPayloadDtoSchema"); + const models = render(backend, { includeSchemas: ["ReportPayloadDto"] }); + expect(models).toContain("export const ReportPayloadDtoSchema"); + expect(models).toContain("export const ReportLineDtoSchema"); + }); +}); diff --git a/src/generators/run/generate.runner.ts b/src/generators/run/generate.runner.ts index 1915f72..27caedf 100644 --- a/src/generators/run/generate.runner.ts +++ b/src/generators/run/generate.runner.ts @@ -2,7 +2,7 @@ import fs from "fs"; import path from "path"; import { OpenAPIV3 } from "openapi-types"; -import { resolveConfig } from "@/generators/core/resolveConfig"; +import { type ConfigParams, resolveConfig } from "@/generators/core/resolveConfig"; import { GenerateFileFormatter } from "@/generators/types/generate"; import { GenerateOptions } from "@/generators/types/options"; import { removeStaleGeneratedFiles, writeGenerateFileData } from "@/generators/utils/file.utils"; @@ -22,14 +22,7 @@ export async function runGenerate({ profiler = new Profiler(process.env.OPENAPI_CODEGEN_PROFILE === "1"), }: { fileConfig?: Partial | null; - params?: Partial< - Omit & { - includeTags: string; - excludeTags: string; - inlineEndpointsExcludeModules: string; - workspaceContext: string; - } - >; + params?: ConfigParams; formatGeneratedFile?: GenerateFileFormatter; profiler?: Profiler; }) { diff --git a/src/generators/types/options.ts b/src/generators/types/options.ts index 8f9d6e4..033f270 100644 --- a/src/generators/types/options.ts +++ b/src/generators/types/options.ts @@ -76,6 +76,8 @@ interface BaseGenerateOptions { excludeTags: string[]; excludePathRegex: string; excludeRedundantZodSchemas: boolean; + /** Component schemas to generate even when no included operation uses them, with their dependencies. */ + includeSchemas: string[]; tsNamespaces: boolean; treeShakeableNamespaces?: boolean; tsPath: string; diff --git a/test/renderer-regressions.yaml b/test/renderer-regressions.yaml index 2ebd16f..84742dc 100644 --- a/test/renderer-regressions.yaml +++ b/test/renderer-regressions.yaml @@ -368,3 +368,18 @@ components: type: object properties: fileName: { type: string } + # No operation uses these; `includeSchemas: [ReportPayloadDto]` generates both. + ReportPayloadDto: + type: object + required: [title, lines] + properties: + title: { type: string } + locale: { type: string, enum: [en, de] } + lines: + type: array + items: { $ref: "#/components/schemas/ReportLineDto" } + ReportLineDto: + type: object + properties: + label: { type: string } + amount: { type: number }