From 22238676b0991ef31d87e2b75c5b87cc3e5c5ad2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andrzej=20=C5=9Awi=C4=99tek?= Date: Tue, 8 Sep 2026 17:25:14 +0200 Subject: [PATCH 01/11] Refactored the generator pipeline into explicit abstractions --- Cargo.toml | 5 +- rustfmt.toml | 2 - src/bindings.rs | 29 +- src/emit/angular/imports.rs | 37 +- src/emit/angular/mod.rs | 45 +- src/emit/angular/request.rs | 364 ++-- src/emit/angular/service.rs | 123 +- src/emit/emitter.rs | 86 + src/emit/mod.rs | 47 +- src/emit/model/emit_ts_models.rs | 266 +-- src/emit/ts/decl.rs | 132 ++ src/emit/ts/imports.rs | 135 ++ src/emit/ts/literal.rs | 44 + src/emit/ts/mod.rs | 15 + src/emit/ts/types.rs | 171 ++ src/emit/ts/writer.rs | 174 ++ src/emit/{typescript_tests.rs => ts_tests.rs} | 103 +- src/emit/typescript.rs | 611 ------ src/error.rs | 150 +- src/ident.rs | 107 ++ src/io/writer.rs | 46 +- src/ir/canonical.rs | 112 +- src/ir/identifier.rs | 18 - src/ir/mod.rs | 1 - src/ir/normalize/mod.rs | 88 +- src/ir/normalize/operations.rs | 1661 ----------------- src/ir/normalize/operations/body.rs | 143 ++ src/ir/normalize/operations/form.rs | 649 +++++++ src/ir/normalize/operations/mod.rs | 162 ++ src/ir/normalize/operations/parameters.rs | 117 ++ src/ir/normalize/operations/path_template.rs | 104 ++ src/ir/normalize/operations/responses.rs | 425 +++++ src/ir/normalize/schema.rs | 701 ------- src/ir/normalize/schema/composition.rs | 123 ++ src/ir/normalize/schema/enums.rs | 51 + src/ir/normalize/schema/map.rs | 64 + src/ir/normalize/schema/mod.rs | 242 +++ src/ir/normalize/schema/reference.rs | 33 + src/ir/normalize/schema/tests.rs | 69 + src/ir/normalize/semantic.rs | 170 +- src/ir/normalize/tests.rs | 72 +- src/ir/normalize/walk.rs | 94 + src/ir/tests.rs | 46 +- src/lib.rs | 1 + src/options.rs | 302 +-- src/parse/input.rs | 398 +--- src/parse/limits.rs | 83 + src/parse/mod.rs | 2 + src/parse/openapi_model.rs | 62 +- src/parse/policy.rs | 171 +- src/parse/unique_map.rs | 173 ++ src/pipeline.rs | 134 +- src/plan/artifact_plan.rs | 259 ++- src/plan/mod.rs | 24 +- src/plan/naming/case.rs | 203 +- src/plan/naming/config.rs | 50 +- src/plan/naming/context.rs | 139 +- src/plan/naming/defaults.rs | 68 +- src/plan/naming/engine.rs | 18 +- src/plan/naming/{legacy.rs => fixed.rs} | 87 +- src/plan/naming/lower.rs | 145 ++ src/plan/naming/mod.rs | 71 +- src/plan/naming/parse_spec.rs | 19 +- src/plan/naming/template.rs | 74 +- src/plan/services.rs | 897 --------- src/plan/services/body.rs | 400 ++++ src/plan/services/grouping.rs | 115 ++ src/plan/services/mod.rs | 367 ++++ src/result.rs | 36 +- src/test_support.rs | 94 +- 70 files changed, 5985 insertions(+), 6244 deletions(-) create mode 100644 src/emit/emitter.rs create mode 100644 src/emit/ts/decl.rs create mode 100644 src/emit/ts/imports.rs create mode 100644 src/emit/ts/literal.rs create mode 100644 src/emit/ts/mod.rs create mode 100644 src/emit/ts/types.rs create mode 100644 src/emit/ts/writer.rs rename src/emit/{typescript_tests.rs => ts_tests.rs} (76%) delete mode 100644 src/emit/typescript.rs create mode 100644 src/ident.rs delete mode 100644 src/ir/identifier.rs delete mode 100644 src/ir/normalize/operations.rs create mode 100644 src/ir/normalize/operations/body.rs create mode 100644 src/ir/normalize/operations/form.rs create mode 100644 src/ir/normalize/operations/mod.rs create mode 100644 src/ir/normalize/operations/parameters.rs create mode 100644 src/ir/normalize/operations/path_template.rs create mode 100644 src/ir/normalize/operations/responses.rs delete mode 100644 src/ir/normalize/schema.rs create mode 100644 src/ir/normalize/schema/composition.rs create mode 100644 src/ir/normalize/schema/enums.rs create mode 100644 src/ir/normalize/schema/map.rs create mode 100644 src/ir/normalize/schema/mod.rs create mode 100644 src/ir/normalize/schema/reference.rs create mode 100644 src/ir/normalize/schema/tests.rs create mode 100644 src/ir/normalize/walk.rs create mode 100644 src/parse/limits.rs create mode 100644 src/parse/unique_map.rs rename src/plan/naming/{legacy.rs => fixed.rs} (60%) create mode 100644 src/plan/naming/lower.rs delete mode 100644 src/plan/services.rs create mode 100644 src/plan/services/body.rs create mode 100644 src/plan/services/grouping.rs create mode 100644 src/plan/services/mod.rs diff --git a/Cargo.toml b/Cargo.toml index 22ac005..f2c204e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,9 +17,8 @@ napi-derive = "=3.5.10" regex = "1" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" -# Pinned: 0.0.13 swapped its YAML backend and dropped error line/column plus -# duplicate-mapping-key rejection, which the duplicate-schema-name and -# mapping-expansion-exceeded diagnostics both read out of serde_yml errors. +# Pinned: 0.0.13 swapped its YAML backend and dropped the line/column suffix +# from decode errors, which every `E_INPUT_INVALID` message forwards verbatim. serde_yml = "=0.0.12" [build-dependencies] diff --git a/rustfmt.toml b/rustfmt.toml index b5f0026..b196eaa 100644 --- a/rustfmt.toml +++ b/rustfmt.toml @@ -1,3 +1 @@ -unstable_features = true tab_spaces = 2 -control_brace_style = "AlwaysSameLine" diff --git a/src/bindings.rs b/src/bindings.rs index 1822a11..ee4df97 100644 --- a/src/bindings.rs +++ b/src/bindings.rs @@ -16,9 +16,8 @@ pub enum EmitTarget { Angular, } -/// User-facing naming config crossing the NAPI boundary. The JS wrapper -/// in `lib/index.js` unpacks each JS `RegExp` into the `{ source, flags -/// }` shape carried here, so Rust sees pure data on this side. +/// The naming config as it crosses the NAPI boundary, where a JS `RegExp` +/// arrives already unpacked into `{ source, flags }`. #[napi(object)] #[derive(Clone, Debug)] pub struct NamingOptions { @@ -26,11 +25,10 @@ pub struct NamingOptions { pub group: Option, } -/// Discriminated union: a string shorthand, a single rule, or a chain -/// of rules-or-shorthands. NAPI cannot express true sum types, so we -/// use exclusive fields: exactly one of `string`, `rule`, or `chain` -/// must be set. The JS wrapper enforces this; the Rust validator -/// double-checks at config resolution. +/// A string shorthand, a single rule, or a chain of either. +/// +/// NAPI has no sum type, so the variants are exclusive fields: exactly +/// one must be set, which `plan::naming::lower` enforces. #[napi(object)] #[derive(Clone, Debug)] pub struct NamingValue { @@ -129,9 +127,10 @@ pub struct GenerateErrorPayload { pub warnings: Vec, } -/// Return shape of the native export. Exactly one field is set. The JS -/// wrapper turns `error` into a thrown `GenerateError`; returning data -/// instead of throwing keeps native and WASI runtimes identical. +/// Return shape of the native export, with exactly one field set. +/// +/// Returning the failure as data keeps the native and WASI runtimes +/// identical. #[napi(object)] pub struct GenerateOutcome { pub result: Option, @@ -171,10 +170,7 @@ pub(crate) fn map_failure(failure: GenerateFailure) -> GenerateErrorPayload { } } -/// Boundary projection: take the wire-shaped `GenerateOptions` from the -/// JS caller and lower it into the resolved `GenerateConfig` the domain -/// pipeline consumes. Lives in `bindings.rs` (not `options.rs`) so the -/// domain doesn't depend on the NAPI boundary types. +/// Lowers the wire-shaped options into the config the pipeline consumes. impl From for GenerateConfig { fn from(value: GenerateOptions) -> Self { Self { @@ -195,8 +191,7 @@ impl From for GenerateConfig { pub(crate) fn map_generate_result(value: ApplicationGenerateResult) -> GenerateResult { GenerateResult { summary: value.summary, - // Pipeline-collected diagnostics are warnings — fatals exit via the - // `Err(GenerateFailure)` arm and are projected in `map_failure`. + // A success carries warnings only. diagnostics: value .diagnostics .iter() diff --git a/src/emit/angular/imports.rs b/src/emit/angular/imports.rs index 951dad4..c25dc77 100644 --- a/src/emit/angular/imports.rs +++ b/src/emit/angular/imports.rs @@ -1,14 +1,12 @@ use std::collections::{BTreeMap, BTreeSet}; -use crate::emit::typescript::{self as ts, Writer}; +use crate::emit::ts::{Writer, type_import_block}; use crate::ir::canonical::ResponseContent; use crate::ir::schema::collect_type_references; use crate::plan::artifact_plan::{PlannedOperation, PlannedRequestBody, RequestFieldKind}; -/// Relative path from a generated service file (`rest/*.rest.generated.ts`) -/// to the sibling `model.generated.ts` that holds all emitted TypeScript -/// types. Fixed by the emit layout — services always live one directory -/// below the model artifact — so it is a constant rather than a plan field. +/// Path from a generated service file to the model artifact, one +/// directory above it. const MODEL_IMPORT_PATH: &str = "../model.generated"; pub(super) fn render_service_imports( @@ -40,13 +38,8 @@ pub(super) fn render_service_imports( for header in &operation.request.headers { collect_type_references(header.ty, &mut imports); } - // Body types contribute imports according to the body's layout. A - // `Nested` body's ty (named ref or any other `SchemaType`) imports - // straight from the type printer. A `FlatJson` body hoists each - // property's `SchemaType` to a top-level field, so each property - // contributes the same way path/query/header types do. Form bodies - // type their fields via `BodyFieldType`, which never references - // user-declared schemas — they add nothing. + // A form body's fields are typed by `BodyFieldType`, which names no + // user-declared schema. match &operation.request.body { Some(PlannedRequestBody::Nested { ty, .. }) => { collect_type_references(ty, &mut imports); @@ -64,28 +57,20 @@ pub(super) fn render_service_imports( ResponseContent::Json(Some(ty)) => { collect_type_references(ty, &mut imports); } - // `Json(None)` and non-JSON variants render to fixed TS surfaces - // (`void` / `Blob` / `string` / `ArrayBuffer`) that never reference - // user-declared schemas, so they contribute nothing to the import - // set. Non-JSON variants are not yet produced by normalize but the - // match is exhaustive so a future addition forces a compile error. + // Every other variant renders to a built-in type. ResponseContent::Json(None) | ResponseContent::Blob | ResponseContent::Text | ResponseContent::ArrayBuffer => {} } } - // Error-response body types contribute imports the same way as the - // success response: they appear by name in the per-operation - // `{Pascal}Error` interface emitted alongside `{Pascal}Params`. for error in operation.errors { collect_type_references(&error.body, &mut imports); } } if !imports.is_empty() { - let by_path = BTreeMap::from([(MODEL_IMPORT_PATH, imports)]); - ts::import_block(buffer, &by_path, true); + type_import_block(buffer, &BTreeMap::from([(MODEL_IMPORT_PATH, imports)])); } } @@ -105,8 +90,6 @@ mod tests { buf.into_string() } - // ── Fixed-position imports (HttpClient, Angular core, helpers) ───────────── - #[test] fn always_imports_injectable() { let out = render(&[op_with( @@ -150,8 +133,6 @@ mod tests { assert!(out.contains("import { httpParams, requestFactory } from '../rest.util';")); } - // ── Model-ref import dedup ──────────────────────────────────────────────── - #[test] fn model_refs_are_deduplicated_across_operations() { let pet_ref = SchemaType::Ref("Pet".into()); @@ -196,8 +177,6 @@ mod tests { assert!(out.contains("import type { IdempotencyKey } from '../model.generated';")); } - // ── Body imports under smart-flatten ────────────────────────────────────── - #[test] fn nested_body_named_ref_is_imported() { let payload_ref = SchemaType::Ref("CreatePetPayload".into()); @@ -248,8 +227,6 @@ mod tests { assert_eq!(out.matches("Pet").count(), 1); } - // ── empty operation set ─────────────────────────────────────────────────── - #[test] fn empty_operation_set_emits_only_fixed_imports() { let out = render(&[]); diff --git a/src/emit/angular/mod.rs b/src/emit/angular/mod.rs index 222906a..75fecdc 100644 --- a/src/emit/angular/mod.rs +++ b/src/emit/angular/mod.rs @@ -16,12 +16,13 @@ pub(crate) const REST_VALIDATE_TEMPLATE: &str = #[cfg(test)] mod tests { use super::*; + use crate::ident::TypeName; use crate::ir::canonical::HttpMethod; use crate::ir::schema::{SchemaScalar, SchemaType}; use crate::plan::artifact_plan::{ - PlannedOperation, PlannedRequestContract, PlannedRequestField, RequestFieldKind, ServicePlan, + PlannedRequestContract, PlannedRequestField, RequestFieldKind, ServicePlan, }; - use crate::test_support::empty_request; + use crate::test_support::{empty_request, op_with}; #[test] fn rest_model_template_carries_common_request_definitions() { @@ -42,19 +43,15 @@ mod tests { fn emit_service_generates_injectable_class_with_operation_property() { let plan = ServicePlan { group_name: "pet".into(), - class_name: "PetRest".into(), + class_name: TypeName::new("PetRest".to_string()), artifact_path: "rest/pet.rest.generated.ts".to_string(), - operations: vec![PlannedOperation { - operation_id: "listPets".to_string(), - method_name: "listPets".to_string(), - method: HttpMethod::Get, - path: "/pets".to_string(), - request: empty_request(), - response: None, - errors: &[], - description: None, - deprecated: false, - }], + operations: vec![op_with( + "listPets", + HttpMethod::Get, + "/pets", + empty_request(), + None, + )], }; let content = emit_service(&plan); assert!(content.contains("@Injectable(")); @@ -68,14 +65,13 @@ mod tests { let ty = SchemaType::Scalar(SchemaScalar::String); let plan = ServicePlan { group_name: "pet".into(), - class_name: "PetRest".into(), + class_name: TypeName::new("PetRest".to_string()), artifact_path: "rest/pet.rest.generated.ts".to_string(), - operations: vec![PlannedOperation { - operation_id: "updatePet".to_string(), - method_name: "updatePet".to_string(), - method: HttpMethod::Put, - path: "/pets/{id}".to_string(), - request: PlannedRequestContract { + operations: vec![op_with( + "updatePet", + HttpMethod::Put, + "/pets/{id}", + PlannedRequestContract { fields: vec![PlannedRequestField { name: "id".into(), optional: false, @@ -85,11 +81,8 @@ mod tests { headers: vec![], body: None, }, - response: None, - errors: &[], - description: None, - deprecated: false, - }], + None, + )], }; let content = emit_service(&plan); assert!(content.contains("export interface UpdatePetParams")); diff --git a/src/emit/angular/request.rs b/src/emit/angular/request.rs index 346e1ec..d0ec2a6 100644 --- a/src/emit/angular/request.rs +++ b/src/emit/angular/request.rs @@ -1,16 +1,11 @@ -use crate::emit::typescript::{self as ts, Position, Writer, render_type, safe_property_name}; +use crate::emit::ts::{Position, Render, Writer, property_declaration, w, wln}; +use crate::ident::TypeName; use crate::ir::canonical::BodyFieldType; -use crate::ir::schema::{SchemaProperty, SchemaType}; -use crate::plan::artifact_plan::{PlannedOperation, PlannedRequestBody, RequestFieldKind}; -use crate::wln; +use crate::plan::artifact_plan::{ + PlannedFormField, PlannedHeader, PlannedOperation, PlannedRequestBody, RequestFieldKind, +}; -/// Which form-body flavor the inline IIFE builds. -/// -/// Emit-local — distinct from the normalize-stage `FormKind` because the -/// concerns differ: normalize uses it to dispatch the body walker, while -/// emit uses it to pick the runtime constructor (`FormData` vs -/// `URLSearchParams`) and the TS return type. Sharing the enum across -/// stages would couple emit to normalize for no real reuse. +/// Which runtime constructor the form-body IIFE builds. #[derive(Clone, Copy)] enum FormKind { Multipart, @@ -20,7 +15,7 @@ enum FormKind { pub(super) fn render_requestful_builder( buffer: &mut Writer, operation: &PlannedOperation<'_>, - interface_name: &str, + interface_name: &TypeName, ) { buffer.open_block(&format!("(request: {interface_name}) =>")); @@ -30,11 +25,8 @@ pub(super) fn render_requestful_builder( .iter() .map(|f| f.name.as_ref()) .collect(); - // Body destructure depends on the body's layout: `Nested` introduces a - // single `body` identifier, while flat-JSON/form bodies destructure - // each field by name so the builder can reference them as bare - // identifiers in the assembled `body:` expression (object literal / - // `fd.append('name', name)`). + // A nested body destructures as one `body`; a hoisted one destructures + // every field, which the `body:` expression then references by name. match &operation.request.body { None => {} Some(PlannedRequestBody::Nested { .. }) => destructured.push("body"), @@ -42,7 +34,7 @@ pub(super) fn render_requestful_builder( destructured.extend(properties.iter().map(|p| p.name.as_ref())); } Some(PlannedRequestBody::Multipart { fields } | PlannedRequestBody::UrlEncoded { fields }) => { - destructured.extend(fields.iter().map(|f| f.name.as_ref())); + destructured.extend(fields.iter().map(|field| field.name.as_str())); } } if !operation.request.headers.is_empty() { @@ -55,12 +47,8 @@ pub(super) fn render_requestful_builder( buffer.open_block("return"); wln!(buffer, "method: '{}',", operation.method); write_path_template_line(buffer, &operation.path); - if let Some(params_expression) = render_params_expression(operation) { - wln!(buffer, "params: {params_expression},"); - } - if let Some(body_expression) = render_body_expression(operation) { - wln!(buffer, "body: {body_expression},"); - } + write_params_line(buffer, operation); + write_body_line(buffer, operation); if !operation.request.headers.is_empty() { buffer.line("headers,"); } @@ -78,9 +66,7 @@ pub(super) fn render_zero_arg_builder(buffer: &mut Writer, operation: &PlannedOp buffer.line("}),"); } -/// Stream `url: \`\`,\n` into `buffer`, expanding each -/// `{name}` placeholder to `${encodeURIComponent(name)}` without -/// allocating a separate `String` per template. +/// Writes the `url:` line, expanding each `{name}` placeholder. fn write_path_template_line(buffer: &mut Writer, path: &str) { buffer.push("url: `"); write_path_template_into(buffer, path); @@ -90,82 +76,46 @@ fn write_path_template_line(buffer: &mut Writer, path: &str) { pub(super) fn render_request_interface( buffer: &mut Writer, operation: &PlannedOperation<'_>, - request_name: &str, + request_name: &TypeName, ) { - // Manual emit (instead of `ts::interface_block`) because the body's - // hoisted fields can mix `SchemaType` (flat-JSON body properties) with - // `BodyFieldType` (form-body fields). The two share no enum — form-field - // types are deliberately constrained (`Scalar | ArrayOfScalar | Binary - // | ArrayOfBinary`) — so we render each group with its own type printer - // and keep the ordering invariant: path → query → body → headers. + // Emitted member by member because a hoisted body mixes `SchemaType` + // with `BodyFieldType`, which `interface_block` cannot take together. + // Member order is path → query → body → headers. buffer.open_block(&format!("export interface {request_name}")); - // Path / query parameters at the top. for field in &operation.request.fields { - ts::write_property_declaration(buffer, field.name.as_ref(), field.optional, field.ty); + property_declaration(buffer, field.name.as_ref(), field.optional, field.ty); buffer.push(";\n"); } - // Body. Smart-flatten dispatches on the body kind: - // - Nested → single `body: T` field (preserves named-ref identity). - // - FlatJson → hoist each property as a top-level field (matches the - // spec's authorial intent for unnamed object bodies). - // - Multipart / UrlEncoded → hoist each form-field as a top-level - // entry rendered through the BodyFieldType printer. match &operation.request.body { None => {} Some(PlannedRequestBody::Nested { ty, optional }) => { - ts::write_property_declaration(buffer, "body", *optional, ty); + property_declaration(buffer, "body", *optional, ty); buffer.push(";\n"); } Some(PlannedRequestBody::FlatJson { properties, .. }) => { for prop in properties { - ts::write_property_declaration(buffer, prop.name.as_ref(), prop.optional, prop.ty); + property_declaration(buffer, prop.name.as_ref(), prop.optional, prop.ty); buffer.push(";\n"); } } Some(PlannedRequestBody::Multipart { fields } | PlannedRequestBody::UrlEncoded { fields }) => { for form in fields { - let name = safe_property_name(form.name.as_ref()).into_owned(); - let optional_marker = if form.optional { "?" } else { "" }; - let ts_type = ts::render_body_field_type(form.ty); - wln!(buffer, "{name}{optional_marker}: {ts_type};"); + property_declaration(buffer, form.name.as_str(), form.optional, form.ty); + buffer.push(";\n"); } } } - // Synthetic `headers` block. Materialized here at the writer level - // (not at plan time) so the plan's `headers` list stays a simple - // sibling of `fields`. Headers carry no per-field deprecation — - // OpenAPI's Parameter Object has `deprecated` on Operation/Schema - // but not on header parameters — so each property's trailing flag - // is `false`. if !operation.request.headers.is_empty() { - let header_props: Vec = operation - .request - .headers - .iter() - .map(|h| SchemaProperty { - name: h.name.clone(), - required: !h.optional, - ty: h.ty.clone(), - description: None, - deprecated: false, - }) - .collect(); - let all_optional = operation.request.headers.iter().all(|h| h.optional); - let headers_ty = SchemaType::InlineObject { - properties: header_props, - }; - ts::write_property_declaration(buffer, "headers", all_optional, &headers_ty); - buffer.push(";\n"); + render_headers_member(buffer, &operation.request.headers); } buffer.close_block(""); } -/// Renders the per-operation `{Pascal}Error` interface — a numeric-status-keyed -/// map of error body types, e.g. +/// Emits an operation's error interface: its body types keyed by status. /// /// ```ignore /// export interface UpdatePetError { @@ -173,137 +123,150 @@ pub(super) fn render_request_interface( /// 500: { traceId: string }; /// } /// ``` -/// -/// Lives in the service file (alongside `{Pascal}Params`) so the per-operation -/// typed surfaces are colocated. Consumers access individual body types via -/// `UpdatePetError[400]` and cast `HttpErrorResponse.error` themselves — the -/// framework types `.error` as `any`, so this is a documentation/help type, -/// not a runtime guarantee. pub(super) fn render_error_interface( buffer: &mut Writer, operation: &PlannedOperation<'_>, - error_name: &str, + error_name: &TypeName, ) { buffer.open_block(&format!("export interface {error_name}")); for error in operation.errors { buffer.push(&error.status.to_string()); buffer.push(": "); - render_type(buffer, &error.body, Position::Standalone); + error.body.render(buffer, Position::Standalone); buffer.push(";\n"); } buffer.close_block(""); } -fn render_params_expression(operation: &PlannedOperation<'_>) -> Option { - let query_fields: Vec<&str> = operation +/// Emits the synthetic `headers` member: an inline object over the +/// operation's `in: header` parameters, optional when every one of them is. +/// +/// No member carries JSDoc: OpenAPI's Parameter Object has no +/// `deprecated` for a header. +fn render_headers_member(buffer: &mut Writer, headers: &[PlannedHeader<'_>]) { + buffer.push("headers"); + if headers.iter().all(|header| header.optional) { + buffer.push("?"); + } + buffer.push(": {\n"); + buffer.indent(); + for header in headers { + property_declaration(buffer, header.name.as_ref(), header.optional, header.ty); + buffer.push(";\n"); + } + buffer.dedent(); + buffer.push("};\n"); +} + +/// Writes `params: httpParams({ … }),` when the operation declares query +/// parameters. +/// +/// Emitted even when every field is optional: `httpParams` skips an +/// undefined value, so an all-undefined call yields empty params. +fn write_params_line(buffer: &mut Writer, operation: &PlannedOperation<'_>) { + let mut query = operation .request .fields .iter() - .filter(|f| f.kind == RequestFieldKind::Query) - .map(|f| f.name.as_ref()) - .collect(); - if query_fields.is_empty() { - return None; - } - - // When all query fields are optional and undefined at call time, the - // emitted `httpParams({...})` produces an empty `HttpParams` (the helper - // in templates/angular/rest.util.ts skips undefined values). We keep the - // unconditional emit instead of a per-call runtime guard because the - // empty-params path is a cheap no-op and the alternative spread guard - // (`...(a !== undefined ? { params: ... } : {})`) is noisier than the - // cost it saves. - Some(format!("httpParams({{ {} }})", query_fields.join(", "),)) + .filter(|field| field.kind == RequestFieldKind::Query) + .map(|field| field.name.as_ref()) + .peekable(); + if query.peek().is_none() { + return; + } + + buffer.push("params: httpParams({ "); + for (index, name) in query.enumerate() { + if index > 0 { + buffer.push(", "); + } + buffer.push(name); + } + buffer.push(" }),\n"); } -fn render_body_expression(operation: &PlannedOperation<'_>) -> Option { - match operation.request.body.as_ref()? { - // Nested bodies forward verbatim via property shorthand — `body,` in - // the builder return literal. - PlannedRequestBody::Nested { .. } => Some("body".to_string()), - // Flat-JSON bodies re-assemble the hoisted properties into an object - // literal by destructured name, restoring the original body shape on - // the wire. +/// Writes the `body: …,` line for whichever body layout the operation +/// declares. +fn write_body_line(buffer: &mut Writer, operation: &PlannedOperation<'_>) { + let Some(body) = operation.request.body.as_ref() else { + return; + }; + match body { + // Forwarded verbatim. + PlannedRequestBody::Nested { .. } => buffer.push("body: body,\n"), + // Re-assembled from the hoisted properties, restoring the wire shape. PlannedRequestBody::FlatJson { properties, .. } => { - let names: Vec<&str> = properties.iter().map(|p| p.name.as_ref()).collect(); - Some(format!("{{ {} }}", names.join(", "))) + buffer.push("body: { "); + for (index, property) in properties.iter().enumerate() { + if index > 0 { + buffer.push(", "); + } + buffer.push(property.name.as_ref()); + } + buffer.push(" },\n"); + } + PlannedRequestBody::Multipart { fields } => { + write_form_body(buffer, fields, FormKind::Multipart); } - PlannedRequestBody::Multipart { fields } => Some(render_form_body(fields, FormKind::Multipart)), PlannedRequestBody::UrlEncoded { fields } => { - Some(render_form_body(fields, FormKind::UrlEncoded)) + write_form_body(buffer, fields, FormKind::UrlEncoded); } } } -/// Build the inline IIFE that materializes a form-body request payload. +/// Writes the IIFE that materializes a form-body payload. /// -/// Returns a multi-line `String` whose first line starts with `((): ... => {` -/// and whose final line ends with `})()` — to be interpolated as the value -/// of a `body:` property at indent level 2 inside `render_requestful_builder`. -/// The `Writer` re-indents each `\n`-terminated line with its current cache, -/// so the leading spaces on continuation lines stack on top of that prefix. +/// Each field is referenced by the bare identifier the outer builder +/// destructured. /// -/// The outer builder destructures each form field from `request` directly -/// (smart-flatten hoists form fields to top-level), so the IIFE references -/// each by bare identifier in the append calls. -/// -/// Per-field append rules are keyed on `BodyFieldType`: -/// - `Scalar` and `ArrayOfScalar` wrap the value in `String(...)` because -/// `FormData.append` / `URLSearchParams.append` accept only string or Blob. -/// - `Binary` and `ArrayOfBinary` skip the cast — `File`/`Blob` are valid -/// `FormData` entries as-is; `URLSearchParams` doesn't support binary so -/// normalize rejects those fields upstream. -/// - Optional fields wrap in `if (name !== undefined) ...` to preserve the -/// "no key present" semantics; required fields emit unguarded. -fn render_form_body( - fields: &[crate::plan::artifact_plan::PlannedFormField<'_>], - kind: FormKind, -) -> String { - let (ctor, var, ts_type) = match kind { +/// `append` takes only a string or a `Blob`, so a scalar is wrapped in +/// `String(…)` and a binary passes through. An optional field is guarded, +/// leaving its key out when the value is absent. +fn write_form_body(buffer: &mut Writer, fields: &[PlannedFormField<'_>], kind: FormKind) { + let (constructor, variable, ts_type) = match kind { FormKind::Multipart => ("new FormData()", "fd", "FormData"), FormKind::UrlEncoded => ("new URLSearchParams()", "params", "URLSearchParams"), }; - let mut out = String::new(); - out.push_str(&format!("((): {ts_type} => {{\n")); - out.push_str(&format!(" const {var} = {ctor};\n")); - for f in fields { - let name = f.name.as_ref(); - let guard_open = if f.optional { - format!("if ({name} !== undefined) ") - } else { - String::new() - }; - let append_call = match f.ty { - BodyFieldType::Scalar(_) => format!("{var}.append('{name}', String({name}));"), + + wln!(buffer, "body: ((): {ts_type} => {{"); + buffer.indent(); + wln!(buffer, "const {variable} = {constructor};"); + for field in fields { + let name = field.name.as_str(); + if field.optional { + w!(buffer, "if ({name} !== undefined) "); + } + match field.ty { + BodyFieldType::Scalar(_) => { + wln!(buffer, "{variable}.append('{name}', String({name}));"); + } BodyFieldType::ArrayOfScalar(_) => { - format!("for (const v of {name}) {var}.append('{name}', String(v));") + wln!( + buffer, + "for (const v of {name}) {variable}.append('{name}', String(v));" + ); } - BodyFieldType::Binary => format!("{var}.append('{name}', {name});"), + BodyFieldType::Binary => wln!(buffer, "{variable}.append('{name}', {name});"), BodyFieldType::ArrayOfBinary => { - format!("for (const v of {name}) {var}.append('{name}', v);") + wln!( + buffer, + "for (const v of {name}) {variable}.append('{name}', v);" + ); } - }; - out.push_str(" "); - out.push_str(&guard_open); - out.push_str(&append_call); - out.push('\n'); - } - out.push_str(&format!(" return {var};\n")); - out.push_str("})()"); - out + } + } + wln!(buffer, "return {variable};"); + buffer.dedent(); + buffer.push("})(),\n"); } -/// Stream `path` into `buffer`, expanding each `{name}` placeholder to -/// `${encodeURIComponent(name)}`. Operates on string slices so the -/// per-placeholder name never lands in its own heap allocation; the -/// caller's buffer absorbs every byte directly. +/// Writes `path` into `buffer`, expanding each `{name}` placeholder to +/// `${encodeURIComponent(name)}`. /// -/// Balanced braces are a normalize-stage invariant -/// (`validate_path_template` rejects unmatched `{` / `}` before this -/// runs), so the loop never encounters a stray brace. The unbalanced-`{` -/// branch survives as a defensive fallback that emits the remainder -/// verbatim rather than panicking — preferable to surfacing a -/// generator panic on adversarial IR. +/// Braces are balanced on any path that reaches emit — normalize's +/// `validate_path_template` rejects the rest. The unmatched-`{` branch +/// emits the remainder verbatim so adversarial IR yields wrong output +/// instead of a panic across the NAPI boundary. fn write_path_template_into(buffer: &mut Writer, path: &str) { let mut rest = path; while let Some(open) = rest.find('{') { @@ -328,20 +291,22 @@ fn write_path_template_into(buffer: &mut Writer, path: &str) { #[cfg(test)] mod tests { use super::*; + + fn type_name(name: &str) -> TypeName { + TypeName::new(name.to_string()) + } use crate::ir::canonical::{BodyFieldType, ErrorResponse, HttpMethod}; - use crate::ir::schema::{SchemaProperty, SchemaScalar}; + use crate::ir::schema::{SchemaProperty, SchemaScalar, SchemaType}; use crate::plan::artifact_plan::{PlannedHeader, PlannedRequestContract}; use crate::test_support::{ body_field, flat_json_body, nested_body, op_with, op_with_errors, op_with_multipart_fields, op_with_multipart_fields_full, op_with_urlencoded_fields, path_field, query_field, string_ty, }; - // ── render_error_interface ──────────────────────────────────────────────── - fn render_errors(error_name: &str, errors: &[ErrorResponse]) -> String { let op = op_with_errors("op", errors); let mut buf = Writer::with_capacity(256); - render_error_interface(&mut buf, &op, error_name); + render_error_interface(&mut buf, &op, &type_name(error_name)); buf.into_string() } @@ -381,8 +346,6 @@ mod tests { assert!(out.contains("code: string;")); } - // ── render_requestful_builder ────────────────────────────────────────────── - #[test] fn requestful_builder_renders_get_with_path_param_only() { let ty = string_ty(); @@ -399,7 +362,7 @@ mod tests { ); let mut buf = Writer::with_capacity(512); - render_requestful_builder(&mut buf, &op, "GetPetParams"); + render_requestful_builder(&mut buf, &op, &type_name("GetPetParams")); let out = buf.into_string(); assert!(out.contains("(request: GetPetParams) =>")); @@ -433,7 +396,7 @@ mod tests { ); let mut buf = Writer::with_capacity(1024); - render_requestful_builder(&mut buf, &op, "CreatePetParams"); + render_requestful_builder(&mut buf, &op, &type_name("CreatePetParams")); let out = buf.into_string(); assert!(out.contains("(request: CreatePetParams) =>")); @@ -471,7 +434,7 @@ mod tests { ); let mut buf = Writer::with_capacity(1024); - render_requestful_builder(&mut buf, &op, "DecideParams"); + render_requestful_builder(&mut buf, &op, &type_name("DecideParams")); let out = buf.into_string(); assert!(out.contains("const { csvImportId, doImport } = request;")); @@ -497,7 +460,7 @@ mod tests { ); let mut buf = Writer::with_capacity(512); - render_requestful_builder(&mut buf, &op, "ListPetsParams"); + render_requestful_builder(&mut buf, &op, &type_name("ListPetsParams")); let out = buf.into_string(); assert!(out.contains("const { limit, offset } = request;")); @@ -521,7 +484,7 @@ mod tests { ); let mut buf = Writer::with_capacity(512); - render_requestful_builder(&mut buf, &op, "UploadPayloadParams"); + render_requestful_builder(&mut buf, &op, &type_name("UploadPayloadParams")); let out = buf.into_string(); // Non-object JSON bodies have no property structure to hoist, so they @@ -530,8 +493,6 @@ mod tests { assert!(out.contains("body: body,")); } - // ── render_zero_arg_builder ──────────────────────────────────────────────── - #[test] fn zero_arg_builder_renders_no_request_destructure() { let op = op_with( @@ -559,8 +520,6 @@ mod tests { assert!(!out.contains("params:")); } - // ── render_request_interface ─────────────────────────────────────────────── - #[test] fn request_interface_renders_ref_body_as_nested_alongside_headers() { let str_ty = string_ty(); @@ -589,7 +548,7 @@ mod tests { ); let mut buf = Writer::with_capacity(1024); - render_request_interface(&mut buf, &op, "CreatePetParams"); + render_request_interface(&mut buf, &op, &type_name("CreatePetParams")); let out = buf.into_string(); assert!(out.contains("export interface CreatePetParams")); @@ -605,9 +564,6 @@ mod tests { #[test] fn request_interface_hoists_flat_json_body_properties_to_top_level() { - // Smart-flatten: inline-object bodies surface as top-level fields, - // matching the spec author's intent (loose parameter bag rather than - // a named DTO). let str_ty = string_ty(); let bool_ty = SchemaType::Scalar(SchemaScalar::Boolean); let op = op_with( @@ -629,7 +585,7 @@ mod tests { ); let mut buf = Writer::with_capacity(512); - render_request_interface(&mut buf, &op, "DecideParams"); + render_request_interface(&mut buf, &op, &type_name("DecideParams")); let out = buf.into_string(); assert!(out.contains("export interface DecideParams")); @@ -654,7 +610,7 @@ mod tests { None, ); let mut buf = Writer::with_capacity(512); - render_request_interface(&mut buf, &op, "SavePetParams"); + render_request_interface(&mut buf, &op, &type_name("SavePetParams")); let out = buf.into_string(); assert!(out.contains("body?: MaybePayload;")); } @@ -679,7 +635,7 @@ mod tests { ); let mut buf = Writer::with_capacity(512); - render_request_interface(&mut buf, &op, "GetPetParams"); + render_request_interface(&mut buf, &op, &type_name("GetPetParams")); let out = buf.into_string(); // All-optional headers ⇒ the synthetic `headers` field itself is `?:`. @@ -702,7 +658,7 @@ mod tests { ); let mut buf = Writer::with_capacity(512); - render_request_interface(&mut buf, &op, "GetPetParams"); + render_request_interface(&mut buf, &op, &type_name("GetPetParams")); let out = buf.into_string(); assert!(out.contains("id: string;")); @@ -719,7 +675,7 @@ mod tests { vec![("avatar", false, &binary)], // form fields ); let mut buf = Writer::with_capacity(512); - render_request_interface(&mut buf, &op, "OpParams"); + render_request_interface(&mut buf, &op, &type_name("OpParams")); let out = buf.into_string(); assert!(out.contains("export interface OpParams")); assert!(out.contains("petId: string;")); @@ -731,7 +687,7 @@ mod tests { let arr_binary = BodyFieldType::ArrayOfBinary; let op = op_with_multipart_fields_full(vec![], vec![], vec![("galleries", false, &arr_binary)]); let mut buf = Writer::with_capacity(512); - render_request_interface(&mut buf, &op, "OpParams"); + render_request_interface(&mut buf, &op, &type_name("OpParams")); let out = buf.into_string(); assert!(out.contains("galleries: (Blob | File)[];")); } @@ -741,7 +697,7 @@ mod tests { let scalar = BodyFieldType::Scalar(SchemaScalar::String); let op = op_with_multipart_fields_full(vec![], vec![], vec![("nickname", true, &scalar)]); let mut buf = Writer::with_capacity(512); - render_request_interface(&mut buf, &op, "OpParams"); + render_request_interface(&mut buf, &op, &type_name("OpParams")); let out = buf.into_string(); // Form fields hoist to top-level — no nested `body:` wrapper. assert!(out.contains("nickname?: string;")); @@ -757,15 +713,13 @@ mod tests { vec![("status", false, &scalar), ("nickname", true, &scalar)], ); let mut buf = Writer::with_capacity(512); - render_request_interface(&mut buf, &op, "OpParams"); + render_request_interface(&mut buf, &op, &type_name("OpParams")); let out = buf.into_string(); assert!(out.contains("status: string;")); assert!(out.contains("nickname?: string;")); assert!(!out.contains("body:")); } - // ── path-template expansion ──────────────────────────────────────────────── - #[test] fn write_path_template_expands_every_placeholder() { let mut buf = Writer::with_capacity(128); @@ -783,8 +737,6 @@ mod tests { assert_eq!(buf.into_string(), "/pets"); } - // ── multipart form-body builder ──────────────────────────────────────────── - #[test] fn multipart_builder_renders_required_scalar_as_unguarded_append() { let scalar = BodyFieldType::Scalar(SchemaScalar::String); @@ -793,7 +745,7 @@ mod tests { ]); let mut buf = Writer::with_capacity(512); - render_requestful_builder(&mut buf, &op, "OpParams"); + render_requestful_builder(&mut buf, &op, &type_name("OpParams")); let out = buf.into_string(); // Form fields are destructured directly from `request` (smart-flatten @@ -812,7 +764,7 @@ mod tests { ("nickname", true, &scalar), // optional ]); let mut buf = Writer::with_capacity(512); - render_requestful_builder(&mut buf, &op, "OpParams"); + render_requestful_builder(&mut buf, &op, &type_name("OpParams")); let out = buf.into_string(); assert!(out.contains("if (nickname !== undefined) fd.append('nickname', String(nickname));")); } @@ -822,7 +774,7 @@ mod tests { let arr = BodyFieldType::ArrayOfScalar(SchemaScalar::Number); let op = op_with_multipart_fields(vec![("tagIds", false, &arr)]); let mut buf = Writer::with_capacity(512); - render_requestful_builder(&mut buf, &op, "OpParams"); + render_requestful_builder(&mut buf, &op, &type_name("OpParams")); let out = buf.into_string(); assert!(out.contains("for (const v of tagIds) fd.append('tagIds', String(v));")); assert!(!out.contains("if (tagIds")); // required ⇒ no guard @@ -833,7 +785,7 @@ mod tests { let binary = BodyFieldType::Binary; let op = op_with_multipart_fields(vec![("avatar", false, &binary)]); let mut buf = Writer::with_capacity(512); - render_requestful_builder(&mut buf, &op, "OpParams"); + render_requestful_builder(&mut buf, &op, &type_name("OpParams")); let out = buf.into_string(); assert!(out.contains("fd.append('avatar', avatar);")); assert!(!out.contains("String(avatar)")); @@ -844,21 +796,19 @@ mod tests { let arr_binary = BodyFieldType::ArrayOfBinary; let op = op_with_multipart_fields(vec![("galleries", false, &arr_binary)]); let mut buf = Writer::with_capacity(512); - render_requestful_builder(&mut buf, &op, "OpParams"); + render_requestful_builder(&mut buf, &op, &type_name("OpParams")); let out = buf.into_string(); assert!(out.contains("for (const v of galleries) fd.append('galleries', v);")); assert!(!out.contains("String(v)")); } - // ── url-encoded form-body builder ────────────────────────────────────────── - #[test] fn urlencoded_builder_uses_url_search_params_constructor() { let scalar = BodyFieldType::Scalar(SchemaScalar::String); let arr = BodyFieldType::ArrayOfScalar(SchemaScalar::Number); let op = op_with_urlencoded_fields(vec![("status", false, &scalar), ("tagIds", true, &arr)]); let mut buf = Writer::with_capacity(512); - render_requestful_builder(&mut buf, &op, "OpParams"); + render_requestful_builder(&mut buf, &op, &type_name("OpParams")); let out = buf.into_string(); assert!(out.contains("const params = new URLSearchParams();")); assert!(out.contains("params.append('status', String(status));")); diff --git a/src/emit/angular/service.rs b/src/emit/angular/service.rs index 74f47d8..4492cc6 100644 --- a/src/emit/angular/service.rs +++ b/src/emit/angular/service.rs @@ -1,9 +1,7 @@ -use std::fmt::Write as _; - -use crate::emit::typescript::{Position, Writer, render_type}; +use crate::emit::ts::{Doc, Position, Render, Writer, jsdoc, w}; +use crate::ident::TypeName; use crate::ir::canonical::ResponseContent; use crate::plan::artifact_plan::{PlannedOperation, ServicePlan}; -use crate::plan::naming::{error_interface_name, request_interface_name}; use super::imports::render_service_imports; use super::request::{ @@ -12,9 +10,8 @@ use super::request::{ }; pub(crate) fn emit_service(service_plan: &ServicePlan<'_>) -> String { - // Each operation produces ~512 bytes (request interface + factory - // triplet + URL/body construction); 2KB floor covers the @Injectable - // header + import block. + // Roughly 512 bytes per operation, over a floor covering the class + // header and the import block. let capacity = (service_plan.operations.len() * 512).max(2048); let mut buffer = Writer::with_capacity(capacity); @@ -25,75 +22,48 @@ pub(crate) fn emit_service(service_plan: &ServicePlan<'_>) -> String { buffer.line("})"); buffer.open_block(&format!("export class {}", service_plan.class_name)); - // Cache request interface names computed once per operation - let request_names: std::collections::HashMap<&str, String> = service_plan - .operations - .iter() - .filter(|operation| has_request_interface(operation)) - .map(|operation| { - ( - operation.method_name.as_str(), - request_interface_name(&operation.method_name), - ) - }) - .collect(); - for operation in &service_plan.operations { buffer.blank_line(); - render_operation_property( - &mut buffer, - operation, - request_names.get(operation.method_name.as_str()), - ); + render_operation_property(&mut buffer, operation); } buffer.close_block(""); - // Per-operation tail: for each operation, emit its `{Pascal}Params` - // interface (when the operation has any inputs) followed by its - // `{Pascal}Error` interface (when it declares any 4xx/5xx with a JSON - // schema). Per-operation grouping beats kind-grouping when the file - // grows long — a reader searching for "UpdatePet" finds the property, - // its params, and its error map contiguously. + // Each operation's interfaces follow the class, grouped so that one + // operation's declarations stay contiguous. for operation in &service_plan.operations { - let request_name = request_names.get(operation.method_name.as_str()); - let has_errors = !operation.errors.is_empty(); - if request_name.is_none() && !has_errors { + if operation.request_interface.is_none() && operation.error_interface.is_none() { continue; } buffer.blank_line(); - if let Some(name) = request_name { + if let Some(name) = &operation.request_interface { render_request_interface(&mut buffer, operation, name); } - if has_errors { - if request_name.is_some() { + if let Some(name) = &operation.error_interface { + if operation.request_interface.is_some() { buffer.blank_line(); } - let error_name = error_interface_name(&operation.method_name); - render_error_interface(&mut buffer, operation, &error_name); + render_error_interface(&mut buffer, operation, name); } } buffer.into_string() } -fn render_operation_property( - buffer: &mut Writer, - operation: &PlannedOperation<'_>, - request_name: Option<&String>, -) { - let property_name = &operation.method_name; - - crate::emit::typescript::jsdoc( +fn render_operation_property(buffer: &mut Writer, operation: &PlannedOperation<'_>) { + jsdoc( buffer, - operation.description.as_deref(), - operation.deprecated, + Doc::new(operation.description.as_deref(), operation.deprecated), + ); + w!(buffer, "readonly {} = ", operation.method_name); + write_response_call_site( + buffer, + operation.response, + operation.request_interface.as_ref(), ); - write!(buffer, "readonly {property_name} = ").unwrap(); - write_response_call_site(buffer, operation.response, request_name); buffer.push("(\n"); buffer.indent(); - match request_name { + match &operation.request_interface { Some(name) => render_requestful_builder(buffer, operation, name), None => render_zero_arg_builder(buffer, operation), } @@ -101,29 +71,19 @@ fn render_operation_property( buffer.line(");"); } -const fn has_request_interface(operation: &PlannedOperation<'_>) -> bool { - !operation.request.fields.is_empty() - || operation.request.body.is_some() - || !operation.request.headers.is_empty() -} - -/// Writes the full helper call prefix into `buffer`. The arity of the -/// operation (does it take a typed `Request`?) and the response variant -/// pick one of four call shapes — explicit at the generator boundary, -/// so the runtime no longer needs the `reqFn.length === 0` probe. -/// -/// Mapping (see docs/superpowers/specs/2026-05-19-request-factory-variants-design.md): +/// Writes the helper call prefix. The operation's arity (does it take a +/// typed request?) and its response variant pick one of four call shapes: /// -/// | | Requestful | Zero-arg | -/// |----------------|-------------------------------|----------------------------------------| -/// | JSON / void | `requestFactory` | `requestFactory.zeroArg` | -/// | Blob | `requestFactory.blob` | `requestFactory.zeroArg.blob` | -/// | Text | `requestFactory.text` | `requestFactory.zeroArg.text` | +/// | | Requestful | Zero-arg | +/// |----------------|-----------------------------------|--------------------------------------| +/// | JSON / void | `requestFactory` | `requestFactory.zeroArg` | +/// | Blob | `requestFactory.blob` | `requestFactory.zeroArg.blob` | +/// | Text | `requestFactory.text` | `requestFactory.zeroArg.text` | /// | ArrayBuffer | `requestFactory.arrayBuffer` | `requestFactory.zeroArg.arrayBuffer` | fn write_response_call_site( buffer: &mut Writer, response: Option<&ResponseContent>, - request_name: Option<&String>, + request_name: Option<&TypeName>, ) { let variant = match response { Some(ResponseContent::Blob) => Some("blob"), @@ -134,13 +94,13 @@ fn write_response_call_site( match (variant, request_name) { (Some(kind), Some(request)) => { - write!(buffer, "requestFactory.{kind}<{request}>").unwrap(); + w!(buffer, "requestFactory.{kind}<{request}>"); } (Some(kind), None) => { - write!(buffer, "requestFactory.zeroArg.{kind}").unwrap(); + w!(buffer, "requestFactory.zeroArg.{kind}"); } (None, Some(request)) => { - write!(buffer, "requestFactory<{request}, ").unwrap(); + w!(buffer, "requestFactory<{request}, "); write_response_type(buffer, response); buffer.push(">"); } @@ -155,7 +115,7 @@ fn write_response_call_site( fn write_response_type(buffer: &mut Writer, response: Option<&ResponseContent>) { match response { Some(ResponseContent::Json(Some(ty))) => { - render_type(buffer, ty, Position::Standalone); + ty.render(buffer, Position::Standalone); } Some(ResponseContent::Json(None)) | None => { buffer.push("void"); @@ -180,10 +140,9 @@ mod tests { // use the static-method variant (requestFactory.blob(…) etc.) — // no Response generic and no { responseKind: '…' } option line. - fn render_property(op: &PlannedOperation<'_>, request_name: &str) -> String { + fn render_property(op: &PlannedOperation<'_>) -> String { let mut buf = Writer::with_capacity(512); - let owned = request_name.to_string(); - render_operation_property(&mut buf, op, Some(&owned)); + render_operation_property(&mut buf, op); buf.into_string() } @@ -210,7 +169,7 @@ mod tests { let str_ty = string_ty(); let json = ResponseContent::Json(Some(SchemaType::Scalar(SchemaScalar::String))); let op = op_with_response_and_path("listPets", &str_ty, &json); - let out = render_property(&op, "ListPetsParams"); + let out = render_property(&op); assert!( out.contains("requestFactory"), @@ -232,7 +191,7 @@ mod tests { fn request_factory_call_uses_blob_variant_for_blob_response() { let str_ty = string_ty(); let op = op_with_response_and_path("download", &str_ty, &ResponseContent::Blob); - let out = render_property(&op, "DownloadParams"); + let out = render_property(&op); assert!( out.contains("requestFactory.blob"), @@ -252,7 +211,7 @@ mod tests { fn request_factory_call_uses_text_variant_for_text_response() { let str_ty = string_ty(); let op = op_with_response_and_path("rawConfig", &str_ty, &ResponseContent::Text); - let out = render_property(&op, "RawConfigParams"); + let out = render_property(&op); assert!( out.contains("requestFactory.text"), @@ -272,7 +231,7 @@ mod tests { fn request_factory_call_uses_array_buffer_variant_for_array_buffer_response() { let str_ty = string_ty(); let op = op_with_response_and_path("fetch", &str_ty, &ResponseContent::ArrayBuffer); - let out = render_property(&op, "FetchParams"); + let out = render_property(&op); assert!( out.contains("requestFactory.arrayBuffer"), @@ -312,7 +271,7 @@ mod tests { fn render_zero_arg_property(op: &PlannedOperation<'_>) -> String { let mut buf = Writer::with_capacity(512); - render_operation_property(&mut buf, op, None); + render_operation_property(&mut buf, op); buf.into_string() } diff --git a/src/emit/emitter.rs b/src/emit/emitter.rs new file mode 100644 index 0000000..cd342fa --- /dev/null +++ b/src/emit/emitter.rs @@ -0,0 +1,86 @@ +//! The emit-target registry: each [`EmitTarget`] maps to the artifacts it +//! produces, and every artifact path and template lives here. + +use crate::bindings::EmitTarget; +use crate::plan::GenerationPlan; +use crate::result::GeneratedArtifact; + +use super::MODEL_ARTIFACT_PATH; +use super::angular::{ + REST_MODEL_PATH, REST_MODEL_TEMPLATE, REST_UTIL_PATH, REST_UTIL_TEMPLATE, REST_VALIDATE_PATH, + REST_VALIDATE_TEMPLATE, emit_service, +}; +use super::model::emit_ts_models::emit_model; + +/// A family of generated files. +pub(crate) trait Emitter { + /// Produces this target's artifacts, in the order they should be + /// emitted. Returns none when the plan gives the target nothing to do. + fn artifacts(&self, plan: &GenerationPlan<'_>) -> Vec; +} + +/// The emitters `target` contributes, in emit order. +pub(crate) fn emitters_for(target: EmitTarget) -> Vec> { + match target { + EmitTarget::Models => vec![Box::new(TsModels)], + EmitTarget::Angular => vec![ + Box::new(StaticTemplate::new(REST_MODEL_PATH, REST_MODEL_TEMPLATE)), + Box::new(StaticTemplate::new(REST_UTIL_PATH, REST_UTIL_TEMPLATE)), + Box::new(StaticTemplate::new( + REST_VALIDATE_PATH, + REST_VALIDATE_TEMPLATE, + )), + Box::new(AngularServices), + ], + } +} + +/// The TypeScript model file. Emits nothing when the spec declares no +/// schemas. +struct TsModels; + +impl Emitter for TsModels { + fn artifacts(&self, plan: &GenerationPlan<'_>) -> Vec { + if plan.schemas.is_empty() { + return Vec::new(); + } + vec![GeneratedArtifact::new( + MODEL_ARTIFACT_PATH.to_string(), + emit_model(plan.schemas, &plan.mapped_types), + )] + } +} + +/// A support file copied verbatim from `templates/`. +struct StaticTemplate { + path: &'static str, + body: &'static str, +} + +impl StaticTemplate { + const fn new(path: &'static str, body: &'static str) -> Self { + Self { path, body } + } +} + +impl Emitter for StaticTemplate { + fn artifacts(&self, _plan: &GenerationPlan<'_>) -> Vec { + vec![GeneratedArtifact::new( + self.path.to_string(), + self.body.to_string(), + )] + } +} + +/// One Angular service per planned group, in `plan.services` order. +struct AngularServices; + +impl Emitter for AngularServices { + fn artifacts(&self, plan: &GenerationPlan<'_>) -> Vec { + plan + .services + .iter() + .map(|service| GeneratedArtifact::new(service.artifact_path.clone(), emit_service(service))) + .collect() + } +} diff --git a/src/emit/mod.rs b/src/emit/mod.rs index e1a6695..ad01714 100644 --- a/src/emit/mod.rs +++ b/src/emit/mod.rs @@ -1,9 +1,12 @@ pub(crate) mod angular; +mod emitter; pub(crate) mod model; -pub(crate) mod typescript; +pub(crate) mod ts; + +pub(crate) use emitter::emitters_for; #[cfg(test)] -mod typescript_tests; +mod ts_tests; /// Public path of the generated TypeScript model artifact. pub(crate) const MODEL_ARTIFACT_PATH: &str = "model.generated.ts"; @@ -12,29 +15,18 @@ pub(crate) const MODEL_ARTIFACT_PATH: &str = "model.generated.ts"; /// Cargo.toml (the same value `package.json:3` mirrors). const GENERATOR_VERSION: &str = env!("CARGO_PKG_VERSION"); -/// Top-of-file banner prepended to every generated artifact. -/// Shape: -/// // Generated by openapi-ng vX.Y.Z -/// // Source: -/// // DO NOT EDIT — regenerate with `openapi-ng generate ...` -/// -/// Three lines so editors that wrap the first line do not hide the -/// generator name. The `// DO NOT EDIT` line carries the en-dash so -/// consumers searching for `eslint-plugin-no-edit-generated`-style -/// patterns find a stable anchor. +/// Builds the three-line banner every generated artifact opens with: /// -/// Computed once per pipeline run in `pipeline::run_pipeline` and threaded -/// into every emit call site as `banner: &str`, so a large spec's N -/// artifacts share one allocation instead of paying `format!` per file. +/// ```text +/// // Generated by openapi-ng vX.Y.Z +/// // Source: +/// // DO NOT EDIT — regenerate with `openapi-ng generate` +/// ``` /// -/// Security: absolute paths that resolve INSIDE the current working -/// directory are relativised against CWD before being embedded. This -/// prevents programmatic consumers (and the CLI, since the JS wrapper -/// now passes the raw user input) from leaking local filesystem layout -/// (e.g. `/Users/alice/work/spec.yaml`) into committed artifacts. -/// Paths outside CWD are left absolute so the leak is bounded to the -/// "spec lives outside the project root" case, and on a missing CWD -/// we fall back to the input path verbatim. +/// A `source_path` inside the working directory is relativised against +/// it, keeping the local directory layout out of a committed artifact. +/// One outside stays absolute, as does any path when the working +/// directory is unknown. pub(crate) fn render_generated_banner(source_path: &str) -> String { let display = relativise_against_cwd(source_path); format!( @@ -92,13 +84,8 @@ mod banner_tests { assert!(banner.contains(&format!("v{expected}"))); } - /// Security regression: when given an absolute path that lives inside - /// the current working directory, the banner must render the relative - /// form so the absolute CWD prefix never lands in a committed - /// artifact. Mirrors the JS-side `relativizeForBanner` that previously - /// did this at the CLI boundary; the Rust side now owns it so - /// programmatic consumers (`generate({ inputPath: '/abs/...' })`) get - /// the same treatment as the CLI. + /// An absolute path inside the working directory must render relative, + /// so no local directory prefix reaches a committed artifact. #[test] fn banner_relativises_paths_inside_cwd() { let cwd = std::env::current_dir().unwrap(); diff --git a/src/emit/model/emit_ts_models.rs b/src/emit/model/emit_ts_models.rs index ad9aefa..64bea99 100644 --- a/src/emit/model/emit_ts_models.rs +++ b/src/emit/model/emit_ts_models.rs @@ -1,224 +1,136 @@ -use std::fmt::Write as _; +use std::collections::{BTreeMap, BTreeSet}; use crate::{ - emit::typescript::{self as ts, Position, Writer, render_type, write_import_line}, + emit::ts::{ + Binding, Doc, Member, Position, Render, Statement, Writer, import_line, interface_block, jsdoc, + string_union, type_alias, type_reexport_line, w, + }, ir::{ canonical::ModelSymbol, schema::{SchemaProperty, SchemaType}, }, plan::artifact_plan::ResolvedMappedType, }; -use std::collections::{BTreeMap, BTreeSet}; pub(crate) fn emit_model( - model_symbols: &[ModelSymbol], + symbols: &[ModelSymbol], mapped_types: &[ResolvedMappedType<'_>], ) -> String { - // Heuristic: each named model symbol expands to ~256 bytes of TS once - // mapped imports are factored in. Pre-sizing the buffer avoids 4-5 - // reallocs for petstore-rich-sized specs. - let capacity = (model_symbols.len() * 256).max(1024); - let mut output = Writer::with_capacity(capacity); - - emit_mapped_imports(mapped_types, &mut output); - - let mapped_by_name: BTreeMap<&str, &ResolvedMappedType<'_>> = - mapped_types.iter().map(|m| (m.schema, m)).collect(); - - // `emit_mapped_imports` writes exactly one line per `mapped_type` (either an - // import or a re-export) — so non-empty input is sufficient to know we - // emitted something and need a blank-line separator before the model body. - if !mapped_types.is_empty() && !model_symbols.is_empty() { - output.blank_line(); + // Roughly 256 bytes of TypeScript per named symbol. + let mut out = Writer::with_capacity((symbols.len() * 256).max(1024)); + + emit_mapped_imports(mapped_types, &mut out); + + let mapped_by_name: BTreeMap<&str, &ResolvedMappedType<'_>> = mapped_types + .iter() + .map(|mapped| (mapped.schema, mapped)) + .collect(); + + // `emit_mapped_imports` writes one line per mapped type. + if !mapped_types.is_empty() && !symbols.is_empty() { + out.blank_line(); } let mut first = true; - - for symbol in model_symbols { + for symbol in symbols { let name = symbol.name.as_ref(); - if let Some(mapped_type) = mapped_by_name.get(name) { - // Re-export self-aliases are emitted as `export type { ... } from - // '...'` in the imports block above — skip the placeholder alias - // entirely (`export type X = X;` would collide with the imported - // binding). - if is_self_alias(mapped_type) { - continue; - } - if !first { - output.blank_line(); - } - first = false; - emit_mapped_placeholder(name, mapped_type, &mut output); + let mapped = mapped_by_name.get(name).copied(); + + // A self-aliasing mapped type was already written as a re-export. + if mapped.is_some_and(|mapped| is_self_alias(mapped)) { continue; } - if !first { - output.blank_line(); + out.blank_line(); } first = false; - match &symbol.body { - SchemaType::InlineObject { properties } => emit_interface( - name, - symbol.description.as_deref(), - symbol.deprecated, - properties, - &mut output, - ), - SchemaType::StringLiterals { values } => emit_enum( - name, - symbol.description.as_deref(), - symbol.deprecated, - values, - &mut output, - ), - other => emit_type_alias( - name, - symbol.description.as_deref(), - symbol.deprecated, - other, - &mut output, - ), + match mapped { + Some(mapped) => type_alias(&mut out, name, Doc::default(), native_binding(mapped)), + None => emit_symbol(symbol, &mut out), } } - let mut rendered = output.into_string(); + let mut rendered = out.into_string(); if !rendered.ends_with('\n') { rendered.push('\n'); } rendered } -/// A mapped type is a *self-alias* when the binding it introduces into -/// the file (the alias if set, otherwise the imported type name) already -/// matches the schema name. In that case the regular `import type { Y as -/// X } from '...';` + `export type X = X;` pair would collide on the -/// `X` identifier, so we collapse to a single `export type { Y as X } -/// from '...';` re-export and skip the alias placeholder. -fn is_self_alias(mapped_type: &ResolvedMappedType<'_>) -> bool { - let binding_name = mapped_type - .alias - .as_deref() - .unwrap_or_else(|| mapped_type.ty.as_ref()); - binding_name == mapped_type.schema -} - -fn emit_mapped_imports(mapped_types: &[ResolvedMappedType<'_>], output: &mut Writer) { - // Group by import path, partitioning each path's entries into - // re-exports and regular imports so the emitted block has a stable - // ordering: regular imports first (deterministic per-path), then - // re-exports. - let mut imports_by_path = BTreeMap::<&str, BTreeSet<(&str, Option<&str>)>>::new(); - let mut reexports_by_path = BTreeMap::<&str, BTreeSet<(&str, &str)>>::new(); - - for mapped_type in mapped_types { - if is_self_alias(mapped_type) { - // `export type { ty as schema }` — when `ty == schema`, drop the - // alias rename so the line stays `export type { X } from '...'`. - let imported = mapped_type.ty.as_ref(); - let exported_as = mapped_type.schema; - reexports_by_path - .entry(mapped_type.import.as_ref()) - .or_default() - .insert((imported, exported_as)); - } else { - imports_by_path - .entry(mapped_type.import.as_ref()) - .or_default() - .insert((mapped_type.ty.as_ref(), mapped_type.alias.as_deref())); +fn emit_symbol(symbol: &ModelSymbol, out: &mut Writer) { + let doc = Doc::new(symbol.description.as_deref(), symbol.deprecated); + let name = symbol.name.as_ref(); + match &symbol.body { + SchemaType::InlineObject { properties } if properties.is_empty() => { + type_alias(out, name, doc, "Record"); + } + SchemaType::InlineObject { properties } => { + interface_block(out, name, doc, properties.iter().map(member), true); + } + SchemaType::StringLiterals { values } => string_union(out, name, doc, values), + other => { + jsdoc(out, doc); + w!(out, "export type {name} = "); + other.render(out, Position::Standalone); + out.push(";\n"); } - } - - for (import_path, type_names) in &imports_by_path { - write_import_line(output, type_names.iter().copied(), import_path, true); - } - - for (import_path, entries) in &reexports_by_path { - write_reexport_line(output, entries, import_path); } } -fn write_reexport_line(output: &mut Writer, entries: &BTreeSet<(&str, &str)>, import_path: &str) { - output.push("export type { "); - let mut first = true; - for (imported, exported_as) in entries { - if !first { - output.push(", "); - } - first = false; - output.push(imported); - if imported != exported_as { - output.push(" as "); - output.push(exported_as); - } +fn member(property: &SchemaProperty) -> Member<'_> { + Member { + name: property.name.as_ref(), + optional: !property.required, + ty: &property.ty, + doc: Doc::new(property.description.as_deref(), property.deprecated), } - output.push(" } from '"); - output.push(import_path); - output.push("';\n"); } -fn emit_mapped_placeholder(name: &str, mapped_type: &ResolvedMappedType<'_>, output: &mut Writer) { - let native_type = mapped_type +/// The name a mapped type introduces into the file. +fn native_binding<'a>(mapped: &'a ResolvedMappedType<'_>) -> &'a str { + mapped .alias .as_deref() - .unwrap_or_else(|| mapped_type.ty.as_ref()); - ts::type_alias(output, name, None, false, native_type); + .unwrap_or_else(|| mapped.ty.as_ref()) } -fn emit_type_alias( - name: &str, - description: Option<&str>, - deprecated: bool, - target: &SchemaType, - output: &mut Writer, -) { - ts::jsdoc(output, description, deprecated); - write!(output, "export type {name} = ").unwrap(); - render_type(output, target, Position::Standalone); - output.push(";\n"); +/// True when the binding a mapped type introduces already equals the schema +/// name it replaces. The usual `import type { Y as X }` plus +/// `export type X = X;` pair would then collide on `X`, so the pair +/// collapses to a single re-export. +fn is_self_alias(mapped: &ResolvedMappedType<'_>) -> bool { + native_binding(mapped) == mapped.schema } -fn emit_enum( - name: &str, - description: Option<&str>, - deprecated: bool, - values: &[String], - output: &mut Writer, -) { - ts::string_union(output, name, description, deprecated, values); -} +/// Emits the mapped types' import block: regular imports first, grouped by +/// path, then the re-exports. +fn emit_mapped_imports(mapped_types: &[ResolvedMappedType<'_>], out: &mut Writer) { + let mut imports = BTreeMap::<&str, BTreeSet<(&str, Option<&str>)>>::new(); + let mut reexports = BTreeMap::<&str, BTreeSet<(&str, &str)>>::new(); + + for mapped in mapped_types { + let path = mapped.import.as_ref(); + if is_self_alias(mapped) { + reexports + .entry(path) + .or_default() + .insert((mapped.ty.as_ref(), mapped.schema)); + } else { + imports + .entry(path) + .or_default() + .insert((mapped.ty.as_ref(), mapped.alias.as_deref())); + } + } -fn emit_interface( - name: &str, - description: Option<&str>, - deprecated: bool, - properties: &[SchemaProperty], - output: &mut Writer, -) { - if properties.is_empty() { - ts::type_alias( - output, - name, - description, - deprecated, - "Record", - ); - return; + for (path, bindings) in &imports { + let bindings = bindings + .iter() + .map(|&(name, alias)| Binding { name, alias }); + import_line(out, bindings, path, Statement::TypeImport); + } + for (path, entries) in &reexports { + type_reexport_line(out, entries, path); } - ts::interface_block( - output, - name, - description, - deprecated, - properties.iter().map(|p| { - ( - p.name.as_ref(), - !p.required, - &p.ty, - p.description.as_deref(), - p.deprecated, - ) - }), - true, - ); } diff --git a/src/emit/ts/decl.rs b/src/emit/ts/decl.rs new file mode 100644 index 0000000..aa11265 --- /dev/null +++ b/src/emit/ts/decl.rs @@ -0,0 +1,132 @@ +//! Declaration-level emit: JSDoc, interfaces, type aliases, literal unions. + +use crate::ir::schema::SchemaType; + +use super::literal::quoted; +use super::types::property_declaration; +use super::writer::{Writer, wln}; + +/// Width below which a top-level literal union stays on one line. Counts +/// the joined `'a' | 'b'` form, not the `export type X = ` prefix, and +/// matches prettier's default `printWidth`. +const UNION_INLINE_WIDTH: usize = 80; + +/// The JSDoc a declaration carries. +#[derive(Clone, Copy, Default)] +pub(crate) struct Doc<'a> { + pub(crate) description: Option<&'a str>, + /// The source declared `deprecated: true`; renders as an `@deprecated` + /// tag so IDEs mark the reference site. + pub(crate) deprecated: bool, +} + +impl<'a> Doc<'a> { + pub(crate) const fn new(description: Option<&'a str>, deprecated: bool) -> Self { + Self { + description, + deprecated, + } + } + + /// Prose with trailing whitespace trimmed, or `None` when it is empty. + fn prose(self) -> Option<&'a str> { + self + .description + .map(str::trim_end) + .filter(|text| !text.is_empty()) + } + + fn is_empty(self) -> bool { + self.prose().is_none() && !self.deprecated + } +} + +/// Emits `doc` as a JSDoc block, or nothing when it carries neither prose +/// nor a deprecation. +pub(crate) fn jsdoc(out: &mut Writer, doc: Doc<'_>) { + if doc.is_empty() { + return; + } + out.line("/**"); + if let Some(text) = doc.prose() { + for line in text.lines() { + let body = line.trim_end(); + if body.is_empty() { + out.line(" *"); + } else { + wln!(out, " * {}", body.replace("*/", "*\\/")); + } + } + } + if doc.deprecated { + out.line(" * @deprecated"); + } + out.line(" */"); +} + +/// One member of an emitted interface. +pub(crate) struct Member<'a> { + pub(crate) name: &'a str, + pub(crate) optional: bool, + pub(crate) ty: &'a SchemaType, + pub(crate) doc: Doc<'a>, +} + +/// Emits `interface {name} { ... }`, exported unless `exported` is false. +pub(crate) fn interface_block<'a>( + out: &mut Writer, + name: &str, + doc: Doc<'_>, + members: impl IntoIterator>, + exported: bool, +) { + jsdoc(out, doc); + let keyword = if exported { + "export interface " + } else { + "interface " + }; + out.open_block(&format!("{keyword}{name}")); + for member in members { + jsdoc(out, member.doc); + property_declaration(out, member.name, member.optional, member.ty); + out.push(";\n"); + } + out.close_block(""); +} + +/// Emits `export type {name} = {rhs};`. +pub(crate) fn type_alias(out: &mut Writer, name: &str, doc: Doc<'_>, rhs: &str) { + jsdoc(out, doc); + wln!(out, "export type {name} = {rhs};"); +} + +/// Emits a string-literal union, collapsing to one line when it fits +/// [`UNION_INLINE_WIDTH`]. +pub(crate) fn string_union(out: &mut Writer, name: &str, doc: Doc<'_>, values: &[String]) { + jsdoc(out, doc); + + // Upper bound on the joined width. Byte length over-counts the visual + // width of non-ASCII values, which can only force an extra wrap. + let separators = values.len().saturating_sub(1) * " | ".len(); + let quotes: usize = values.iter().map(|value| value.len() + 2).sum(); + + if separators + quotes <= UNION_INLINE_WIDTH { + let inline = values + .iter() + .map(|value| quoted(value)) + .collect::>() + .join(" | "); + wln!(out, "export type {name} = {inline};"); + return; + } + + wln!(out, "export type {name} ="); + out.indent(); + let last = values.len().saturating_sub(1); + for (index, value) in values.iter().enumerate() { + let terminator = if index == last { ";" } else { "" }; + wln!(out, "| {}{terminator}", quoted(value)); + } + out.dedent(); +} diff --git a/src/emit/ts/imports.rs b/src/emit/ts/imports.rs new file mode 100644 index 0000000..5afbbf7 --- /dev/null +++ b/src/emit/ts/imports.rs @@ -0,0 +1,135 @@ +//! `import` and `export … from` statement emit. + +use std::collections::{BTreeMap, BTreeSet}; + +use super::writer::Writer; + +/// Width above which a statement wraps to one identifier per line. +/// +/// Matches prettier's wrap point, which keeps a consumer's first `format` +/// run a no-op and regeneration an empty diff. +const INLINE_WIDTH: usize = 100; + +/// One imported or re-exported name, optionally renamed. +#[derive(Clone, Copy)] +pub(crate) struct Binding<'a> { + pub(crate) name: &'a str, + pub(crate) alias: Option<&'a str>, +} + +impl<'a> Binding<'a> { + pub(crate) const fn plain(name: &'a str) -> Self { + Self { name, alias: None } + } + + const fn renamed(name: &'a str, alias: &'a str) -> Self { + Self { + name, + alias: Some(alias), + } + } + + fn width(self) -> usize { + self.name.len() + self.alias.map_or(0, |alias| " as ".len() + alias.len()) + } + + fn write(self, out: &mut Writer) { + out.push(self.name); + if let Some(alias) = self.alias { + out.push(" as "); + out.push(alias); + } + } +} + +/// Emits one `import type { … } from '…';` per path, names in iteration +/// order. +pub(crate) fn type_import_block(out: &mut Writer, by_path: &BTreeMap<&str, BTreeSet<&str>>) { + for (path, names) in by_path { + import_line( + out, + names.iter().copied().map(Binding::plain), + path, + Statement::TypeImport, + ); + } +} + +/// Which statement keyword the bindings belong to. +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum Statement { + TypeImport, + /// `export type { … } from '…'` — re-exports the names instead of + /// binding them locally. + TypeReexport, +} + +impl Statement { + const fn open(self) -> &'static str { + match self { + Self::TypeImport => "import type { ", + Self::TypeReexport => "export type { ", + } + } + + const fn open_wrapped(self) -> &'static str { + match self { + Self::TypeImport => "import type {\n", + Self::TypeReexport => "export type {\n", + } + } +} + +/// Emits one statement binding `bindings` from `path`, wrapping when the +/// single-line form would exceed [`INLINE_WIDTH`]. +pub(crate) fn import_line<'a>( + out: &mut Writer, + bindings: impl IntoIterator>, + path: &str, + statement: Statement, +) { + // Buffered so the joined width can be measured before a layout is + // chosen. + let bindings: Vec> = bindings.into_iter().collect(); + let names: usize = bindings.iter().map(|binding| binding.width()).sum(); + let separators = bindings.len().saturating_sub(1) * ", ".len(); + let tail = " } from '".len() + path.len() + "';".len(); + + if statement.open().len() + names + separators + tail <= INLINE_WIDTH || bindings.len() <= 1 { + out.push(statement.open()); + for (index, binding) in bindings.iter().enumerate() { + if index > 0 { + out.push(", "); + } + binding.write(out); + } + out.push(" } from '"); + out.push(path); + out.push("';\n"); + return; + } + + out.push(statement.open_wrapped()); + out.indent(); + for binding in &bindings { + binding.write(out); + out.push(",\n"); + } + out.dedent(); + out.push("} from '"); + out.push(path); + out.push("';\n"); +} + +/// Emits `export type { … } from '…';`, dropping the rename where the +/// exported name already matches the imported one. +pub(crate) fn type_reexport_line(out: &mut Writer, entries: &BTreeSet<(&str, &str)>, path: &str) { + let bindings = entries.iter().map(|&(imported, exported)| { + if imported == exported { + Binding::plain(imported) + } else { + Binding::renamed(imported, exported) + } + }); + import_line(out, bindings, path, Statement::TypeReexport); +} diff --git a/src/emit/ts/literal.rs b/src/emit/ts/literal.rs new file mode 100644 index 0000000..7593a4d --- /dev/null +++ b/src/emit/ts/literal.rs @@ -0,0 +1,44 @@ +//! String-literal and property-name escaping. + +use std::borrow::Cow; + +use crate::ident::is_ident; + +/// Appends `value` to `out` as a single-quoted TypeScript string literal, +/// quotes included. +pub(crate) fn escape_into(out: &mut String, value: &str) { + out.reserve(value.len() + 2); + out.push('\''); + for ch in value.chars() { + match ch { + '\\' => out.push_str("\\\\"), + '\'' => out.push_str("\\'"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + _ => out.push(ch), + } + } + out.push('\''); +} + +/// `value` as a single-quoted TypeScript string literal. +pub(crate) fn quoted(value: &str) -> String { + let mut out = String::with_capacity(value.len() + 2); + escape_into(&mut out, value); + out +} + +/// Quotes `name` when it is not a bare identifier. +/// +/// Reserved words such as `class` or `default` are legal in property +/// position — an interface member is a `PropertyName`, which accepts any +/// `IdentifierName` — so only names outside the +/// `[A-Za-z_$][A-Za-z0-9_$]*` shape get quoted. +pub(crate) fn safe_property_name(name: &str) -> Cow<'_, str> { + if is_ident(name) { + Cow::Borrowed(name) + } else { + Cow::Owned(quoted(name)) + } +} diff --git a/src/emit/ts/mod.rs b/src/emit/ts/mod.rs new file mode 100644 index 0000000..848c597 --- /dev/null +++ b/src/emit/ts/mod.rs @@ -0,0 +1,15 @@ +//! TypeScript emit primitives. +//! +//! Parenthesization is decided by [`types::Position`] and indentation by +//! [`writer::Writer`]. + +pub(crate) mod decl; +pub(crate) mod imports; +pub(crate) mod literal; +pub(crate) mod types; +pub(crate) mod writer; + +pub(crate) use decl::{Doc, Member, interface_block, jsdoc, string_union, type_alias}; +pub(crate) use imports::{Binding, Statement, import_line, type_import_block, type_reexport_line}; +pub(crate) use types::{Position, Render, property_declaration}; +pub(crate) use writer::{Writer, w, wln}; diff --git a/src/emit/ts/types.rs b/src/emit/ts/types.rs new file mode 100644 index 0000000..5739185 --- /dev/null +++ b/src/emit/ts/types.rs @@ -0,0 +1,171 @@ +//! Type-expression rendering. + +use crate::ir::canonical::BodyFieldType; +use crate::ir::schema::{SchemaProperty, SchemaScalar, SchemaType}; + +use super::literal::safe_property_name; +use super::writer::Writer; + +/// Syntactic position of a rendered type, which decides whether a composite +/// needs parentheses so the surrounding operator binds correctly. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Position { + /// A type-alias right-hand side, a property type, a generic argument — + /// nothing binds tighter, so nothing is parenthesized. + Standalone, + /// A composition member (`A | B`, `A & B`) or an array element (`X[]`), + /// where a composite child must be parenthesized. + /// + /// An inline object never needs it: both `A & { x: T }` and `{ x: T }[]` + /// parse unambiguously. + Wrapped, +} + +/// A value with a TypeScript type expression. +pub(crate) trait Render { + /// Appends the type expression, parenthesized if `at` requires it. + fn render(&self, out: &mut Writer, at: Position); +} + +impl Render for SchemaType { + fn render(&self, out: &mut Writer, at: Position) { + if at == Position::Wrapped && self.is_composite() { + out.push("("); + self.render_inner(out); + out.push(")"); + } else { + self.render_inner(out); + } + } +} + +impl Render for BodyFieldType { + /// Binary parts surface as `Blob | File`, the union + /// `FormData.append` accepts. Arrays of binary keep the parentheses so + /// `(Blob | File)[]` does not read as `Blob | File[]`. + fn render(&self, out: &mut Writer, _at: Position) { + match self { + Self::Scalar(scalar) => out.push(scalar_keyword(scalar)), + Self::ArrayOfScalar(scalar) => { + out.push(scalar_keyword(scalar)); + out.push("[]"); + } + Self::Binary => out.push("Blob | File"), + Self::ArrayOfBinary => out.push("(Blob | File)[]"), + } + } +} + +/// Lets a reference stand in for the value it points at. +impl Render for &T { + fn render(&self, out: &mut Writer, at: Position) { + (**self).render(out, at); + } +} + +impl SchemaType { + const fn is_composite(&self) -> bool { + matches!( + self, + Self::Union { .. } | Self::Intersection(_) | Self::Nullable(_) + ) + } + + fn render_inner(&self, out: &mut Writer) { + match self { + Self::Any => out.push("unknown"), + Self::Scalar(scalar) => out.push(scalar_keyword(scalar)), + Self::Array(items) => { + items.render(out, Position::Wrapped); + out.push("[]"); + } + Self::Map(values) => { + out.push("Record"); + } + Self::StringLiterals { values } => render_literal_union(out, values), + Self::Ref(name) => out.push(name), + Self::Union { members, .. } => { + if members.is_empty() { + out.push("never"); + } else { + render_composition(out, members, " | "); + } + } + Self::Intersection(members) => render_composition(out, members, " & "), + Self::InlineObject { properties } => render_inline_object(out, properties), + Self::Nullable(inner) => { + // `A | B | null`, not `(A | B) | null`: the flat form mirrors + // OpenAPI 3.1's `oneOf: [A, B, null]`. + let at = if matches!(inner.as_ref(), Self::Union { .. }) { + Position::Standalone + } else { + Position::Wrapped + }; + inner.render(out, at); + out.push(" | null"); + } + } + } +} + +/// Appends `name`, its optional marker, and its type as an interface member +/// — without the trailing `;`. +pub(crate) fn property_declaration(out: &mut Writer, name: &str, optional: bool, ty: &impl Render) { + out.push(&safe_property_name(name)); + if optional { + out.push("?"); + } + out.push(": "); + ty.render(out, Position::Standalone); +} + +const fn scalar_keyword(scalar: &SchemaScalar) -> &'static str { + match scalar { + SchemaScalar::String => "string", + SchemaScalar::Number => "number", + SchemaScalar::Boolean => "boolean", + } +} + +fn render_composition(out: &mut Writer, members: &[SchemaType], separator: &str) { + for (index, member) in members.iter().enumerate() { + if index > 0 { + out.push(separator); + } + member.render(out, Position::Wrapped); + } +} + +fn render_literal_union(out: &mut Writer, values: &[String]) { + for (index, value) in values.iter().enumerate() { + if index > 0 { + out.push(" | "); + } + out.push(&super::literal::quoted(value)); + } +} + +fn render_inline_object(out: &mut Writer, properties: &[SchemaProperty]) { + if properties.is_empty() { + out.push("Record"); + return; + } + out.push("{\n"); + out.indent(); + for property in properties { + property_declaration(out, &property.name, !property.required, &property.ty); + out.push(";\n"); + } + out.dedent(); + out.push("}"); +} + +/// Renders `value` into a fresh `String`. +#[cfg(test)] +pub(crate) fn render_to_string(value: &impl Render) -> String { + let mut out = Writer::with_capacity(128); + value.render(&mut out, Position::Standalone); + out.into_string() +} diff --git a/src/emit/ts/writer.rs b/src/emit/ts/writer.rs new file mode 100644 index 0000000..5152915 --- /dev/null +++ b/src/emit/ts/writer.rs @@ -0,0 +1,174 @@ +//! The output buffer every emitter writes through. + +/// Indent-aware string writer. +/// +/// Tracks line-start state, so consecutive [`Writer::push`] calls share one +/// indent prefix without the caller threading it. Every method is +/// infallible: the sink is an in-memory `String`. +#[derive(Debug, Default)] +pub(crate) struct Writer { + buf: String, + indent_cache: String, + indent_level: usize, + line_start: bool, + last_was_blank: bool, +} + +impl Writer { + pub(crate) fn with_capacity(capacity: usize) -> Self { + Self { + buf: String::with_capacity(capacity), + indent_cache: String::new(), + indent_level: 0, + line_start: true, + last_was_blank: false, + } + } + + pub(crate) fn push(&mut self, value: &str) { + // Fast path for the mid-line token case, which needs no indent + // bookkeeping and no newline scan. + if !self.line_start && !value.contains('\n') { + if !value.is_empty() { + self.buf.push_str(value); + self.last_was_blank = false; + } + return; + } + + let mut rest = value; + while !rest.is_empty() { + if self.line_start { + if let Some(stripped) = rest.strip_prefix('\n') { + self.buf.push('\n'); + self.last_was_blank = true; + rest = stripped; + continue; + } + self.write_indent(); + } + + if let Some(newline) = rest.find('\n') { + self.buf.push_str(&rest[..=newline]); + let line_had_content = newline > 0; + rest = &rest[newline + 1..]; + self.line_start = true; + if line_had_content { + self.last_was_blank = false; + } + } else { + self.buf.push_str(rest); + self.line_start = false; + self.last_was_blank = false; + break; + } + } + } + + /// Appends formatted text. Prefer the `w!` / `wln!` macros at call + /// sites. + pub(crate) fn put(&mut self, args: std::fmt::Arguments<'_>) { + // A format string with no arguments is already a `&str`; only an + // interpolated one needs the intermediate allocation. + if let Some(literal) = args.as_str() { + self.push(literal); + } else { + self.push(&std::fmt::format(args)); + } + } + + pub(crate) fn line(&mut self, value: &str) { + let was_empty = value.is_empty() && self.line_start; + self.push(value); + self.buf.push('\n'); + self.line_start = true; + if was_empty { + self.last_was_blank = true; + } + } + + /// Ends the current line and leaves exactly one blank line behind. + /// Collapses repeats, and does nothing at the start of the buffer. + pub(crate) fn blank_line(&mut self) { + if self.buf.is_empty() || self.last_was_blank { + return; + } + if !self.buf.ends_with('\n') { + self.buf.push('\n'); + } + self.buf.push('\n'); + self.line_start = true; + self.last_was_blank = true; + } + + /// Writes `header` followed by ` {`, then indents. Pass an empty header + /// for a bare `{`. + pub(crate) fn open_block(&mut self, header: &str) { + if header.is_empty() { + self.line("{"); + } else { + self.push(header); + self.push(" {"); + self.end_line(); + } + self.indent(); + } + + /// Dedents, then writes `}` followed by `suffix`. + pub(crate) fn close_block(&mut self, suffix: &str) { + self.dedent(); + if suffix.is_empty() { + self.line("}"); + } else { + self.push("}"); + self.push(suffix); + self.end_line(); + } + } + + pub(crate) fn indent(&mut self) { + self.indent_level += 1; + self.indent_cache.push_str(" "); + } + + /// Panics when called more times than [`Writer::indent`]. + pub(crate) fn dedent(&mut self) { + self.indent_level = self + .indent_level + .checked_sub(1) + .expect("over-dedent in emitter"); + self.indent_cache.truncate(self.indent_level * 2); + } + + pub(crate) fn into_string(self) -> String { + self.buf + } + + fn end_line(&mut self) { + self.buf.push('\n'); + self.line_start = true; + self.last_was_blank = false; + } + + fn write_indent(&mut self) { + self.buf.push_str(&self.indent_cache); + self.line_start = false; + } +} + +/// Appends formatted text to a [`Writer`]. +macro_rules! w { + ($writer:expr, $($arg:tt)*) => { + $writer.put(::std::format_args!($($arg)*)) + }; +} + +/// Appends formatted text to a [`Writer`], then ends the line. +macro_rules! wln { + ($writer:expr, $($arg:tt)*) => {{ + $writer.put(::std::format_args!($($arg)*)); + $writer.push("\n"); + }}; +} + +pub(crate) use {w, wln}; diff --git a/src/emit/typescript_tests.rs b/src/emit/ts_tests.rs similarity index 76% rename from src/emit/typescript_tests.rs rename to src/emit/ts_tests.rs index 2469836..df2be81 100644 --- a/src/emit/typescript_tests.rs +++ b/src/emit/ts_tests.rs @@ -1,16 +1,15 @@ -// Tests for src/emit/typescript.rs — kept in a sibling file to keep typescript.rs -// focused on production logic. +//! Tests for the `emit::ts` primitives. #[cfg(test)] mod tests { - use super::super::typescript::*; + use super::super::ts::literal::safe_property_name; + use super::super::ts::types::render_to_string; + use super::super::ts::*; + use crate::ident::is_ident; use crate::ir::canonical::BodyFieldType; - use crate::ir::identifier::is_valid_identifier; use crate::ir::schema::{SchemaScalar, SchemaType}; use crate::test_support::{nullable_property, property}; - // ── Writer buffer ────────────────────────────────────────────────────────── - #[test] fn open_and_close_block_manage_indentation() { let mut buffer = Writer::with_capacity(4096); @@ -41,8 +40,6 @@ mod tests { ); } - // ── safe_property_name ───────────────────────────────────────────────────── - #[test] fn leaves_valid_identifiers_unquoted() { for name in ["id", "Pet", "pet_id", "$ref", "_name", "petId2"] { @@ -84,8 +81,6 @@ mod tests { assert_eq!(safe_property_name("a\tb").as_ref(), "'a\\tb'"); } - // ── render_type ──────────────────────────────────────────────────────────── - #[test] fn render_type_reference_covers_every_type_expression_variant() { let inline_object = SchemaType::InlineObject { @@ -140,7 +135,7 @@ mod tests { ]; for (value, expected) in cases { - assert_eq!(render_type_reference(&value), expected); + assert_eq!(render_to_string(&value), expected); } } @@ -162,9 +157,9 @@ mod tests { }, ]); - assert_eq!(render_type_reference(&array_of_union), "(Cat | Dog)[]"); + assert_eq!(render_to_string(&array_of_union), "(Cat | Dog)[]"); assert_eq!( - render_type_reference(&intersection_with_inline), + render_to_string(&intersection_with_inline), "AuditFields & {\n nickname?: string;\n}" ); } @@ -199,51 +194,49 @@ mod tests { }; assert_eq!( - render_type_reference(&nested_inline_object), + render_to_string(&nested_inline_object), "{\n profile: {\n displayName: string;\n metadata: {\n active: boolean;\n };\n };\n}" ); } - // ── render_body_field_type ───────────────────────────────────────────────── - #[test] fn render_body_field_type_for_each_variant() { assert_eq!( - render_body_field_type(&BodyFieldType::Scalar(SchemaScalar::String)), + render_to_string(&BodyFieldType::Scalar(SchemaScalar::String)), "string" ); assert_eq!( - render_body_field_type(&BodyFieldType::Scalar(SchemaScalar::Number)), + render_to_string(&BodyFieldType::Scalar(SchemaScalar::Number)), "number" ); assert_eq!( - render_body_field_type(&BodyFieldType::Scalar(SchemaScalar::Boolean)), + render_to_string(&BodyFieldType::Scalar(SchemaScalar::Boolean)), "boolean" ); assert_eq!( - render_body_field_type(&BodyFieldType::ArrayOfScalar(SchemaScalar::String)), + render_to_string(&BodyFieldType::ArrayOfScalar(SchemaScalar::String)), "string[]" ); assert_eq!( - render_body_field_type(&BodyFieldType::ArrayOfScalar(SchemaScalar::Number)), + render_to_string(&BodyFieldType::ArrayOfScalar(SchemaScalar::Number)), "number[]" ); + assert_eq!(render_to_string(&BodyFieldType::Binary), "Blob | File"); assert_eq!( - render_body_field_type(&BodyFieldType::Binary), - "Blob | File" - ); - assert_eq!( - render_body_field_type(&BodyFieldType::ArrayOfBinary), + render_to_string(&BodyFieldType::ArrayOfBinary), "(Blob | File)[]" ); } - // ── write_import_line wrapping ───────────────────────────────────────────── - #[test] fn write_import_line_emits_single_line_when_under_budget() { let mut out = Writer::with_capacity(4096); - write_import_line(&mut out, [("Pet", None), ("PetId", None)], "./models", true); + import_line( + &mut out, + [Binding::plain("Pet"), Binding::plain("PetId")], + "./models", + Statement::TypeImport, + ); assert_eq!( out.into_string(), "import type { Pet, PetId } from './models';\n" @@ -253,11 +246,14 @@ mod tests { #[test] fn write_import_line_emits_alias_form() { let mut out = Writer::with_capacity(4096); - write_import_line( + import_line( &mut out, - [("ExternalPetId", Some("PetId"))], + [Binding { + name: "ExternalPetId", + alias: Some("PetId"), + }], "@demo/types", - true, + Statement::TypeImport, ); assert_eq!( out.into_string(), @@ -271,16 +267,16 @@ mod tests { // writer should switch to one-identifier-per-line with trailing // commas (prettier-friendly). let mut out = Writer::with_capacity(4096); - let names: Vec<(&str, Option<&str>)> = vec![ - ("ResourceOneInterfaceWithExtraLongName", None), - ("ResourceTwoInterfaceWithExtraLongName", None), - ("ResourceThreeInterfaceWithExtraLongName", None), + let names = vec![ + Binding::plain("ResourceOneInterfaceWithExtraLongName"), + Binding::plain("ResourceTwoInterfaceWithExtraLongName"), + Binding::plain("ResourceThreeInterfaceWithExtraLongName"), ]; - write_import_line(&mut out, names, "./models", false); + import_line(&mut out, names, "./models", Statement::TypeImport); assert_eq!( out.into_string(), concat!( - "import {\n", + "import type {\n", " ResourceOneInterfaceWithExtraLongName,\n", " ResourceTwoInterfaceWithExtraLongName,\n", " ResourceThreeInterfaceWithExtraLongName,\n", @@ -294,14 +290,13 @@ mod tests { // A single identifier always stays on one line — wrapping a single // name is just noise. let mut out = Writer::with_capacity(4096); - write_import_line( + import_line( &mut out, - [( + [Binding::plain( "ExtremelyLongIdentifierNameThatWouldOtherwiseTriggerTheWrapHeuristicYesItWould", - None, )], "./models", - true, + Statement::TypeImport, ); let rendered = out.into_string(); assert!(rendered.starts_with("import type { ExtremelyLongIdentifier")); @@ -310,16 +305,13 @@ mod tests { assert_eq!(rendered.matches('\n').count(), 1); } - // ── string_union ─────────────────────────────────────────────────────────── - #[test] fn string_union_escapes_embedded_quotes_and_control_chars_inline() { let mut out = Writer::with_capacity(4096); string_union( &mut out, "Tricky", - None, - false, + Doc::new(None, false), &["it's".to_string(), "a\\b".to_string(), "x\ny".to_string()], ); assert_eq!( @@ -340,7 +332,7 @@ mod tests { format!("{long}-3"), ]; let mut out = Writer::with_capacity(4096); - string_union(&mut out, "Long", None, false, &values); + string_union(&mut out, "Long", Doc::new(None, false), &values); let rendered = out.into_string(); assert!(rendered.contains("| 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1\\'a'\n")); assert!(rendered.contains("| 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-2\\\\b'\n")); @@ -356,19 +348,10 @@ mod tests { discriminator: None, })))); let mut buf = Writer::with_capacity(4096); - render_type(&mut buf, &nested, Position::Standalone); + nested.render(&mut buf, Position::Standalone); assert_eq!(buf.into_string(), "(Cat | Dog)[][]"); } - // ── Property-based: safe_property_name ───────────────────────────────────── - // - // The function lives in the path that converts arbitrary OpenAPI - // property names into TS-shaped output. The example tests above lock in - // representative cases; the properties here assert invariants over the - // full input space so an adversarial spec (mixed scripts, control - // characters, embedded quotes/backslashes) can't sneak in malformed - // output. - use proptest::prelude::*; /// Lexes the output as either a bare identifier or a single-quoted @@ -378,7 +361,7 @@ mod tests { if out.is_empty() { return false; } - if is_valid_identifier(out) { + if is_ident(out) { return true; } let bytes = out.as_bytes(); @@ -433,12 +416,10 @@ mod tests { } } - // ── jsdoc ────────────────────────────────────────────────────────────────── - #[test] fn jsdoc_escapes_close_comment_sequence() { let mut out = Writer::with_capacity(4096); - jsdoc(&mut out, Some("Crafted */ injection /*"), false); + jsdoc(&mut out, Doc::new(Some("Crafted */ injection /*"), false)); let s = out.into_string(); // The only allowed `*/` is the trailing JSDoc closer on its own line. // Strip exactly the opener and closer lines, then assert no `*/` remains diff --git a/src/emit/typescript.rs b/src/emit/typescript.rs deleted file mode 100644 index 5b8753d..0000000 --- a/src/emit/typescript.rs +++ /dev/null @@ -1,611 +0,0 @@ -//! Unified emit-layer writer. -//! -//! `Writer` is the single output engine for every emitter (TS models, -//! Angular services). It owns the `String` buffer, tracks indent -//! depth, and exposes both raw output primitives (`push`, `line`, -//! `block`) and TypeScript-shaped helpers (`jsdoc`, -//! `interface_block`, `type_alias`, `string_union`, `import_block`, -//! `render_type`). Folding everything into one module collapses the -//! prior split across `CodeBuffer` (buffer engine), `primitives` -//! (interface/type-alias/import helpers), and `ts_renderer` -//! (type-expression rendering). -//! -//! Two policies that used to leak across modules now live exactly once: -//! * **Parenthesization** — `render_type` consults `needs_parens` with -//! a single `Position` enum (standalone / composition / array-item). -//! * **Indentation** — `Writer.indent_cache` is the only ratchet. -//! -//! Most renderers take `&mut Writer` and append directly. The free -//! functions below (`safe_property_name`, `render_type_reference`) -//! exist for the few callers that need a standalone `String` without -//! owning a `Writer`. - -use std::borrow::Cow; -use std::collections::{BTreeMap, BTreeSet}; - -use crate::ir::canonical::BodyFieldType; -use crate::ir::identifier::is_valid_identifier; -use crate::ir::schema::{SchemaProperty, SchemaScalar, SchemaType}; -use crate::wln; - -/// Width budget below which a top-level string union renders on a single line. -/// Counts the joined `'a' | 'b' | 'c'` form, not the `export type X = ` prefix. -/// Matches prettier's default `printWidth: 80`. -const ENUM_INLINE_WIDTH: usize = 80; - -/// Width budget for a single-line `import { ... } from '...';` statement. -/// Lines that would exceed this when joined fall back to a multi-line -/// form (one identifier per indented line) so subsequent formatter runs -/// don't re-wrap the file and produce non-empty diffs on every regen. -const IMPORT_INLINE_WIDTH: usize = 100; - -// ── Buffer engine ──────────────────────────────────────────────────────────── - -/// Indent-aware string writer used by every emit target. Tracks line-start -/// state so consecutive `push` calls share an indent prefix without the -/// caller threading it explicitly. -#[derive(Debug, Default)] -pub(crate) struct Writer { - buf: String, - indent_cache: String, - indent_level: usize, - line_start: bool, - last_was_blank: bool, -} - -impl Writer { - pub(crate) fn with_capacity(capacity: usize) -> Self { - Self { - buf: String::with_capacity(capacity), - indent_cache: String::new(), - indent_level: 0, - line_start: true, - last_was_blank: false, - } - } - - pub(crate) fn push(&mut self, value: &str) { - // Fast path: mid-line, no embedded newline — the common case during - // type/expression emission ("(", ", ", identifier tokens). Skips the - // indent bookkeeping and `find('\n')` loop below. Slow path still - // handles every state transition (indent emission, blank-line tracking, - // multi-line literals). - if !self.line_start && !value.contains('\n') { - if !value.is_empty() { - self.buf.push_str(value); - self.last_was_blank = false; - } - return; - } - - let mut rest = value; - while !rest.is_empty() { - if self.line_start { - if let Some(stripped) = rest.strip_prefix('\n') { - self.buf.push('\n'); - self.last_was_blank = true; - rest = stripped; - continue; - } - self.write_indent(); - } - - if let Some(pos) = rest.find('\n') { - self.buf.push_str(&rest[..=pos]); - let line_had_content = pos > 0; - rest = &rest[pos + 1..]; - self.line_start = true; - if line_had_content { - self.last_was_blank = false; - } - } else { - self.buf.push_str(rest); - self.line_start = false; - self.last_was_blank = false; - break; - } - } - } - - pub(crate) fn line(&mut self, value: &str) { - let was_empty = value.is_empty() && self.line_start; - self.push(value); - self.buf.push('\n'); - self.line_start = true; - if was_empty { - self.last_was_blank = true; - } - } - - pub(crate) fn blank_line(&mut self) { - if self.buf.is_empty() { - return; - } - - if self.last_was_blank { - return; - } - - if !self.buf.ends_with('\n') { - self.buf.push('\n'); - } - - self.buf.push('\n'); - self.line_start = true; - self.last_was_blank = true; - } - - pub(crate) fn open_block(&mut self, header: &str) { - if header.is_empty() { - self.line("{"); - } else { - // Infallible writer; sidestep `std::fmt::Write` so we don't drag in - // an `.unwrap()` for an error the buffer never produces. - self.push(header); - self.push(" {"); - self.buf.push('\n'); - self.line_start = true; - self.last_was_blank = false; - } - self.indent(); - } - - pub(crate) fn close_block(&mut self, suffix: &str) { - self.dedent(); - if suffix.is_empty() { - self.line("}"); - } else { - self.push("}"); - self.push(suffix); - self.buf.push('\n'); - self.line_start = true; - self.last_was_blank = false; - } - } - - pub(crate) fn into_string(self) -> String { - self.buf - } - - pub(crate) fn indent(&mut self) { - self.indent_level += 1; - self.indent_cache.push_str(" "); - } - - pub(crate) fn dedent(&mut self) { - self.indent_level = self - .indent_level - .checked_sub(1) - .expect("over-dedent in emitter"); - let new_len = self.indent_level * 2; - self.indent_cache.truncate(new_len); - } - - fn write_indent(&mut self) { - self.buf.push_str(&self.indent_cache); - self.line_start = false; - } -} - -impl std::fmt::Write for Writer { - fn write_str(&mut self, s: &str) -> std::fmt::Result { - self.push(s); - Ok(()) - } -} - -// ── Identifier escaping ────────────────────────────────────────────────────── - -/// Quote a property name when it isn't a valid bare TS identifier. -/// -/// Reserved words like `class` or `default` are valid in property position in -/// TypeScript (an interface property is `PropertyName`, which accepts any -/// `IdentifierName`), so we only quote when the source name contains -/// characters that would make it syntactically invalid bare: anything other -/// than the `[A-Za-z_$][A-Za-z0-9_$]*` shape (digits-first, kebab-case, -/// dotted, whitespace, etc.). Quoting uses single quotes with backslash -/// escapes, matching `write_string_literal`. -pub(crate) fn safe_property_name(name: &str) -> Cow<'_, str> { - if is_valid_identifier(name) { - Cow::Borrowed(name) - } else { - let mut out = String::with_capacity(name.len() + 2); - write_string_literal(&mut out, name); - Cow::Owned(out) - } -} - -// ── TS-shape helpers ───────────────────────────────────────────────────────── - -/// Emit a JSDoc block for the given description, if any. The description -/// may be multi-line (e.g. when OpenAPI `summary` and `description` are -/// merged with a blank-line separator); each line becomes ` * `. -/// Emits nothing when description is None or empty after trimming AND -/// `deprecated` is false. When `deprecated` is true an `@deprecated` tag -/// line is added (after the description body if both are present) so the -/// emitted output surfaces the deprecation marker to IDE tooltips and -/// linters at the call site. -pub(crate) fn jsdoc(out: &mut Writer, description: Option<&str>, deprecated: bool) { - let trimmed = description.map(str::trim_end).filter(|s| !s.is_empty()); - if trimmed.is_none() && !deprecated { - return; - } - out.line("/**"); - if let Some(text) = trimmed { - for line in text.lines() { - let body = line.trim_end(); - if body.is_empty() { - out.line(" *"); - } else { - let escaped = body.replace("*/", "*\\/"); - wln!(out, " * {escaped}"); - } - } - } - if deprecated { - out.line(" * @deprecated"); - } - out.line(" */"); -} - -/// Emit an `interface` block with the given properties. -/// Properties are tuples of `(name, optional, ty, description, deprecated)`. -/// Nullability is folded into `ty` (`SchemaType::Nullable(...)`) rather than -/// carried alongside it — one carrier for the whole IR. The trailing -/// `deprecated` flag emits a `@deprecated` JSDoc tag above the property -/// declaration when the source schema's `deprecated: true` is set. -pub(crate) fn interface_block<'a>( - out: &mut Writer, - name: &str, - description: Option<&str>, - deprecated: bool, - properties: impl IntoIterator, bool)>, - exported: bool, -) { - jsdoc(out, description, deprecated); - let keyword = if exported { - "export interface " - } else { - "interface " - }; - out.open_block(&format!("{keyword}{name}")); - for (prop_name, optional, ty, prop_description, prop_deprecated) in properties { - jsdoc(out, prop_description, prop_deprecated); - write_property_declaration(out, prop_name, optional, ty); - out.push(";\n"); - } - out.close_block(""); -} - -/// Emit `export type {name} = {rhs};` with an optional JSDoc header. -pub(crate) fn type_alias( - out: &mut Writer, - name: &str, - description: Option<&str>, - deprecated: bool, - rhs: &str, -) { - jsdoc(out, description, deprecated); - wln!(out, "export type {name} = {rhs};"); -} - -/// Emit a string-literal union type, collapsing to one line when short. -pub(crate) fn string_union( - out: &mut Writer, - name: &str, - description: Option<&str>, - deprecated: bool, - values: &[String], -) { - jsdoc(out, description, deprecated); - - // Cheap upper bound on the joined `'a' | 'b' | 'c'` length: each value - // contributes its byte length plus the two quote characters, separated - // by ` | `. UTF-8 byte length over-counts visual width for non-ASCII - // identifiers, which is fine — the budget exists to keep lines short, - // and over-counting only ever forces an additional wrap. - let separator_total = values.len().saturating_sub(1) * " | ".len(); - let quoted_total: usize = values.iter().map(|v| v.len() + 2).sum(); - let joined_width = separator_total + quoted_total; - - if joined_width <= ENUM_INLINE_WIDTH { - let mut inline = String::with_capacity(joined_width); - for (index, value) in values.iter().enumerate() { - if index > 0 { - inline.push_str(" | "); - } - write_string_literal(&mut inline, value); - } - wln!(out, "export type {name} = {inline};"); - return; - } - - wln!(out, "export type {name} ="); - out.indent(); - let last = values.len().saturating_sub(1); - for (index, value) in values.iter().enumerate() { - let suffix = if index == last { ";" } else { "" }; - let mut literal = String::with_capacity(value.len() + 2); - write_string_literal(&mut literal, value); - wln!(out, "| {literal}{suffix}"); - } - out.dedent(); -} - -/// Emit one `import [type] { ... } from '...';` line per path entry. -/// Names within each path are emitted in iteration order (callers should -/// pass a `BTreeSet` for stable output). -pub(crate) fn import_block( - out: &mut Writer, - by_path: &BTreeMap<&str, BTreeSet<&str>>, - type_only: bool, -) { - for (path, names) in by_path { - write_import_line(out, names.iter().map(|n| (*n, None)), path, type_only); - } -} - -/// Write a single `import [type] { name [as alias], ... } from 'path';` -/// statement. Folds the 2 sites that duplicate this `format!` shape -/// (mapped-type imports, service type imports). -/// -/// When the single-line form would exceed `IMPORT_INLINE_WIDTH`, the -/// statement is emitted multi-line — one identifier per indented line, -/// closing `} from '...';` on its own line — so that prettier-shaped -/// consumer formatters don't re-wrap on the first save and regenerate -/// always produces an empty diff. -pub(crate) fn write_import_line<'a>( - out: &mut Writer, - names: impl IntoIterator)>, - path: &str, - type_only: bool, -) { - let prefix = if type_only { - "import type { " - } else { - "import { " - }; - let suffix_len = " } from '".len() + path.len() + "';".len(); - // Buffer the (name, alias) pairs so we can measure the joined width - // before committing to inline vs multi-line. Names are short - // identifiers; the allocation is tiny in practice. - let entries: Vec<(&'a str, Option<&'a str>)> = names.into_iter().collect(); - - let names_width: usize = entries - .iter() - .map(|(name, alias)| name.len() + alias.map_or(0, |a| " as ".len() + a.len())) - .sum(); - let separators_width = entries.len().saturating_sub(1) * ", ".len(); - let joined_width = prefix.len() + names_width + separators_width + suffix_len; - - if joined_width <= IMPORT_INLINE_WIDTH || entries.len() <= 1 { - out.push(prefix); - let mut first = true; - for (name, alias) in &entries { - if !first { - out.push(", "); - } - first = false; - out.push(name); - if let Some(alias) = alias { - out.push(" as "); - out.push(alias); - } - } - out.push(" } from '"); - out.push(path); - out.push("';\n"); - return; - } - - // Multi-line form: one identifier per indented line, trailing comma - // on every entry (matches prettier's wrap style so the first - // formatter pass on a consumer's checkout is a no-op). - out.push(if type_only { - "import type {\n" - } else { - "import {\n" - }); - out.indent(); - for (name, alias) in &entries { - out.push(name); - if let Some(alias) = alias { - out.push(" as "); - out.push(alias); - } - out.push(",\n"); - } - out.dedent(); - out.push("} from '"); - out.push(path); - out.push("';\n"); -} - -// ── Type-expression rendering ──────────────────────────────────────────────── - -/// Syntactic position of a `SchemaType` reference. The position decides -/// whether a composite child needs to be parenthesized so that the -/// surrounding operator binds correctly. Centralising this in one place -/// removes the 3-way duplication we used to keep across separate -/// `render_type_reference` / `render_wrapped_type_reference` / -/// `render_array_item_reference` functions. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) enum Position { - /// Standalone reference (top-level type alias RHS, property type, etc.). - /// Never parenthesizes the value. - Standalone, - /// Inside a context where composites (`A | B`, `A & B`, `T | null`) must - /// be parenthesized so the surrounding operator binds correctly — both - /// composition lists (`A | B`, `A & B`) and array-item position (`X[]`). - /// Inline objects don't need wrapping in either context: `A & { x: T }` - /// parses unambiguously, and `{a: T}[]` likewise. - Wrapped, -} - -/// Render `value` to `out` at the given syntactic position. -pub(crate) fn render_type(out: &mut Writer, value: &SchemaType, position: Position) { - if needs_parens(value, position) { - out.push("("); - render_type_inner(out, value); - out.push(")"); - } else { - render_type_inner(out, value); - } -} - -/// Test-only string-returning shim around [`render_type`]. Production code -/// streams type references straight into the active `Writer`; the only -/// callers that need a `String` back are unit tests that compare rendered -/// fragments. -#[cfg(test)] -pub(crate) fn render_type_reference(value: &SchemaType) -> String { - let mut buf = Writer::with_capacity(128); - render_type(&mut buf, value, Position::Standalone); - buf.into_string() -} - -pub(crate) fn write_property_declaration( - out: &mut Writer, - name: &str, - optional: bool, - ty: &SchemaType, -) { - out.push(&safe_property_name(name)); - if optional { - out.push("?"); - } - out.push(": "); - render_type(out, ty, Position::Standalone); -} - -const fn needs_parens(value: &SchemaType, position: Position) -> bool { - let is_composite = matches!( - value, - SchemaType::Union { .. } | SchemaType::Intersection(_) | SchemaType::Nullable(_) - ); - match position { - Position::Standalone => false, - // Inline objects don't need wrapping in `A & { x: T }` — `&` binds - // lower than the property-block, and TS parses the form - // unambiguously. Composites (`|`, `&`, `T | null`) still need - // wrapping so the surrounding operator binds correctly. - Position::Wrapped => is_composite, - } -} - -fn render_type_inner(out: &mut Writer, value: &SchemaType) { - match value { - SchemaType::Any => out.push("unknown"), - SchemaType::Scalar(scalar) => out.push(scalar_keyword(scalar)), - SchemaType::Array(items) => { - render_type(out, items, Position::Wrapped); - out.push("[]"); - } - SchemaType::Map(items) => { - out.push("Record"); - } - SchemaType::StringLiterals { values } => render_string_literal_union(out, values), - SchemaType::Ref(name) => out.push(name), - SchemaType::Union { members, .. } => { - if members.is_empty() { - out.push("never"); - } else { - render_composition(out, members, " | "); - } - } - SchemaType::Intersection(members) => render_composition(out, members, " & "), - SchemaType::InlineObject { properties } => render_inline_object(out, properties), - SchemaType::Nullable(inner) => { - // Flatten `Nullable(Union { A, B })` into `A | B | null` rather - // than `(A | B) | null` — both are equivalent TS but the flat form - // mirrors OpenAPI 3.1's `oneOf: [A, B, null]` semantics. Other - // inner shapes (Intersection, InlineObject) still need parens to - // preserve precedence. - let inner_position = if matches!(inner.as_ref(), SchemaType::Union { .. }) { - Position::Standalone - } else { - Position::Wrapped - }; - render_type(out, inner, inner_position); - out.push(" | null"); - } - } -} - -const fn scalar_keyword(scalar: &SchemaScalar) -> &'static str { - match scalar { - SchemaScalar::String => "string", - SchemaScalar::Number => "number", - SchemaScalar::Boolean => "boolean", - } -} - -/// Render a form-body field type (multipart/urlencoded) as TS source. -/// -/// Binary parts surface as `Blob | File` (the runtime union the fetch -/// `FormData.append` overload accepts); scalar parts reuse the same -/// keywords as `SchemaType::Scalar`. Arrays wrap binary unions in -/// parentheses so `(Blob | File)[]` parses as an array of unions rather -/// than the precedence trap `Blob | File[]`. -pub(crate) fn render_body_field_type(ty: &BodyFieldType) -> String { - match ty { - BodyFieldType::Scalar(scalar) => scalar_keyword(scalar).to_string(), - BodyFieldType::ArrayOfScalar(scalar) => format!("{}[]", scalar_keyword(scalar)), - BodyFieldType::Binary => "Blob | File".to_string(), - BodyFieldType::ArrayOfBinary => "(Blob | File)[]".to_string(), - } -} - -fn render_composition(out: &mut Writer, members: &[SchemaType], separator: &str) { - let mut first = true; - for member in members { - if !first { - out.push(separator); - } - first = false; - render_type(out, member, Position::Wrapped); - } -} - -fn render_string_literal_union(out: &mut Writer, values: &[String]) { - let mut first = true; - for value in values { - if !first { - out.push(" | "); - } - first = false; - write_string_literal(out, value); - } -} - -fn render_inline_object(out: &mut Writer, properties: &[SchemaProperty]) { - if properties.is_empty() { - out.push("Record"); - return; - } - out.push("{\n"); - out.indent(); - for property in properties { - write_property_declaration(out, &property.name, !property.required, &property.ty); - out.push(";\n"); - } - out.dedent(); - out.push("}"); -} - -pub(crate) fn write_string_literal(out: &mut W, value: &str) { - out.write_char('\'').unwrap(); - for ch in value.chars() { - match ch { - '\\' => out.write_str("\\\\").unwrap(), - '\'' => out.write_str("\\'").unwrap(), - '\n' => out.write_str("\\n").unwrap(), - '\r' => out.write_str("\\r").unwrap(), - '\t' => out.write_str("\\t").unwrap(), - _ => out.write_char(ch).unwrap(), - } - } - out.write_char('\'').unwrap(); -} diff --git a/src/error.rs b/src/error.rs index e165f56..fd1c75f 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,3 +1,4 @@ +use std::cell::RefCell; use std::rc::Rc; use napi_derive::napi; @@ -6,8 +7,7 @@ use serde::Serialize; const SEVERITY_WARNING: &str = "warning"; const SEVERITY_ERROR: &str = "error"; -/// Compact diagnostic taxonomy. Six codes covering every fatal/warning -/// the pipeline emits: +/// Every code a fatal or a warning can carry: /// /// * `InputInvalid` — read or decode failed (`E_INPUT_INVALID`). /// * `UnsupportedSemantic` — accepted spec uses a shape outside the supported @@ -17,9 +17,7 @@ const SEVERITY_ERROR: &str = "error"; /// * `PolicyViolation` — IR-level rule (missing tag, missing operationId, /// request-field collision, planner refusal) (`E_POLICY_VIOLATION`). /// * `WriteFailed` — output file write failed (`E_WRITE_FAILED`). -/// * `Unexpected` — a panic crossed the NAPI boundary; surfaced by -/// `map_panic` so a Rust panic becomes an `E_UNEXPECTED` GenerateError -/// instead of aborting the host Node process. +/// * `Unexpected` — a panic crossed the NAPI boundary (`E_UNEXPECTED`). #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum DiagnosticCode { InputInvalid, @@ -45,10 +43,8 @@ impl DiagnosticCode { } } -/// Single internal diagnostic carried across the pipeline. Severity is -/// implicit (Err vs warnings-vec). `path` is an `Rc` so the reporter -/// can attach the same display path to every diagnostic by bumping a -/// refcount, not allocating a fresh `String`. +/// One diagnostic. Severity is implicit: a fatal travels as `Err`, a +/// warning through [`Reporter::warning`]. /// /// Message convention: lead with a stage-gerund subject ("Failed to /// decode input", "Unsupported OpenAPI semantic shape", "Failed to plan @@ -76,7 +72,7 @@ impl Diagnostic { } pub(crate) fn policy_violation( - reporter: &Reporter<'_>, + reporter: &Reporter, subcode: &'static str, message: impl Into, ) -> Self { @@ -112,12 +108,11 @@ impl std::fmt::Display for Diagnostic { impl std::error::Error for Diagnostic {} -/// Boundary projection of `Diagnostic` for the NAPI surface — string-typed -/// `code` is what JS consumers see and compare against. `severity` is -/// either `"warning"` or `"error"`; the TS surface narrows it to the -/// `'warning' | 'error'` union via `scripts/patch-types.mjs`. `subcode` -/// is populated only for `PolicyViolation` today; consumers route on it -/// when they need finer-grained remediation than `code` alone. +/// Boundary projection of [`Diagnostic`] for the NAPI surface, where +/// `code` and `severity` are strings a JS consumer compares against. +/// +/// `severity` is `"warning"` or `"error"`. `subcode` is set only for +/// `PolicyViolation`. #[napi(object)] #[derive(Clone, Debug, Serialize)] pub struct GeneratorDiagnostic { @@ -128,13 +123,10 @@ pub struct GeneratorDiagnostic { pub path: String, } -/// Borrowed breadcrumb for diagnostic context messages used during schema/operation -/// normalization. Building a `Context` value is alloc-free; only `.render()` allocates, -/// and only on the error path when a diagnostic message is actually being constructed. +/// Borrowed breadcrumb naming one position of a schema walk, one variant +/// per level. /// -/// Each variant corresponds to one level of the recursive normalization walk. -/// `Copy` so inner call sites can take `context: &Context<'_>` and build a deeper -/// context by value without extra indirection. +/// Building one is allocation-free; only [`Context::render`] allocates. #[derive(Clone, Copy)] pub(crate) enum Context<'a> { /// Top-level named schema: renders as `"schema {name}"`. @@ -182,42 +174,69 @@ impl<'a> Context<'a> { } } -/// Single reporter type carried through every pipeline stage. Holds the -/// display path (shared via `Rc` across every diagnostic it builds) -/// and a borrow into the boundary-owned warnings vec. -/// -/// Stages take `&Reporter<'_>` when they only emit fatals via -/// `.error(...)`; they take `&mut Reporter<'_>` when they also need to -/// push pre-fatal warnings via `.warning(...)`. -pub(crate) struct Reporter<'a> { +/// Diagnostic sink for one run. Attaches the display path to every +/// diagnostic it builds and accumulates the pre-fatal warnings. +pub(crate) struct Reporter { path: Rc, - warnings: &'a mut Vec, + warnings: RefCell>, } -impl<'a> Reporter<'a> { - pub(crate) const fn new(path: Rc, warnings: &'a mut Vec) -> Self { - Self { path, warnings } +impl Reporter { + pub(crate) const fn new(path: Rc) -> Self { + Self { + path, + warnings: RefCell::new(Vec::new()), + } } + /// Builds a fatal diagnostic without recording it. pub(crate) fn error(&self, code: DiagnosticCode, message: impl Into) -> Diagnostic { Diagnostic::new(code, message, Rc::clone(&self.path)) } - /// Push a pre-fatal warning. `subcode` is an optional stable - /// kebab-case tag that lets consumers route on a finer-grained class - /// than `code` alone; pass `None` when no such subdivision applies. + /// Records a pre-fatal warning. `subcode` is a stable kebab-case tag that + /// lets consumers route on a finer class than `code` alone; pass `None` + /// when no such subdivision applies. pub(crate) fn warning( - &mut self, + &self, code: DiagnosticCode, subcode: Option<&'static str>, message: impl Into, ) { let mut diagnostic = Diagnostic::new(code, message, Rc::clone(&self.path)); diagnostic.subcode = subcode; - self.warnings.push(diagnostic); + self.warnings.borrow_mut().push(diagnostic); } + + pub(crate) fn into_warnings(self) -> Vec { + self.warnings.into_inner() + } +} + +/// Returns a `PolicyViolation` from the enclosing function. +/// +/// `$subcode` is the stable kebab-case tag consumers route on. +macro_rules! bail_policy { + ($reporter:expr, $subcode:expr, $($message:tt)*) => { + return ::core::result::Result::Err($crate::error::Diagnostic::policy_violation( + $reporter, + $subcode, + ::std::format!($($message)*), + )) + }; +} + +/// Returns a fatal diagnostic of the given code from the enclosing function. +macro_rules! bail { + ($reporter:expr, $code:expr, $($message:tt)*) => { + return ::core::result::Result::Err( + $reporter.error($code, ::std::format!($($message)*)), + ) + }; } +pub(crate) use {bail, bail_policy}; + #[cfg(test)] mod tests { use serde_json::json; @@ -265,9 +284,9 @@ mod tests { #[test] fn subcode_threads_through_the_napi_projection() { - let mut ctx = crate::test_support::test_ctx(); + let ctx = crate::test_support::test_reporter(); let diagnostic = Diagnostic::policy_violation( - &ctx.reporter(), + &ctx, "missing-tag", "Failed to plan services: operation missing tag.", ); @@ -278,9 +297,8 @@ mod tests { } #[test] - fn warning_pushes_typed_diagnostic_carrying_path() { - let mut warnings = Vec::new(); - let mut reporter = Reporter::new(std::rc::Rc::from("fixtures/spec.yaml"), &mut warnings); + fn warning_records_typed_diagnostic_carrying_path() { + let reporter = Reporter::new(std::rc::Rc::from("fixtures/spec.yaml")); reporter.warning( DiagnosticCode::UnsupportedSemantic, @@ -288,34 +306,56 @@ mod tests { "Input used a fallback path.", ); + let warnings = reporter.into_warnings(); assert_eq!(warnings.len(), 1); assert_eq!(warnings[0].code, DiagnosticCode::UnsupportedSemantic); assert_eq!(warnings[0].path.as_ref(), "fixtures/spec.yaml"); } #[test] - fn error_returns_a_fatal_diagnostic_without_pushing() { - let mut warnings = Vec::new(); - let reporter = Reporter::new(std::rc::Rc::from("fixtures/spec.yaml"), &mut warnings); + fn error_returns_a_fatal_diagnostic_without_recording_it() { + let reporter = Reporter::new(std::rc::Rc::from("fixtures/spec.yaml")); let fatal = reporter.error(DiagnosticCode::WriteFailed, "Failed to write artifact."); assert_eq!(fatal.code, DiagnosticCode::WriteFailed); assert_eq!(fatal.path.as_ref(), "fixtures/spec.yaml"); - assert!(warnings.is_empty()); + assert!(reporter.into_warnings().is_empty()); } #[test] - fn warnings_accumulate_in_order_on_the_caller_owned_vec() { - let mut warnings = Vec::new(); - { - let mut reporter = Reporter::new(std::rc::Rc::from("fixtures/spec.yaml"), &mut warnings); - reporter.warning(DiagnosticCode::UnsupportedSemantic, None, "First warning."); - reporter.warning(DiagnosticCode::UnsupportedSemantic, None, "Second warning."); - } + fn warnings_accumulate_in_report_order() { + let reporter = Reporter::new(std::rc::Rc::from("fixtures/spec.yaml")); + reporter.warning(DiagnosticCode::UnsupportedSemantic, None, "First warning."); + reporter.warning(DiagnosticCode::UnsupportedSemantic, None, "Second warning."); + + let warnings = reporter.into_warnings(); assert_eq!(warnings.len(), 2); assert_eq!(warnings[0].message, "First warning."); assert_eq!(warnings[1].message, "Second warning."); } + + #[test] + fn reporting_composes_inside_a_fallible_iterator_chain() { + let reporter = Reporter::new(std::rc::Rc::from("fixtures/spec.yaml")); + + let outcome = ["ok", "warn", "fatal"] + .iter() + .map(|token| match *token { + "fatal" => Err(reporter.error(DiagnosticCode::InputInvalid, "Bad token.")), + "warn" => { + reporter.warning(DiagnosticCode::UnsupportedSemantic, None, "Odd token."); + Ok(*token) + } + other => Ok(other), + }) + .collect::, Diagnostic>>(); + + assert_eq!( + outcome.expect_err("chain short-circuits").code, + DiagnosticCode::InputInvalid + ); + assert_eq!(reporter.into_warnings().len(), 1); + } } diff --git a/src/ident.rs b/src/ident.rs new file mode 100644 index 0000000..4887510 --- /dev/null +++ b/src/ident.rs @@ -0,0 +1,107 @@ +//! Validated identifier and name types shared by normalize, plan and emit. +//! +//! Every value here is checked at construction, so a holder may interpolate +//! it into generated TypeScript without re-checking or quoting. + +/// A bare JavaScript / TypeScript identifier, restricted to the ASCII +/// subset: `[A-Za-z_$][A-Za-z0-9_$]*`. +/// +/// Holding one is the assertion that the name needs no quoting in property +/// position and no escaping in expression position. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) struct Ident(Box); + +impl Ident { + /// Returns `None` when `name` is not a bare identifier — digits-first, + /// kebab-case, dotted, empty, or whitespace-bearing names all reject. + pub(crate) fn parse(name: &str) -> Option { + is_ident(name).then(|| Self(Box::from(name))) + } + + pub(crate) fn as_str(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for Ident { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +/// True when `name` is a bare identifier. Prefer [`Ident::parse`] where +/// the validated name is kept. +pub(crate) fn is_ident(name: &str) -> bool { + let mut chars = name.chars(); + chars + .next() + .is_some_and(|first| first.is_ascii_alphabetic() || first == '_' || first == '$') + && chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '$') +} + +/// An operation's method name after the naming rules have run. +/// +/// Not the spec's `operationId`: a rule may rewrite `Pet_listPets` into +/// `listPets`. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) struct MethodName(String); + +impl MethodName { + pub(crate) const fn new(name: String) -> Self { + Self(name) + } + + pub(crate) fn as_str(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for MethodName { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +/// A PascalCase TypeScript type name emitted by the generator, derived from +/// a [`MethodName`] or a service group. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) struct TypeName(String); + +impl TypeName { + pub(crate) const fn new(name: String) -> Self { + Self(name) + } +} + +impl std::fmt::Display for TypeName { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +#[cfg(test)] +mod tests { + use super::{Ident, is_ident}; + + #[test] + fn accepts_the_bare_identifier_grammar() { + for name in ["pet", "_pet", "$pet", "Pet2", "a_b$c9"] { + assert!(Ident::parse(name).is_some(), "{name} must parse"); + } + } + + #[test] + fn rejects_names_that_need_quoting() { + for name in ["", "2pet", "pet-name", "pet.name", "pet name", "pét"] { + assert!(Ident::parse(name).is_none(), "{name} must reject"); + assert!(!is_ident(name)); + } + } + + #[test] + fn parsed_identifier_round_trips_its_source() { + let ident = Ident::parse("listPets").expect("bare identifier"); + assert_eq!(ident.as_str(), "listPets"); + assert_eq!(ident.to_string(), "listPets"); + } +} diff --git a/src/io/writer.rs b/src/io/writer.rs index f339529..55420df 100644 --- a/src/io/writer.rs +++ b/src/io/writer.rs @@ -1,26 +1,15 @@ use std::fs; use crate::{ - error::{Diagnostic, DiagnosticCode, Reporter}, + error::{Diagnostic, DiagnosticCode, Reporter, bail}, io::host_cwd::resolve_against_host_cwd, result::GeneratedArtifact, }; -/// Write a formatted line into a `Writer`. `Writer`'s `fmt::Write` impl is -/// infallible (it writes into an in-memory `String`), so the underlying -/// `writeln!` cannot fail; this macro hides the unwrap noise. -#[macro_export] -macro_rules! wln { - ($w:expr, $($arg:tt)*) => {{ - use std::fmt::Write as _; - writeln!($w, $($arg)*).expect("writing into Writer cannot fail") - }}; -} - pub(crate) fn write_generated_artifacts( output_path: Option<&str>, artifacts: &[GeneratedArtifact], - reporter: &Reporter<'_>, + reporter: &Reporter, ) -> Result<(), Diagnostic> { let Some(output_path) = output_path else { return Ok(()); @@ -36,20 +25,19 @@ pub(crate) fn write_generated_artifacts( fn write_artifact( output_path: &str, artifact: &GeneratedArtifact, - reporter: &Reporter<'_>, + reporter: &Reporter, ) -> Result<(), Diagnostic> { let artifact_rel = std::path::Path::new(&artifact.path); if artifact_rel .components() .any(|c| matches!(c, std::path::Component::ParentDir)) { - return Err(reporter.error( + bail!( + reporter, DiagnosticCode::WriteFailed, - format!( - "Failed to write artifact: artifact path '{}' contains parent traversal ('..').", - artifact.path - ), - )); + "Failed to write artifact: artifact path '{}' contains parent traversal ('..').", + artifact.path + ); } let output_dir = resolve_against_host_cwd(std::path::Path::new(output_path)); @@ -93,7 +81,7 @@ mod tests { time::{SystemTime, UNIX_EPOCH}, }; - use crate::{result::GeneratedArtifact, test_support::test_ctx}; + use crate::{result::GeneratedArtifact, test_support::test_reporter}; fn unique_path(label: &str) -> std::path::PathBuf { let nanos = SystemTime::now() @@ -113,7 +101,7 @@ mod tests { #[test] fn write_generated_artifacts_writes_nested_artifacts_into_output_directory() { let output_path = unique_path("artifact-writer-success"); - let mut ctx = test_ctx(); + let ctx = test_reporter(); let artifacts = vec![ artifact("model.generated.ts", "export interface Pet {}\n"), artifact("rest/pet.rest.generated.ts", "export class PetService {}\n"), @@ -122,7 +110,7 @@ mod tests { super::write_generated_artifacts( Some(output_path.to_str().expect("output path should be utf-8")), &artifacts, - &ctx.reporter(), + &ctx, ) .expect("writer succeeds"); @@ -147,7 +135,7 @@ mod tests { fs::write(blocked_output_path.join("rest"), "not-a-directory") .expect("create blocking parent file"); - let mut ctx = test_ctx(); + let ctx = test_reporter(); let failure = super::write_generated_artifacts( Some( blocked_output_path @@ -158,7 +146,7 @@ mod tests { "rest/pet.rest.generated.ts", "export class PetService {}\n", )], - &ctx.reporter(), + &ctx, ) .expect_err("writer should fail when parent directory cannot be created"); @@ -174,13 +162,13 @@ mod tests { fs::create_dir_all(&output_path).expect("create output directory"); fs::write(output_path.join("a.ts"), "stale content").expect("write stale file"); - let mut ctx = test_ctx(); + let ctx = test_reporter(); let artifacts = vec![artifact("a.ts", "fresh content")]; super::write_generated_artifacts( Some(output_path.to_str().expect("output path should be utf-8")), &artifacts, - &ctx.reporter(), + &ctx, ) .expect("overwrite should succeed"); @@ -194,13 +182,13 @@ mod tests { #[test] fn write_generated_artifacts_rejects_artifact_path_with_parent_traversal() { let output_path = unique_path("artifact-writer-traversal"); - let mut ctx = test_ctx(); + let ctx = test_reporter(); let artifacts = vec![artifact("../escape.ts", "x")]; let err = super::write_generated_artifacts( Some(output_path.to_str().expect("output path should be utf-8")), &artifacts, - &ctx.reporter(), + &ctx, ) .expect_err("should reject artifact path containing '..'"); diff --git a/src/ir/canonical.rs b/src/ir/canonical.rs index afade88..5f99563 100644 --- a/src/ir/canonical.rs +++ b/src/ir/canonical.rs @@ -1,18 +1,18 @@ +use crate::ident::Ident; use crate::ir::schema::{SchemaScalar, SchemaType}; -/// Named, top-level schema declaration. Produced by normalize and consumed -/// by the IR validator and downstream emitters. The body is a `SchemaType` -/// — interface, enum, and alias shapes all use the same carrier: +/// A named, top-level schema declaration. /// -/// * `SchemaType::InlineObject { properties }` → `export interface X { ... }` +/// Interface, enum and alias shapes share one `body` carrier: +/// +/// * `SchemaType::InlineObject { properties }` → `export interface X { … }` /// * `SchemaType::StringLiterals { values }` → `export type X = 'a' | 'b'` -/// * any other variant → `export type X = ...` +/// * any other variant → `export type X = …` #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct ModelSymbol { pub(crate) name: Box, pub(crate) description: Option, - /// Source schema's OpenAPI `deprecated: true`. Surfaces as - /// `@deprecated` in the JSDoc above the emitted TS declaration. + /// The source schema declared `deprecated: true`. pub(crate) deprecated: bool, pub(crate) body: SchemaType, } @@ -20,10 +20,8 @@ pub(crate) struct ModelSymbol { #[derive(Clone, Debug, Default, PartialEq, Eq)] pub(crate) struct RequestDef { pub(crate) inputs: Vec, - /// `in: header` parameters. Kept separate from `inputs` so plan and - /// emit treat them as a structurally distinct group — the request - /// interface renders them as a nested `headers: { ... }` field that is - /// threaded through to `CommonRequest.headers`. + /// `in: header` parameters, kept apart from `inputs` because they + /// travel in a different slot of the request. pub(crate) headers: Vec, pub(crate) body: Option, } @@ -36,7 +34,7 @@ pub(crate) struct RequestInputDef { pub(crate) ty: SchemaType, } -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum RequestInputSource { Path, Query, @@ -55,11 +53,10 @@ pub(crate) struct RequestBodyDef { pub(crate) content: BodyContent, } -/// Typed carrier for an operation's request-body content. `Json` -/// keeps the existing schema-carrying behaviour; `Multipart` and -/// `UrlEncoded` carry a flat field list (with an optional -/// `body_ref` recording the source named schema when the body was -/// declared as a top-level `$ref`). +/// An operation's request-body content. +/// +/// A form variant's `body_ref` names the source schema when the body was +/// declared as a top-level `$ref`. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) enum BodyContent { Json(SchemaType), @@ -73,31 +70,26 @@ pub(crate) enum BodyContent { }, } -#[allow(dead_code)] +/// One field of a `multipart/form-data` or urlencoded body. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct BodyField { - pub(crate) name: Box, + pub(crate) name: Ident, pub(crate) required: bool, pub(crate) ty: BodyFieldType, } #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) enum BodyFieldType { - #[allow(dead_code)] Scalar(SchemaScalar), - #[allow(dead_code)] ArrayOfScalar(SchemaScalar), - #[allow(dead_code)] Binary, - #[allow(dead_code)] ArrayOfBinary, } -// TRACE is intentionally absent: Angular's HttpClient has no `.trace()` -// method, and TRACE is disabled at most production gateways for security -// reasons (XST). Specs that include it are rejected explicitly at -// normalize-time (`normalize_operation`) so the failure is visible -// rather than a silent drop. +/// The HTTP methods the generator supports. +/// +/// TRACE is absent by design: it is disabled at most production gateways, +/// and a spec declaring it is rejected with its own diagnostic. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum HttpMethod { Get, @@ -122,11 +114,20 @@ impl HttpMethod { } } + /// The lower-case name, as an OpenAPI path item spells it. + pub(crate) const fn as_lowercase(self) -> &'static str { + match self { + Self::Get => "get", + Self::Post => "post", + Self::Put => "put", + Self::Delete => "delete", + Self::Patch => "patch", + Self::Options => "options", + Self::Head => "head", + } + } + pub(crate) fn from_lowercase(value: &str) -> Option { - // NOTE: TRACE intentionally returns None here; the strict rejection - // (with its TRACE-specific remediation message) happens in - // `ir::normalize::operations::normalize_operation`, which inspects the - // raw `method` string after this returns None. match value { "get" => Some(Self::Get), "post" => Some(Self::Post), @@ -154,39 +155,29 @@ pub(crate) struct OperationDef { pub(crate) path: String, pub(crate) request: RequestDef, pub(crate) response: Option, - /// Non-2xx responses with a JSON schema, sorted by status ascending. - /// Populated by normalize regardless of emit config; the `errors` emit - /// target reads from here. Schemaless or non-JSON error responses are - /// silently skipped — error responses in real specs are commonly - /// underspecified, so the strict-rejection model used for success - /// responses would be hostile here. + /// The 4xx and 5xx responses that declare a JSON schema, sorted by + /// status ascending. A schemaless or non-JSON error response is + /// skipped rather than rejected. pub(crate) errors: Vec, - /// Combined `summary` (first line) and `description` (subsequent - /// paragraph) from the OpenAPI Operation. Rendered as a JSDoc block - /// above the service operation member. + /// The OpenAPI Operation's `summary` and `description`, joined by a + /// blank line. pub(crate) description: Option, - /// Source operation's OpenAPI `deprecated: true`. Surfaces as - /// `@deprecated` in the JSDoc above the service method so call sites - /// see the IDE deprecation marker. + /// The source operation declared `deprecated: true`. pub(crate) deprecated: bool, } -/// One non-2xx response slot keyed by HTTP status. `default` and 1xx/3xx -/// are intentionally excluded — the strict typing surface the errors emit -/// builds (`OpError[400]`) only makes sense for explicit 4xx/5xx codes. +/// One response slot keyed by an explicit 4xx or 5xx status. `default`, +/// 1xx and 3xx are excluded. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct ErrorResponse { pub(crate) status: u16, pub(crate) body: SchemaType, } -/// Typed carrier for an operation's success-response content. `Json` -/// keeps the existing schema-carrying behaviour (with `None` covering -/// JSON responses that declare no schema); `Blob`, `Text`, and -/// `ArrayBuffer` will be produced by the response-kind classifier in a -/// later phase so non-JSON responses can be rendered with the right -/// `HttpClient` responseType. They carry no payload because their TS -/// surface is fixed (`Blob` / `string` / `ArrayBuffer`). +/// An operation's success-response content. +/// +/// `Json(None)` is a JSON response that declares no schema. The other +/// variants carry no payload: their type is fixed by the variant. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) enum ResponseContent { Json(Option), @@ -212,15 +203,6 @@ pub(crate) struct ApiModel { mod tests { use super::*; - // ── HttpMethod ───────────────────────────────────────────────────────────── - // - // The rest of this module is plain data carriers — `ModelSymbol`, - // `OperationDef`, `RequestDef`, `ApiInfo`, `ApiModel`. They hold no - // logic worth testing in isolation; their behaviour is exercised by - // every normalize/plan/emit test that builds an `ApiModel`. Only - // `HttpMethod` carries a parse path (`from_lowercase`) and a render - // path (`as_str` / `Display`) that benefit from direct coverage. - #[test] fn http_method_round_trips_lowercase_keyword_to_uppercase_string() { let cases = [ @@ -267,7 +249,7 @@ mod tests { let multipart = BodyContent::Multipart { body_ref: None, fields: vec![BodyField { - name: "avatar".into(), + name: Ident::parse("avatar").expect("identifier"), required: true, ty: BodyFieldType::Binary, }], @@ -275,7 +257,7 @@ mod tests { let url_encoded = BodyContent::UrlEncoded { body_ref: Some("LoginForm".into()), fields: vec![BodyField { - name: "username".into(), + name: Ident::parse("username").expect("identifier"), required: true, ty: BodyFieldType::Scalar(SchemaScalar::String), }], diff --git a/src/ir/identifier.rs b/src/ir/identifier.rs deleted file mode 100644 index 2f9c986..0000000 --- a/src/ir/identifier.rs +++ /dev/null @@ -1,18 +0,0 @@ -//! Shared identifier validation used across normalize and emit. -//! -//! Normalize calls this to reject untrusted spec strings (form-field -//! names, path-template parameter names) before they reach emit and -//! land as bare JS identifiers; emit calls this when deciding whether -//! a property name needs quoting. - -/// True when `name` is a valid bare JavaScript / TypeScript identifier -/// (restricted to the ASCII subset). Matches the production grammar -/// `[A-Za-z_$][A-Za-z0-9_$]*` — digits-first, kebab-case, dotted, or -/// whitespace-bearing names all reject. -pub(crate) fn is_valid_identifier(name: &str) -> bool { - let mut chars = name.chars(); - let first_ok = chars - .next() - .is_some_and(|c| c.is_ascii_alphabetic() || c == '_' || c == '$'); - first_ok && chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$') -} diff --git a/src/ir/mod.rs b/src/ir/mod.rs index e4027f3..4044c1c 100644 --- a/src/ir/mod.rs +++ b/src/ir/mod.rs @@ -1,5 +1,4 @@ pub(crate) mod canonical; -pub(crate) mod identifier; pub(crate) mod normalize; pub(crate) mod schema; diff --git a/src/ir/normalize/mod.rs b/src/ir/normalize/mod.rs index 4c7d967..15e4ca0 100644 --- a/src/ir/normalize/mod.rs +++ b/src/ir/normalize/mod.rs @@ -3,33 +3,31 @@ pub(crate) mod schema; mod semantic; #[cfg(test)] mod tests; +mod walk; use std::collections::BTreeMap; -use crate::error::{Context, Diagnostic, DiagnosticCode, Reporter}; +use crate::error::{Diagnostic, DiagnosticCode, Reporter}; use crate::ir::canonical::{ApiInfo, ApiModel}; use crate::ir::schema::SchemaType; use crate::options::ResponseTypeMapping; use crate::parse::openapi_model::{OpenApiDocument, Schema}; use operations::normalize_operations; use schema::normalize_schemas; +pub(crate) use walk::SchemaWalk; -/// Hard cap on `Schema` recursion during normalize. Realistic OpenAPI -/// specs nest a handful of levels (the deepest committed fixture is -/// 5 layers of allOf); a value of 32 leaves a healthy margin above that -/// while still rejecting pathological / cyclic specs before they -/// overflow the thread stack. The cap sits below the serde YAML/JSON -/// recursion limit (~60), so any spec that reaches this guard already -/// represents an unsupported shape rather than a parser-rejected one. +/// Hard cap on `Schema` nesting, enforced by [`SchemaWalk::check_depth`]. /// -/// Threaded as a `u16` argument through the recursive callers in -/// `schema.rs` — operations.rs starts each schema walk at depth 0. +/// Real specs nest a handful of levels — the deepest committed fixture is +/// 5 layers of `allOf` — and the cap sits below serde's own recursion +/// limit of roughly 60, so a spec that reaches it is an unsupported shape +/// and not a parser-rejected one. pub(crate) const MAX_NORMALIZE_DEPTH: u16 = 32; pub(crate) fn normalize_api_model( document: &OpenApiDocument, response_type_mapping: &[ResponseTypeMapping], - reporter: &mut Reporter<'_>, + reporter: &Reporter, ) -> Result { let schemas = normalize_schemas(&document.components.schemas, reporter)?; let schema_index: BTreeMap<&str, &SchemaType> = @@ -50,56 +48,72 @@ pub(crate) fn normalize_api_model( operations, }; - // Final semantic step: sort schemas, narrow discriminator member - // properties for TS emit, and validate `$ref` resolution. semantic::finalize(&mut model, reporter)?; Ok(model) } -pub(crate) fn unsupported( - detail: impl AsRef, - reporter: &Reporter<'_>, - include_readme: bool, -) -> Diagnostic { - let suffix = if include_readme { - ". See the supported subset documented in README.md ('Out of Scope' section)." - } else { - "" - }; +/// Diagnostic for a spec shape outside the supported subset, pointing the +/// reader at the documented subset. +pub(crate) fn unsupported(reporter: &Reporter, detail: impl AsRef) -> Diagnostic { reporter.error( DiagnosticCode::UnsupportedSemantic, format!( - "Unsupported OpenAPI semantic shape: {}{}", + "Unsupported OpenAPI semantic shape: {}. See the supported subset documented in README.md ('Out of Scope' section).", detail.as_ref(), - suffix ), ) } +/// Diagnostic for a shape rejected by a rule that `detail` already names. +/// Appends no pointer to the documented subset. +pub(crate) fn unsupported_rule(reporter: &Reporter, detail: impl AsRef) -> Diagnostic { + reporter.error( + DiagnosticCode::UnsupportedSemantic, + format!("Unsupported OpenAPI semantic shape: {}", detail.as_ref()), + ) +} + +/// Returns an [`unsupported`] diagnostic from the enclosing function. +macro_rules! bail_unsupported { + ($reporter:expr, $($message:tt)*) => { + return ::core::result::Result::Err($crate::ir::normalize::unsupported( + $reporter, + ::std::format!($($message)*), + )) + }; +} + +/// Returns an [`unsupported_rule`] diagnostic from the enclosing function. +macro_rules! bail_unsupported_rule { + ($reporter:expr, $($message:tt)*) => { + return ::core::result::Result::Err($crate::ir::normalize::unsupported_rule( + $reporter, + ::std::format!($($message)*), + )) + }; +} + +pub(crate) use {bail_unsupported, bail_unsupported_rule}; + pub(crate) fn check_unsupported_not( schema: &Schema, - context: &Context<'_>, - reporter: &Reporter<'_>, + walk: SchemaWalk<'_>, ) -> Result<(), Diagnostic> { if schema.not.is_some() { - return Err(unsupported( - format!( - "{} uses not, which is outside the supported subset.", - context.render() - ), - reporter, - true, - )); + bail_unsupported!( + walk.reporter(), + "{} uses not, which is outside the supported subset.", + walk.here() + ); } Ok(()) } -/// Test helper: deserialize a raw JSON value into an OpenApiDocument and normalize it. #[cfg(test)] pub(crate) fn normalize_document( document: &serde_json::Value, - reporter: &mut Reporter<'_>, + reporter: &Reporter, ) -> Result { let doc: OpenApiDocument = serde_json::from_value(document.clone()) .expect("test document must be a valid OpenApiDocument"); diff --git a/src/ir/normalize/operations.rs b/src/ir/normalize/operations.rs deleted file mode 100644 index d210047..0000000 --- a/src/ir/normalize/operations.rs +++ /dev/null @@ -1,1661 +0,0 @@ -use std::collections::BTreeMap; - -use crate::error::{Context, Diagnostic, DiagnosticCode, Reporter}; -use crate::ir::canonical::{ - BodyContent, BodyField, BodyFieldType, ErrorResponse, HeaderDef, HttpMethod, OperationDef, - RequestBodyDef, RequestDef, RequestInputDef, RequestInputSource, ResponseContent, -}; -use crate::ir::identifier::is_valid_identifier; -use crate::ir::schema::{SchemaScalar, SchemaType}; -use crate::options::{ResponseType, ResponseTypeMapping}; -use crate::parse::openapi_model::{ - AdditionalProperties, MediaType, Operation, PathItem, RequestBody, Response, Schema, -}; - -use super::schema::normalize_schema; -use super::unsupported; - -pub(super) fn normalize_operations( - paths: &BTreeMap, - schema_index: &BTreeMap<&str, &SchemaType>, - response_type_mapping: &[ResponseTypeMapping], - reporter: &mut Reporter<'_>, -) -> Result, Diagnostic> { - let mut operations = Vec::new(); - - for (path, path_item) in paths { - validate_path_template(path, reporter)?; - for (method, operation) in path_item.operations() { - operations.push(normalize_operation( - path, - method, - operation, - schema_index, - response_type_mapping, - reporter, - )?); - } - } - - Ok(operations) -} - -/// Reject path strings with unbalanced `{` / `}` braces before emit -/// silently produces a broken TypeScript template. The path-template -/// expander in `emit/angular/request.rs` bails on a stray `{` and emits -/// the remainder verbatim — fine on validated input, but a malformed -/// spec like `/pets/{id` would yield `url: \`/pets/id\`` with no -/// `encodeURIComponent` call. Surfacing the error at normalize time -/// keeps the emit stage operating on validated IR. -fn validate_path_template(path: &str, reporter: &mut Reporter<'_>) -> Result<(), Diagnostic> { - let mut rest = path; - while let Some(open) = rest.find('{') { - let after_open = &rest[open + 1..]; - if let Some(stray) = after_open.find('{') { - let close = after_open.find('}'); - if close.is_none_or(|c| stray < c) { - return Err(unsupported( - format!( - "path template {path} contains nested '{{' which is not a valid OpenAPI parameter placeholder." - ), - reporter, - true, - )); - } - } - let Some(close) = after_open.find('}') else { - return Err(unsupported( - format!("path template {path} has an unbalanced '{{' with no matching '}}'."), - reporter, - true, - )); - }; - let name = &after_open[..close]; - if !is_valid_identifier(name) { - return Err(Diagnostic::policy_violation( - reporter, - "invalid-path-parameter-name", - format!( - "path template {path}: parameter name '{name}' is not a valid JavaScript identifier. Rename the parameter or split this path into a non-generated client." - ), - )); - } - rest = &after_open[close + 1..]; - } - if let Some(stray) = rest.find('}') { - let _ = stray; - return Err(unsupported( - format!("path template {path} has an unbalanced '}}' with no matching '{{'."), - reporter, - true, - )); - } - Ok(()) -} - -fn normalize_operation( - path_name: &str, - method: &str, - operation: &Operation, - schema_index: &BTreeMap<&str, &SchemaType>, - response_type_mapping: &[ResponseTypeMapping], - reporter: &mut Reporter<'_>, -) -> Result { - let http_method = HttpMethod::from_lowercase(method).ok_or_else(|| { - let detail = if method == "trace" { - // OpenAPI permits `trace:` but Angular's HttpClient has no - // `.trace()` helper and TRACE is disabled at most production - // gateways for security reasons (XST). Reject explicitly rather - // than silently emitting a service that references an unusable - // method. - format!("HTTP method TRACE for {path_name} is not supported; remove the trace operation or split it into a non-generated client.") - } else { - format!("unknown HTTP method {method} for {path_name}.") - }; - unsupported(detail, reporter, true) - })?; - - let operation_id = operation - .operation_id - .clone() - .unwrap_or_else(|| format!("{}_{}", method, path_name.replace(['/', '{', '}'], "_"))); - - let method_str = http_method.as_str(); - let request = normalize_request( - operation, - &operation_id, - method_str, - path_name, - schema_index, - reporter, - )?; - let response = normalize_success_response( - operation.responses.as_ref(), - http_method, - path_name, - response_type_mapping, - reporter, - )?; - - let errors = normalize_error_responses( - operation.responses.as_ref(), - http_method, - path_name, - reporter, - )?; - - Ok(OperationDef { - operation_id, - tags: operation.tags.clone(), - method: http_method, - path: path_name.to_string(), - request, - response, - errors, - description: operation.merged_description(), - deprecated: operation.deprecated, - }) -} - -fn normalize_request( - operation: &Operation, - operation_id: &str, - method: &str, - path: &str, - schema_index: &BTreeMap<&str, &SchemaType>, - reporter: &mut Reporter<'_>, -) -> Result { - let (inputs, headers) = - normalize_request_inputs(&operation.parameters, operation_id, method, path, reporter)?; - let body = normalize_request_body( - operation.request_body.as_ref(), - method, - path, - schema_index, - reporter, - )?; - Ok(RequestDef { - inputs, - headers, - body, - }) -} - -fn normalize_request_inputs( - parameters: &[crate::parse::openapi_model::Parameter], - operation_id: &str, - method: &str, - path: &str, - reporter: &mut Reporter<'_>, -) -> Result<(Vec, Vec), Diagnostic> { - let mut inputs = Vec::with_capacity(parameters.len()); - let mut headers = Vec::new(); - - for parameter in parameters { - let name = ¶meter.name; - // `None` routes the parameter to `headers`; `Some(source)` routes it to - // `inputs` with that source. `cookie` short-circuits with a warning, - // anything else is an error. - let source: Option = match parameter.location.as_str() { - "path" => Some(RequestInputSource::Path), - "query" => Some(RequestInputSource::Query), - "header" => None, - "cookie" => { - // Cookies are managed by the browser; surfacing them in the - // generated request contract would create an inconsistent API - // surface (the client can't actually set Cookie headers). Warn - // and drop the parameter here at normalize-time so downstream - // stages never see it. - reporter.warning( - DiagnosticCode::UnsupportedSemantic, - Some("unsupported-parameter-location"), - format!( - "operationId '{operation_id}': parameter '{name}' uses location 'cookie', which is not supported in the generated service contract and will be omitted.", - ), - ); - continue; - } - other => { - return Err(unsupported( - format!("parameter {name} for {method} {path} uses unsupported location {other}."), - reporter, - true, - )); - } - }; - - let required = parameter.required; - - if source == Some(RequestInputSource::Path) && !required { - return Err(unsupported( - format!("path parameter {name} for {method} {path} must be required."), - reporter, - true, - )); - } - - if parameter.content.is_some() { - return Err(unsupported( - format!("parameter {name} for {method} {path} must use schema, not content."), - reporter, - true, - )); - } - - let schema = parameter.schema.as_ref().ok_or_else(|| { - unsupported( - format!("parameter {name} for {method} {path} must define schema."), - reporter, - true, - ) - })?; - - // Each operation-level schema walk starts at depth 0; the recursion - // counter only spans a single schema tree, not the request/response - // grouping above it. - let param_context = Context::Parameter { method, path }; - let ty = normalize_schema(schema, ¶m_context, 0, reporter)?; - match ty { - SchemaType::InlineObject { .. } => { - return Err(unsupported( - format!( - "parameter {name} for {method} {path} uses an inline object schema, which is outside the supported subset." - ), - reporter, - true, - )); - } - SchemaType::Any => { - return Err(unsupported( - format!( - "parameter {name} for {method} {path} uses an empty schema, which is outside the supported subset." - ), - reporter, - true, - )); - } - _ => {} - } - - match source { - Some(source) => inputs.push(RequestInputDef { - name: name.as_str().into(), - source, - required, - ty, - }), - None => headers.push(HeaderDef { - name: name.as_str().into(), - required, - ty, - }), - } - } - - inputs.sort_by(|left, right| request_input_sort_key(left).cmp(&request_input_sort_key(right))); - headers.sort_by(|left, right| left.name.cmp(&right.name)); - Ok((inputs, headers)) -} - -fn normalize_request_body( - request_body: Option<&RequestBody>, - method: &str, - path: &str, - schema_index: &BTreeMap<&str, &SchemaType>, - reporter: &mut Reporter<'_>, -) -> Result, Diagnostic> { - let Some(body) = request_body else { - return Ok(None); - }; - - // Multi-content bodies cannot be represented by a single request - // contract: the caller would have to pick one media type at call site, - // which defeats the typed-client guarantees. Reject up-front with a - // dedicated subcode so downstream tooling can route on it. - if body.content.len() > 1 { - return Err(Diagnostic::policy_violation( - reporter, - "multi-content-body", - format!("requestBody for {method} {path} must declare exactly one content type."), - )); - } - - let Some((mime, media)) = body.content.iter().next() else { - return Ok(None); - }; - // OpenAPI permits MIME case variation (`Application/JSON`); lowercase - // before matching so the dispatch is case-insensitive while the arms - // remain canonical-form string literals. - let mime_lc = mime.to_ascii_lowercase(); - - let content = match mime_lc.as_str() { - "application/json" => { - let schema = media.schema.as_ref().ok_or_else(|| { - unsupported( - format!("requestBody for {method} {path} must define schema."), - reporter, - true, - ) - })?; - - let body_context = Context::RequestBody { method, path }; - let ty = normalize_schema(schema, &body_context, 0, reporter)?; - - if matches!(ty, SchemaType::Any) { - return Err(unsupported( - format!("requestBody for {method} {path} must define a concrete schema."), - reporter, - true, - )); - } - - BodyContent::Json(ty) - } - "multipart/form-data" => { - let (body_ref, fields) = normalize_form_body_fields( - media, - FormKind::Multipart, - method, - path, - schema_index, - reporter, - )?; - BodyContent::Multipart { body_ref, fields } - } - "application/x-www-form-urlencoded" => { - let (body_ref, fields) = normalize_form_body_fields( - media, - FormKind::UrlEncoded, - method, - path, - schema_index, - reporter, - )?; - BodyContent::UrlEncoded { body_ref, fields } - } - other => { - return Err(Diagnostic::policy_violation( - reporter, - "unsupported-body-content-type", - format!( - "requestBody for {method} {path}: unsupported content type {other:?}. Use application/json, multipart/form-data, or application/x-www-form-urlencoded." - ), - )); - } - }; - - Ok(Some(RequestBodyDef { - required: body.required, - content, - })) -} - -/// Discriminates between `multipart/form-data` and -/// `application/x-www-form-urlencoded` so the field walker can apply the -/// content-type-specific rejection rules (e.g. urlencoded forbids binary -/// payloads). -#[derive(Clone, Copy)] -enum FormKind { - Multipart, - UrlEncoded, -} - -/// Normalizes a `multipart/form-data` or `application/x-www-form-urlencoded` -/// media's schema into a flat list of `BodyField`s. Top-level `$ref`s to a -/// named object are recorded in the returned `body_ref` so plan/emit can -/// surface the schema name in the request contract. Returned fields are -/// sorted alphabetically for deterministic emit. -/// -/// Format-binary detection requires raw `Schema.format`, which the IR-side -/// `SchemaType` does not carry — so we peek the raw `Schema` directly for -/// inline bodies. For top-level `$ref` bodies the raw schema is not -/// reachable from `schema_index` (which carries normalized types only); the -/// resolved properties come from the normalized index and format detection -/// for ref-target bodies is currently a no-op. The Task 7 accept tests do -/// not exercise format-binary through a `$ref`. -fn normalize_form_body_fields( - media: &MediaType, - kind: FormKind, - method: &str, - path: &str, - schema_index: &BTreeMap<&str, &SchemaType>, - reporter: &mut Reporter<'_>, -) -> Result<(Option>, Vec), Diagnostic> { - let raw_schema = media.schema.as_ref().ok_or_else(|| { - Diagnostic::policy_violation( - reporter, - "missing-body-schema", - format!("requestBody for {method} {path} must define schema."), - ) - })?; - - // Open-schema pre-check: form bodies must enumerate every field - // statically so the field walker can emit a stable contract. Any form - // of `additionalProperties` (literal `true`, or a schema describing - // the additional values) means the body's shape is open-ended and - // cannot be represented as a fixed `FormData` / urlencoded layout. - // Reject up-front so the per-property walk below operates on a closed - // object. `additionalProperties: false` and the absent case are fine. - // Subcode is kind-aware so downstream tooling can route on the precise - // form variant without parsing the message. - if let Some(ap) = &raw_schema.additional_properties - && !matches!(ap, AdditionalProperties::Boolean(false)) - { - return Err(Diagnostic::policy_violation( - reporter, - open_schema_subcode(kind), - format!( - "requestBody for {method} {path}: {} bodies must not declare additionalProperties; every field must be enumerated.", - form_kind_label(kind), - ), - )); - } - - let body_context = Context::RequestBody { method, path }; - let normalized = normalize_schema(raw_schema, &body_context, 0, reporter)?; - - // Resolve a top-level $ref by looking up the resolved `SchemaType` in - // `schema_index`. The body_ref is recorded so plan/emit can re-surface - // the named schema in the request contract; the actual property walk - // uses the resolved InlineObject. - let (body_ref, resolved_ty): (Option>, &SchemaType) = match &normalized { - SchemaType::Ref(name) => { - let resolved = schema_index.get(name.as_ref()).ok_or_else(|| { - unsupported( - format!("requestBody for {method} {path} references unknown schema '{name}'.",), - reporter, - true, - ) - })?; - (Some(name.clone()), *resolved) - } - other => (None, other), - }; - - // The resolved body schema must be an `InlineObject`. Other shapes - // (Map, Array, Scalar, Union, ...) cannot be flattened into discrete - // form fields, so we reject with a kind-aware non-object-body subcode - // so downstream consumers can route on the precise reason and variant. - let SchemaType::InlineObject { properties } = resolved_ty else { - return Err(Diagnostic::policy_violation( - reporter, - non_object_body_subcode(kind), - format!( - "requestBody for {method} {path}: {} body schema must resolve to an object.", - form_kind_label(kind), - ), - )); - }; - - // Raw-peek table for format detection. Populated from the inline - // body's raw `Schema.properties`; empty when the body is a top-level - // `$ref` (raw schemas behind refs are not threaded through to this - // layer — see function-level doc comment). - let raw_property_lookup = collect_raw_property_formats(raw_schema); - - let mut fields: Vec = Vec::with_capacity(properties.len()); - for prop in properties { - // Emit interpolates the field name as a bare JS identifier in the - // form-body IIFE (`if (name !== undefined)`, `for (const v of name)`). - // Reject names that aren't valid identifiers at normalize time so - // emit operates on validated input. - if !is_valid_identifier(prop.name.as_ref()) { - return Err(Diagnostic::policy_violation( - reporter, - "invalid-form-field-name", - format!( - "body field '{name}' in {method} {path}: name is not a valid JavaScript identifier. Rename the field or split this body into a non-generated client.", - name = prop.name.as_ref(), - ), - )); - } - let raw_format = raw_property_lookup - .get(prop.name.as_ref()) - .copied() - .unwrap_or(RawPropertyFormat::default()); - let ty = classify_body_field_type( - &prop.ty, - raw_format, - kind, - method, - path, - prop.name.as_ref(), - reporter, - )?; - fields.push(BodyField { - name: prop.name.clone(), - required: prop.required, - ty, - }); - } - - fields.sort_by(|a, b| a.name.cmp(&b.name)); - Ok((body_ref, fields)) -} - -/// Format hints peeked from the raw `Schema` for one body property. -/// `own` is the property schema's own `format` (relevant for -/// `type: string, format: binary`). `items` is the array-item schema's -/// `format` (relevant for `type: array, items: { format: binary }`). -/// Both default to `None` when the property's raw schema is unavailable -/// (e.g. when the body is a top-level `$ref`). -#[derive(Clone, Copy, Default)] -struct RawPropertyFormat<'a> { - own: Option<&'a str>, - items: Option<&'a str>, -} - -/// Walks the raw inline body `Schema.properties` to build a lookup of -/// per-property `format` hints. Used to detect `format: binary` (and, -/// for arrays, `items.format: binary`) which the normalized -/// `SchemaType` deliberately does not carry — keeping format-binary -/// semantics confined to form-body normalize. -fn collect_raw_property_formats(raw_schema: &Schema) -> BTreeMap<&str, RawPropertyFormat<'_>> { - let mut lookup = BTreeMap::new(); - let Some(properties) = &raw_schema.properties else { - return lookup; - }; - for (name, schema) in properties { - let own = schema.format.as_deref(); - let items = schema - .items - .as_deref() - .and_then(|item_schema| item_schema.format.as_deref()); - lookup.insert(name.as_str(), RawPropertyFormat { own, items }); - } - lookup -} - -/// Classifies one form-body property into a `BodyFieldType`. Accept -/// branches cover scalar, array-of-scalar, binary, and array-of-binary. -/// Reject branches use kebab-case subcodes consumers route on. Subcodes -/// are FormKind-aware so downstream tooling can distinguish multipart -/// vs urlencoded reject paths without parsing the message: -/// `multipart-nested-object` / `urlencoded-nested-object`, -/// `multipart-composed-field` / `urlencoded-composed-field`, -/// `urlencoded-binary-field`. -fn classify_body_field_type( - ty: &SchemaType, - raw_format: RawPropertyFormat<'_>, - kind: FormKind, - method: &str, - path: &str, - field_name: &str, - reporter: &mut Reporter<'_>, -) -> Result { - match ty { - // Binary: string + format: binary. - SchemaType::Scalar(SchemaScalar::String) if raw_format.own == Some("binary") => match kind { - FormKind::Multipart => Ok(BodyFieldType::Binary), - // Urlencoded forbids binary payloads; a single `urlencoded-binary-field` - // subcode covers both scalar binary and array-of-binary so downstream - // routing can collapse the two reject arms into one branch. - FormKind::UrlEncoded => Err(Diagnostic::policy_violation( - reporter, - "urlencoded-binary-field", - format!( - "body field '{field_name}' in {method} {path}: binary fields are not supported in application/x-www-form-urlencoded." - ), - )), - }, - // Array of binary: array of (string + format: binary). Detected - // via the array-item's raw `format` hint (`raw_format.items`). - SchemaType::Array(inner) - if matches!(inner.as_ref(), SchemaType::Scalar(SchemaScalar::String)) - && raw_format.items == Some("binary") => - { - match kind { - FormKind::Multipart => Ok(BodyFieldType::ArrayOfBinary), - FormKind::UrlEncoded => Err(Diagnostic::policy_violation( - reporter, - "urlencoded-binary-field", - format!( - "body field '{field_name}' in {method} {path}: array-of-binary fields are not supported in application/x-www-form-urlencoded." - ), - )), - } - } - SchemaType::Scalar(scalar) => Ok(BodyFieldType::Scalar(scalar.clone())), - SchemaType::Array(inner) => match inner.as_ref() { - SchemaType::Scalar(scalar) => Ok(BodyFieldType::ArrayOfScalar(scalar.clone())), - // Arrays whose items are not scalar/binary cannot be flattened - // into repeated form-field entries; treat them as composed for - // routing purposes (consistent with the "composed" semantics for - // a field's payload shape). - _ => Err(Diagnostic::policy_violation( - reporter, - composed_field_subcode(kind), - format!( - "body field '{field_name}' in {method} {path}: array items must be scalar or binary." - ), - )), - }, - SchemaType::InlineObject { .. } | SchemaType::Ref(_) => Err(Diagnostic::policy_violation( - reporter, - nested_object_subcode(kind), - format!( - "body field '{field_name}' in {method} {path}: nested objects are not supported in {} bodies.", - form_kind_label(kind), - ), - )), - // Composed (oneOf/anyOf/allOf), Nullable, Map, non-string-literal - // enums, Any — all collapse into a single "composed" subcode so the - // downstream router can recognise the family without parsing the - // message. - _ => Err(Diagnostic::policy_violation( - reporter, - composed_field_subcode(kind), - format!( - "body field '{field_name}' in {method} {path}: composed schemas are not supported in {} bodies.", - form_kind_label(kind), - ), - )), - } -} - -/// Subcode for a nested-object reject, kind-aware so downstream tooling -/// can distinguish multipart vs urlencoded paths. -const fn nested_object_subcode(kind: FormKind) -> &'static str { - match kind { - FormKind::Multipart => "multipart-nested-object", - FormKind::UrlEncoded => "urlencoded-nested-object", - } -} - -/// Subcode for a composed-field reject (oneOf/anyOf/allOf, nullable, -/// non-string-literal enums, array-of-non-scalar, etc.), kind-aware. -const fn composed_field_subcode(kind: FormKind) -> &'static str { - match kind { - FormKind::Multipart => "multipart-composed-field", - FormKind::UrlEncoded => "urlencoded-composed-field", - } -} - -/// Subcode for a non-object top-level body reject (the resolved body -/// schema is a scalar, array, map, union, ...). Kind-aware so downstream -/// tooling can distinguish multipart vs urlencoded paths. -const fn non_object_body_subcode(kind: FormKind) -> &'static str { - match kind { - FormKind::Multipart => "multipart-non-object-body", - FormKind::UrlEncoded => "urlencoded-non-object-body", - } -} - -/// Subcode for an open-schema reject (top-level `additionalProperties` -/// is `true` or a schema). Kind-aware so consumers can distinguish -/// multipart vs urlencoded variants. -const fn open_schema_subcode(kind: FormKind) -> &'static str { - match kind { - FormKind::Multipart => "multipart-open-schema", - FormKind::UrlEncoded => "urlencoded-open-schema", - } -} - -/// Human-readable label used in diagnostic messages so consumers can tell -/// the kind apart without inspecting the subcode. -const fn form_kind_label(kind: FormKind) -> &'static str { - match kind { - FormKind::Multipart => "multipart", - FormKind::UrlEncoded => "urlencoded", - } -} - -fn normalize_success_response( - responses: Option<&BTreeMap>, - method: HttpMethod, - path_name: &str, - response_type_mapping: &[ResponseTypeMapping], - reporter: &mut Reporter<'_>, -) -> Result, Diagnostic> { - let Some(responses) = responses else { - return Ok(None); - }; - - let Some((_status, response)) = responses - .iter() - .find(|(status, _)| is_success_status(status)) - else { - return Ok(None); - }; - - let Some(content) = &response.content else { - return Ok(None); - }; - - let Some((mime, media)) = pick_response_media(content, response_type_mapping) else { - return Ok(None); - }; - - let kind = classify_response_kind(mime, response_type_mapping); - let response_context = Context::ResponseSchema { - method: method.as_str(), - path: path_name, - }; - - Ok(Some(match kind { - ResponseKind::Json => { - let schema = match &media.schema { - Some(s) => Some(normalize_schema(s, &response_context, 0, reporter)?), - None => None, - }; - ResponseContent::Json(schema) - } - ResponseKind::Blob => ResponseContent::Blob, - ResponseKind::Text => ResponseContent::Text, - ResponseKind::ArrayBuffer => ResponseContent::ArrayBuffer, - })) -} - -/// Collects non-2xx response slots with a JSON schema, sorted by status -/// ascending. Lenient by design: schemaless and non-JSON error responses -/// are silently skipped (real specs commonly underspecify errors, and a -/// hard rejection here would be hostile). The `default` key is also -/// skipped — the emitted surface (`OperationError[400]`) only carries -/// numeric status keys for now. -fn normalize_error_responses( - responses: Option<&BTreeMap>, - method: HttpMethod, - path_name: &str, - reporter: &mut Reporter<'_>, -) -> Result, Diagnostic> { - let Some(responses) = responses else { - return Ok(Vec::new()); - }; - - let mut errors: Vec = Vec::new(); - for (status_str, response) in responses { - let Some(status) = parse_error_status(status_str) else { - continue; - }; - let Some(content) = &response.content else { - continue; - }; - let Some(media) = content.get("application/json") else { - continue; - }; - let Some(raw_schema) = &media.schema else { - continue; - }; - let response_context = Context::ResponseSchema { - method: method.as_str(), - path: path_name, - }; - let body = normalize_schema(raw_schema, &response_context, 0, reporter)?; - errors.push(ErrorResponse { status, body }); - } - errors.sort_by_key(|e| e.status); - Ok(errors) -} - -/// Parses a response key as a 4xx or 5xx HTTP status code. Returns `None` -/// for 2xx, 1xx, 3xx, the `default` key, and malformed values. -fn parse_error_status(status: &str) -> Option { - if status.len() != 3 { - return None; - } - let leading = status.as_bytes()[0]; - if leading != b'4' && leading != b'5' { - return None; - } - status.parse::().ok() -} - -/// Picks the media entry to use for a response's typed body. Prefers -/// the first entry whose classification is **not** `Blob` (so -/// `application/json` alongside `application/octet-stream` picks the -/// JSON entry); falls back to the first `Blob` entry when no non-Blob -/// classification exists. Iteration over `BTreeMap` is alphabetical by -/// key, which is the source of determinism here. -fn pick_response_media<'a>( - content: &'a BTreeMap, - user_mapping: &[ResponseTypeMapping], -) -> Option<(&'a str, &'a MediaType)> { - let mut first_blob: Option<(&str, &MediaType)> = None; - for (mime, media) in content { - let kind = classify_response_kind(mime, user_mapping); - if kind != ResponseKind::Blob { - return Some((mime.as_str(), media)); - } - if first_blob.is_none() { - first_blob = Some((mime.as_str(), media)); - } - } - first_blob -} - -fn request_input_sort_key(value: &RequestInputDef) -> (u8, &str) { - let weight = match value.source { - RequestInputSource::Path => 0, - RequestInputSource::Query => 1, - }; - - (weight, &value.name) -} - -fn is_success_status(status: &str) -> bool { - status.starts_with('2') -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum ResponseKind { - Json, - Blob, - Text, - ArrayBuffer, -} - -fn classify_response_kind( - content_type: &str, - user_mapping: &[ResponseTypeMapping], -) -> ResponseKind { - let normalized = content_type.to_ascii_lowercase(); - - // 1. User mapping (exact case-insensitive match) wins. - if let Some(m) = user_mapping - .iter() - .find(|m| m.content_type.eq_ignore_ascii_case(&normalized)) - { - return match m.response_type { - ResponseType::Json => ResponseKind::Json, - ResponseType::Blob => ResponseKind::Blob, - ResponseType::Text => ResponseKind::Text, - ResponseType::ArrayBuffer => ResponseKind::ArrayBuffer, - }; - } - - // 2. Built-in defaults. - if normalized == "application/json" || normalized.ends_with("+json") { - return ResponseKind::Json; - } - if normalized.starts_with("text/") { - return ResponseKind::Text; - } - ResponseKind::Blob -} - -#[cfg(test)] -mod tests { - use std::collections::BTreeMap; - - use super::{ - ResponseKind, classify_response_kind, normalize_error_responses, normalize_request_body, - normalize_success_response, parse_error_status, pick_response_media, validate_path_template, - }; - use crate::ir::canonical::{BodyContent, BodyFieldType, HttpMethod}; - use crate::ir::schema::{SchemaProperty, SchemaScalar, SchemaType}; - use crate::options::{ResponseType, ResponseTypeMapping}; - use crate::parse::openapi_model::{MediaType, RequestBody, Response, Schema}; - use crate::test_support::test_ctx; - - fn parse_request_body(yaml: &str) -> RequestBody { - serde_yml::from_str(yaml).expect("fixture parses as RequestBody") - } - - fn empty_schema_index<'a>() -> BTreeMap<&'a str, &'a SchemaType> { - BTreeMap::new() - } - - fn json_schema() -> Schema { - Schema::default_string() - } - - fn btreemap_with(key: K, value: V) -> BTreeMap { - BTreeMap::from([(key, value)]) - } - - #[test] - fn classifies_application_json_as_json() { - assert_eq!( - classify_response_kind("application/json", &[]), - ResponseKind::Json - ); - } - - #[test] - fn classifies_problem_json_as_json() { - assert_eq!( - classify_response_kind("application/problem+json", &[]), - ResponseKind::Json - ); - assert_eq!( - classify_response_kind("application/vnd.api+json", &[]), - ResponseKind::Json - ); - } - - #[test] - fn classifies_text_plain_as_text() { - assert_eq!( - classify_response_kind("text/plain", &[]), - ResponseKind::Text - ); - assert_eq!(classify_response_kind("text/csv", &[]), ResponseKind::Text); - } - - #[test] - fn classifies_application_pdf_as_blob_via_default() { - assert_eq!( - classify_response_kind("application/pdf", &[]), - ResponseKind::Blob - ); - } - - #[test] - fn classifies_octet_stream_as_blob_via_default() { - assert_eq!( - classify_response_kind("application/octet-stream", &[]), - ResponseKind::Blob - ); - } - - #[test] - fn user_mapping_overrides_default() { - let mapping = vec![ResponseTypeMapping { - content_type: "application/octet-stream".into(), - response_type: ResponseType::ArrayBuffer, - }]; - assert_eq!( - classify_response_kind("application/octet-stream", &mapping), - ResponseKind::ArrayBuffer - ); - } - - #[test] - fn user_mapping_matches_case_insensitively() { - let mapping = vec![ResponseTypeMapping { - content_type: "application/PDF".into(), - response_type: ResponseType::ArrayBuffer, - }]; - assert_eq!( - classify_response_kind("application/pdf", &mapping), - ResponseKind::ArrayBuffer - ); - } - - #[test] - fn pick_response_media_prefers_non_blob_classification() { - let mut content = BTreeMap::::new(); - content.insert( - "application/json".into(), - MediaType { - schema: Some(json_schema()), - }, - ); - content.insert( - "application/octet-stream".into(), - MediaType { schema: None }, - ); - - let (mime, _) = pick_response_media(&content, &[]).expect("at least one media"); - assert_eq!(mime, "application/json"); - } - - #[test] - fn pick_response_media_returns_first_blob_when_only_blob_kinds() { - let mut content = BTreeMap::::new(); - content.insert("application/pdf".into(), MediaType { schema: None }); - content.insert("application/zip".into(), MediaType { schema: None }); - let (mime, _) = pick_response_media(&content, &[]).expect("at least one media"); - // BTreeMap iteration order is sorted; "application/pdf" sorts before "application/zip". - assert_eq!(mime, "application/pdf"); - } - - #[test] - fn no_response_content_yields_none_response() { - // A response with no `content` block at all. - let response = Response { content: None }; - let mut ctx = test_ctx(); - let result = normalize_success_response( - Some(&btreemap_with("200".to_string(), response)), - HttpMethod::Get, - "/x", - &[], - &mut ctx.reporter(), - ) - .expect("normalize ok"); - assert!(result.is_none(), "missing response content => None"); - } - - // ── normalize_error_responses ──────────────────────────────────────────── - - /// Builds a Response with a single JSON content entry carrying the - /// given schema. Helper for the error-response tests below. - fn json_response(schema: Schema) -> Response { - Response { - content: Some(BTreeMap::from([( - "application/json".to_string(), - MediaType { - schema: Some(schema), - }, - )])), - } - } - - #[test] - fn parse_error_status_accepts_4xx_and_5xx_only() { - assert_eq!(parse_error_status("400"), Some(400)); - assert_eq!(parse_error_status("404"), Some(404)); - assert_eq!(parse_error_status("500"), Some(500)); - assert_eq!(parse_error_status("503"), Some(503)); - // 2xx, 1xx, 3xx, default key, and malformed values all reject. - assert_eq!(parse_error_status("200"), None); - assert_eq!(parse_error_status("101"), None); - assert_eq!(parse_error_status("301"), None); - assert_eq!(parse_error_status("default"), None); - assert_eq!(parse_error_status("4xx"), None); - assert_eq!(parse_error_status(""), None); - } - - #[test] - fn collects_4xx_and_5xx_responses_with_json_schemas_sorted_by_status() { - let mut responses = BTreeMap::new(); - responses.insert("200".to_string(), json_response(Schema::default_string())); - responses.insert("500".to_string(), json_response(Schema::default_string())); - responses.insert("400".to_string(), json_response(Schema::default_string())); - responses.insert("404".to_string(), json_response(Schema::default_string())); - - let mut ctx = test_ctx(); - let errors = - normalize_error_responses(Some(&responses), HttpMethod::Get, "/x", &mut ctx.reporter()) - .expect("normalize ok"); - - assert_eq!( - errors.iter().map(|e| e.status).collect::>(), - vec![400, 404, 500] - ); - } - - #[test] - fn skips_schemaless_and_non_json_error_responses() { - let mut responses = BTreeMap::new(); - responses.insert("400".to_string(), json_response(Schema::default_string())); - // 503: no content block at all — must be skipped without error. - responses.insert("503".to_string(), Response { content: None }); - // 502: content block, but JSON entry has no schema — must be skipped. - responses.insert( - "502".to_string(), - Response { - content: Some(BTreeMap::from([( - "application/json".to_string(), - MediaType { schema: None }, - )])), - }, - ); - // 504: only non-JSON content — must be skipped. - responses.insert( - "504".to_string(), - Response { - content: Some(BTreeMap::from([( - "text/plain".to_string(), - MediaType { - schema: Some(Schema::default_string()), - }, - )])), - }, - ); - - let mut ctx = test_ctx(); - let errors = - normalize_error_responses(Some(&responses), HttpMethod::Get, "/x", &mut ctx.reporter()) - .expect("normalize ok"); - - assert_eq!( - errors.iter().map(|e| e.status).collect::>(), - vec![400] - ); - } - - #[test] - fn skips_default_response_key() { - let mut responses = BTreeMap::new(); - responses.insert( - "default".to_string(), - json_response(Schema::default_string()), - ); - responses.insert("400".to_string(), json_response(Schema::default_string())); - - let mut ctx = test_ctx(); - let errors = - normalize_error_responses(Some(&responses), HttpMethod::Get, "/x", &mut ctx.reporter()) - .expect("normalize ok"); - - // Only 400 survives — `default` is intentionally not surfaced. - assert_eq!( - errors.iter().map(|e| e.status).collect::>(), - vec![400] - ); - } - - // ── Multipart body field walker (Task 7 — accept path) ──────────────────── - - #[test] - fn accepts_multipart_with_scalar_array_and_binary_fields() { - let yaml = r#" -content: - multipart/form-data: - schema: - type: object - required: [status, avatar] - properties: - status: { type: string } - tagIds: { type: array, items: { type: number } } - avatar: { type: string, format: binary } - nickname: { type: string } -"#; - let body = parse_request_body(yaml); - let mut ctx = test_ctx(); - let result = normalize_request_body( - Some(&body), - "POST", - "/pets", - &empty_schema_index(), - &mut ctx.reporter(), - ) - .expect("normalize ok") - .expect("body present"); - - match result.content { - BodyContent::Multipart { body_ref, fields } => { - assert_eq!(body_ref, None); - let names: Vec<&str> = fields.iter().map(|f| f.name.as_ref()).collect(); - // Sorted alphabetically. - assert_eq!(names, vec!["avatar", "nickname", "status", "tagIds"]); - let avatar = fields.iter().find(|f| f.name.as_ref() == "avatar").unwrap(); - assert_eq!(avatar.ty, BodyFieldType::Binary); - assert!(avatar.required); - let status = fields.iter().find(|f| f.name.as_ref() == "status").unwrap(); - assert!(matches!( - status.ty, - BodyFieldType::Scalar(SchemaScalar::String) - )); - assert!(status.required); - let nickname = fields - .iter() - .find(|f| f.name.as_ref() == "nickname") - .unwrap(); - assert!(!nickname.required); - let tag_ids = fields.iter().find(|f| f.name.as_ref() == "tagIds").unwrap(); - assert!(matches!( - tag_ids.ty, - BodyFieldType::ArrayOfScalar(SchemaScalar::Number) - )); - } - other => panic!("expected Multipart, got {other:?}"), - } - } - - #[test] - fn accepts_multipart_with_array_of_binary_fields() { - let yaml = r#" -content: - multipart/form-data: - schema: - type: object - required: [galleries] - properties: - galleries: { type: array, items: { type: string, format: binary } } -"#; - let body = parse_request_body(yaml); - let mut ctx = test_ctx(); - let result = normalize_request_body( - Some(&body), - "POST", - "/x", - &empty_schema_index(), - &mut ctx.reporter(), - ) - .expect("normalize ok") - .expect("body present"); - - match result.content { - BodyContent::Multipart { fields, .. } => { - assert_eq!(fields.len(), 1); - assert_eq!(fields[0].ty, BodyFieldType::ArrayOfBinary); - } - other => panic!("expected Multipart, got {other:?}"), - } - } - - #[test] - fn accepts_multipart_with_ref_to_named_object() { - let yaml = r#" -content: - multipart/form-data: - schema: - $ref: '#/components/schemas/UploadForm' -"#; - let body = parse_request_body(yaml); - let upload_form_body = SchemaType::InlineObject { - properties: vec![SchemaProperty { - name: "status".into(), - required: true, - ty: SchemaType::Scalar(SchemaScalar::String), - description: None, - deprecated: false, - }], - }; - let schema_index = BTreeMap::from([("UploadForm", &upload_form_body)]); - let mut ctx = test_ctx(); - let result = normalize_request_body( - Some(&body), - "POST", - "/x", - &schema_index, - &mut ctx.reporter(), - ) - .expect("normalize ok") - .expect("body present"); - - match result.content { - BodyContent::Multipart { body_ref, fields } => { - assert_eq!(body_ref.as_deref(), Some("UploadForm")); - assert_eq!(fields.len(), 1); - assert_eq!(fields[0].name.as_ref(), "status"); - } - other => panic!("expected Multipart, got {other:?}"), - } - } - - // ── Multipart body field walker (Task 8 — reject paths) ────────────────── - - #[test] - fn rejects_multipart_with_nested_object_field() { - let yaml = r#" -content: - multipart/form-data: - schema: - type: object - properties: - metadata: - type: object - properties: - authorId: { type: string } -"#; - let body = parse_request_body(yaml); - let mut ctx = test_ctx(); - let err = normalize_request_body( - Some(&body), - "POST", - "/x", - &empty_schema_index(), - &mut ctx.reporter(), - ) - .expect_err("nested object should fail"); - assert_eq!(err.subcode, Some("multipart-nested-object")); - } - - #[test] - fn rejects_multipart_with_composed_field() { - let yaml = r#" -content: - multipart/form-data: - schema: - type: object - properties: - variant: - oneOf: - - { type: string } - - { type: number } -"#; - let body = parse_request_body(yaml); - let mut ctx = test_ctx(); - let err = normalize_request_body( - Some(&body), - "POST", - "/x", - &empty_schema_index(), - &mut ctx.reporter(), - ) - .expect_err("composed field should fail"); - assert_eq!(err.subcode, Some("multipart-composed-field")); - } - - #[test] - fn rejects_multipart_with_additional_properties_true() { - let yaml = r#" -content: - multipart/form-data: - schema: - type: object - additionalProperties: true - properties: - status: { type: string } -"#; - let body = parse_request_body(yaml); - let mut ctx = test_ctx(); - let err = normalize_request_body( - Some(&body), - "POST", - "/x", - &empty_schema_index(), - &mut ctx.reporter(), - ) - .expect_err("open schema should fail"); - assert_eq!(err.subcode, Some("multipart-open-schema")); - } - - #[test] - fn rejects_multipart_with_non_object_top_level_schema() { - let yaml = r#" -content: - multipart/form-data: - schema: - type: string -"#; - let body = parse_request_body(yaml); - let mut ctx = test_ctx(); - let err = normalize_request_body( - Some(&body), - "POST", - "/x", - &empty_schema_index(), - &mut ctx.reporter(), - ) - .expect_err("non-object body should fail"); - assert_eq!(err.subcode, Some("multipart-non-object-body")); - } - - // ── Urlencoded + content-type dispatch (Task 9) ────────────────────────── - - #[test] - fn accepts_urlencoded_with_scalar_and_array_of_scalar_fields() { - let yaml = r#" -content: - application/x-www-form-urlencoded: - schema: - type: object - required: [status] - properties: - status: { type: string } - tagIds: { type: array, items: { type: number } } -"#; - let body = parse_request_body(yaml); - let mut ctx = test_ctx(); - let result = normalize_request_body( - Some(&body), - "POST", - "/x", - &empty_schema_index(), - &mut ctx.reporter(), - ) - .expect("normalize ok") - .expect("body present"); - - match result.content { - BodyContent::UrlEncoded { fields, .. } => { - assert_eq!( - fields.iter().map(|f| f.name.as_ref()).collect::>(), - vec!["status", "tagIds"] - ); - } - other => panic!("expected UrlEncoded, got {other:?}"), - } - } - - #[test] - fn rejects_urlencoded_with_binary_field() { - let yaml = r#" -content: - application/x-www-form-urlencoded: - schema: - type: object - properties: - avatar: { type: string, format: binary } -"#; - let body = parse_request_body(yaml); - let mut ctx = test_ctx(); - let err = normalize_request_body( - Some(&body), - "POST", - "/x", - &empty_schema_index(), - &mut ctx.reporter(), - ) - .expect_err("binary in urlencoded should fail"); - assert_eq!(err.subcode, Some("urlencoded-binary-field")); - } - - #[test] - fn rejects_urlencoded_with_nested_object_field() { - let yaml = r#" -content: - application/x-www-form-urlencoded: - schema: - type: object - properties: - metadata: - type: object - properties: - authorId: { type: string } -"#; - let body = parse_request_body(yaml); - let mut ctx = test_ctx(); - let err = normalize_request_body( - Some(&body), - "POST", - "/x", - &empty_schema_index(), - &mut ctx.reporter(), - ) - .expect_err("nested object should fail"); - assert_eq!(err.subcode, Some("urlencoded-nested-object")); - } - - #[test] - fn rejects_urlencoded_with_composed_field() { - let yaml = r#" -content: - application/x-www-form-urlencoded: - schema: - type: object - properties: - variant: - oneOf: - - { type: string } - - { type: number } -"#; - let body = parse_request_body(yaml); - let mut ctx = test_ctx(); - let err = normalize_request_body( - Some(&body), - "POST", - "/x", - &empty_schema_index(), - &mut ctx.reporter(), - ) - .expect_err("composed field should fail"); - assert_eq!(err.subcode, Some("urlencoded-composed-field")); - } - - #[test] - fn rejects_urlencoded_with_non_object_top_level_schema() { - let yaml = r#" -content: - application/x-www-form-urlencoded: - schema: - type: string -"#; - let body = parse_request_body(yaml); - let mut ctx = test_ctx(); - let err = normalize_request_body( - Some(&body), - "POST", - "/x", - &empty_schema_index(), - &mut ctx.reporter(), - ) - .expect_err("non-object urlencoded body should fail"); - assert_eq!(err.subcode, Some("urlencoded-non-object-body")); - } - - #[test] - fn rejects_urlencoded_with_additional_properties_true() { - let yaml = r#" -content: - application/x-www-form-urlencoded: - schema: - type: object - additionalProperties: true - properties: - status: { type: string } -"#; - let body = parse_request_body(yaml); - let mut ctx = test_ctx(); - let err = normalize_request_body( - Some(&body), - "POST", - "/x", - &empty_schema_index(), - &mut ctx.reporter(), - ) - .expect_err("open urlencoded schema should fail"); - assert_eq!(err.subcode, Some("urlencoded-open-schema")); - } - - #[test] - fn rejects_body_with_multiple_content_types() { - let yaml = r#" -content: - application/json: - schema: { type: object, properties: { x: { type: string } } } - multipart/form-data: - schema: { type: object, properties: { x: { type: string } } } -"#; - let body = parse_request_body(yaml); - let mut ctx = test_ctx(); - let err = normalize_request_body( - Some(&body), - "POST", - "/x", - &empty_schema_index(), - &mut ctx.reporter(), - ) - .expect_err("multi-content should fail"); - assert_eq!(err.subcode, Some("multi-content-body")); - } - - #[test] - fn rejects_unsupported_body_content_type() { - let yaml = r#" -content: - application/xml: - schema: { type: object, properties: { x: { type: string } } } -"#; - let body = parse_request_body(yaml); - let mut ctx = test_ctx(); - let err = normalize_request_body( - Some(&body), - "POST", - "/x", - &empty_schema_index(), - &mut ctx.reporter(), - ) - .expect_err("xml body should fail"); - assert_eq!(err.subcode, Some("unsupported-body-content-type")); - } - - // ── validate_path_template (Issue 1b — parameter name validation) ───────── - - #[test] - fn validate_path_template_accepts_well_formed_paths() { - let mut ctx = test_ctx(); - for path in [ - "/pets", - "/pets/{id}", - "/users/{userId}/pets/{petId}", - "/_internal/{$ref}", - ] { - validate_path_template(path, &mut ctx.reporter()) - .unwrap_or_else(|err| panic!("path {path} should validate, got: {err:?}")); - } - } - - #[test] - fn validate_path_template_rejects_invalid_identifier_parameter_name() { - let mut ctx = test_ctx(); - let err = validate_path_template("/pets/{it's}", &mut ctx.reporter()) - .expect_err("invalid identifier must reject"); - assert_eq!(err.subcode, Some("invalid-path-parameter-name")); - } - - #[test] - fn validate_path_template_rejects_digits_first_parameter_name() { - let mut ctx = test_ctx(); - let err = validate_path_template("/pets/{1foo}", &mut ctx.reporter()) - .expect_err("digits-first must reject"); - assert_eq!(err.subcode, Some("invalid-path-parameter-name")); - } - - #[test] - fn validate_path_template_rejects_kebab_case_parameter_name() { - let mut ctx = test_ctx(); - let err = validate_path_template("/pets/{pet-id}", &mut ctx.reporter()) - .expect_err("kebab-case must reject"); - assert_eq!(err.subcode, Some("invalid-path-parameter-name")); - } - - #[test] - fn validate_path_template_still_rejects_unbalanced_braces() { - let mut ctx = test_ctx(); - let err = validate_path_template("/pets/{id", &mut ctx.reporter()) - .expect_err("unbalanced { must reject"); - // unsupported() uses code, not subcode; just confirm it's an error. - assert_eq!(err.code, crate::error::DiagnosticCode::UnsupportedSemantic); - } - - #[test] - fn validate_path_template_still_rejects_stray_close_brace() { - let mut ctx = test_ctx(); - let err = - validate_path_template("/pets/id}", &mut ctx.reporter()).expect_err("stray } must reject"); - assert_eq!(err.code, crate::error::DiagnosticCode::UnsupportedSemantic); - } - - // ── Form-body field name validation (Issue 1a) ──────────────────────────── - - #[test] - fn rejects_multipart_with_invalid_field_name_kebab_case() { - let yaml = r#" -content: - multipart/form-data: - schema: - type: object - properties: - x-y: { type: string } -"#; - let body = parse_request_body(yaml); - let mut ctx = test_ctx(); - let err = normalize_request_body( - Some(&body), - "POST", - "/x", - &empty_schema_index(), - &mut ctx.reporter(), - ) - .expect_err("kebab-case field name must reject"); - assert_eq!(err.subcode, Some("invalid-form-field-name")); - } - - #[test] - fn rejects_urlencoded_with_invalid_field_name_digits_first() { - let yaml = r#" -content: - application/x-www-form-urlencoded: - schema: - type: object - properties: - "1foo": { type: string } -"#; - let body = parse_request_body(yaml); - let mut ctx = test_ctx(); - let err = normalize_request_body( - Some(&body), - "POST", - "/x", - &empty_schema_index(), - &mut ctx.reporter(), - ) - .expect_err("digits-first field name must reject"); - assert_eq!(err.subcode, Some("invalid-form-field-name")); - } -} diff --git a/src/ir/normalize/operations/body.rs b/src/ir/normalize/operations/body.rs new file mode 100644 index 0000000..35e72dc --- /dev/null +++ b/src/ir/normalize/operations/body.rs @@ -0,0 +1,143 @@ +//! Request-body lowering: content-type dispatch onto JSON, multipart or +//! urlencoded. + +use crate::error::{Context, Diagnostic, bail_policy}; +use crate::ir::canonical::{BodyContent, RequestBodyDef}; +use crate::ir::schema::SchemaType; +use crate::parse::openapi_model::RequestBody; + +use super::super::schema::normalize_schema; +use super::super::{SchemaWalk, bail_unsupported, unsupported}; +use super::OperationCx; +use super::form::{FormBody, FormKind, normalize_form_body_fields}; + +pub(super) fn normalize_request_body( + request_body: Option<&RequestBody>, + cx: OperationCx<'_>, +) -> Result, Diagnostic> { + let (method, path, reporter) = (cx.method(), cx.path(), cx.reporter()); + let Some(body) = request_body else { + return Ok(None); + }; + + if body.content.len() > 1 { + bail_policy!( + reporter, + "multi-content-body", + "requestBody for {method} {path} must declare exactly one content type." + ); + } + + let Some((mime, media)) = body.content.iter().next() else { + return Ok(None); + }; + // OpenAPI permits MIME case variation (`Application/JSON`). + let mime_lc = mime.to_ascii_lowercase(); + + let content = match mime_lc.as_str() { + "application/json" => { + let schema = media.schema.as_ref().ok_or_else(|| { + unsupported( + reporter, + format!("requestBody for {method} {path} must define schema."), + ) + })?; + + let walk = SchemaWalk::root(Context::RequestBody { method, path }, reporter); + let ty = normalize_schema(schema, walk)?; + + if matches!(ty, SchemaType::Any) { + bail_unsupported!( + reporter, + "requestBody for {method} {path} must define a concrete schema." + ); + } + + BodyContent::Json(ty) + } + "multipart/form-data" => { + let (body_ref, fields) = normalize_form_body_fields( + media, + FormBody::new(FormKind::Multipart, method, path, reporter), + cx.schemas(), + )?; + BodyContent::Multipart { body_ref, fields } + } + "application/x-www-form-urlencoded" => { + let (body_ref, fields) = normalize_form_body_fields( + media, + FormBody::new(FormKind::UrlEncoded, method, path, reporter), + cx.schemas(), + )?; + BodyContent::UrlEncoded { body_ref, fields } + } + other => { + bail_policy!( + reporter, + "unsupported-body-content-type", + "requestBody for {method} {path}: unsupported content type {other:?}. Use application/json, multipart/form-data, or application/x-www-form-urlencoded." + ); + } + }; + + Ok(Some(RequestBodyDef { + required: body.required, + content, + })) +} + +#[cfg(test)] +mod tests { + use super::super::OperationCx; + + fn test_cx<'a>( + schemas: &'a BTreeMap<&'a str, &'a SchemaType>, + reporter: &'a crate::error::Reporter, + ) -> OperationCx<'a> { + OperationCx::new("POST", "/x", schemas, &[], reporter) + } + use std::collections::BTreeMap; + + use super::normalize_request_body; + use crate::ir::schema::SchemaType; + use crate::parse::openapi_model::RequestBody; + use crate::test_support::test_reporter; + + fn parse_request_body(yaml: &str) -> RequestBody { + serde_yml::from_str(yaml).expect("fixture parses as RequestBody") + } + + fn empty_schema_index<'a>() -> BTreeMap<&'a str, &'a SchemaType> { + BTreeMap::new() + } + + #[test] + fn rejects_body_with_multiple_content_types() { + let yaml = r#" +content: + application/json: + schema: { type: object, properties: { x: { type: string } } } + multipart/form-data: + schema: { type: object, properties: { x: { type: string } } } +"#; + let body = parse_request_body(yaml); + let ctx = test_reporter(); + let err = normalize_request_body(Some(&body), test_cx(&empty_schema_index(), &ctx)) + .expect_err("multi-content should fail"); + assert_eq!(err.subcode, Some("multi-content-body")); + } + + #[test] + fn rejects_unsupported_body_content_type() { + let yaml = r#" +content: + application/xml: + schema: { type: object, properties: { x: { type: string } } } +"#; + let body = parse_request_body(yaml); + let ctx = test_reporter(); + let err = normalize_request_body(Some(&body), test_cx(&empty_schema_index(), &ctx)) + .expect_err("xml body should fail"); + assert_eq!(err.subcode, Some("unsupported-body-content-type")); + } +} diff --git a/src/ir/normalize/operations/form.rs b/src/ir/normalize/operations/form.rs new file mode 100644 index 0000000..ef3b908 --- /dev/null +++ b/src/ir/normalize/operations/form.rs @@ -0,0 +1,649 @@ +//! `multipart/form-data` and `application/x-www-form-urlencoded` body +//! lowering: a flat, statically enumerated field list. + +use std::collections::BTreeMap; + +use crate::error::{Context, Diagnostic, Reporter, bail_policy}; +use crate::ident::Ident; +use crate::ir::canonical::{BodyField, BodyFieldType}; +use crate::ir::schema::{SchemaScalar, SchemaType}; +use crate::parse::openapi_model::{AdditionalProperties, MediaType, Schema}; + +use super::super::schema::normalize_schema; +use super::super::{SchemaWalk, unsupported}; + +/// The form flavour, the operation position and the diagnostic sink every +/// rejection message needs. +#[derive(Clone, Copy)] +pub(super) struct FormBody<'a> { + kind: FormKind, + method: &'a str, + path: &'a str, + reporter: &'a Reporter, +} + +impl<'a> FormBody<'a> { + pub(super) const fn new( + kind: FormKind, + method: &'a str, + path: &'a str, + reporter: &'a Reporter, + ) -> Self { + Self { + kind, + method, + path, + reporter, + } + } +} + +/// Which form flavour a body declares. +#[derive(Clone, Copy)] +pub(super) enum FormKind { + Multipart, + UrlEncoded, +} + +/// Why a form body or one of its fields was rejected. +/// +/// Paired with a [`FormKind`] it names the stable subcode a consumer can +/// route on without parsing the message. +#[derive(Clone, Copy)] +pub(super) enum Reject { + /// The declared body schema does not resolve to an object. + NonObjectBody, + /// The body declares `additionalProperties`, so its fields are open-ended. + OpenSchema, + /// A field's type is an object. + NestedObject, + /// A field's type is a composition, a map, a nullable, or an array of + /// something other than a scalar or binary. + ComposedField, +} + +impl FormKind { + /// Label used in diagnostic prose. + const fn label(self) -> &'static str { + match self { + Self::Multipart => "multipart", + Self::UrlEncoded => "urlencoded", + } + } + + /// Stable kebab-case subcode for `reject` under this flavour. + const fn subcode(self, reject: Reject) -> &'static str { + match (self, reject) { + (Self::Multipart, Reject::NonObjectBody) => "multipart-non-object-body", + (Self::Multipart, Reject::OpenSchema) => "multipart-open-schema", + (Self::Multipart, Reject::NestedObject) => "multipart-nested-object", + (Self::Multipart, Reject::ComposedField) => "multipart-composed-field", + (Self::UrlEncoded, Reject::NonObjectBody) => "urlencoded-non-object-body", + (Self::UrlEncoded, Reject::OpenSchema) => "urlencoded-open-schema", + (Self::UrlEncoded, Reject::NestedObject) => "urlencoded-nested-object", + (Self::UrlEncoded, Reject::ComposedField) => "urlencoded-composed-field", + } + } +} + +/// Subcode for a binary field in a urlencoded body, which the format has +/// no encoding for. Covers the scalar and the array case alike. +const URLENCODED_BINARY_FIELD: &str = "urlencoded-binary-field"; + +/// Flattens a form body's schema into an alphabetically sorted field list. +/// +/// The returned name is `Some` when the body was declared as a top-level +/// `$ref`, resolved through `schema_index`. `format: binary` is detected +/// only for an inline body: a `$ref` target's raw schema does not reach +/// this layer. +pub(super) fn normalize_form_body_fields( + media: &MediaType, + body: FormBody<'_>, + schema_index: &BTreeMap<&str, &SchemaType>, +) -> Result<(Option>, Vec), Diagnostic> { + let FormBody { + kind, + method, + path, + reporter, + } = body; + let raw_schema = media.schema.as_ref().ok_or_else(|| { + Diagnostic::policy_violation( + reporter, + "missing-body-schema", + format!("requestBody for {method} {path} must define schema."), + ) + })?; + + // `additionalProperties: false` and the absent case leave the field set + // closed; every other form of it leaves the body open-ended. + if let Some(ap) = &raw_schema.additional_properties + && !matches!(ap, AdditionalProperties::Boolean(false)) + { + bail_policy!( + reporter, + kind.subcode(Reject::OpenSchema), + "requestBody for {method} {path}: {} bodies must not declare additionalProperties; every field must be enumerated.", + kind.label(), + ); + } + + let walk = SchemaWalk::root(Context::RequestBody { method, path }, reporter); + let normalized = normalize_schema(raw_schema, walk)?; + + let (body_ref, resolved_ty): (Option>, &SchemaType) = match &normalized { + SchemaType::Ref(name) => { + let resolved = schema_index.get(name.as_ref()).ok_or_else(|| { + unsupported( + reporter, + format!("requestBody for {method} {path} references unknown schema '{name}'.",), + ) + })?; + (Some(name.clone()), *resolved) + } + other => (None, other), + }; + + let SchemaType::InlineObject { properties } = resolved_ty else { + bail_policy!( + reporter, + kind.subcode(Reject::NonObjectBody), + "requestBody for {method} {path}: {} body schema must resolve to an object.", + kind.label(), + ); + }; + + let raw_property_lookup = collect_raw_property_formats(raw_schema); + + let mut fields: Vec = Vec::with_capacity(properties.len()); + for prop in properties.iter() { + let Some(name) = Ident::parse(prop.name.as_ref()) else { + bail_policy!( + reporter, + "invalid-form-field-name", + "body field '{name}' in {method} {path}: name is not a valid JavaScript identifier. Rename the field or split this body into a non-generated client.", + name = prop.name.as_ref(), + ); + }; + let raw_format = raw_property_lookup + .get(prop.name.as_ref()) + .copied() + .unwrap_or(RawPropertyFormat::default()); + let ty = classify_body_field_type(&prop.ty, raw_format, prop.name.as_ref(), body)?; + fields.push(BodyField { + name, + required: prop.required, + ty, + }); + } + + fields.sort_by(|a, b| a.name.cmp(&b.name)); + Ok((body_ref, fields)) +} + +/// One body property's raw `format` hints: `own` from the property +/// schema, `items` from its array-item schema. +#[derive(Clone, Copy, Default)] +struct RawPropertyFormat<'a> { + own: Option<&'a str>, + items: Option<&'a str>, +} + +/// Collects the per-property `format` hints, which `SchemaType` does not +/// carry. Empty when the body is a top-level `$ref`. +fn collect_raw_property_formats(raw_schema: &Schema) -> BTreeMap<&str, RawPropertyFormat<'_>> { + let mut lookup = BTreeMap::new(); + let Some(properties) = &raw_schema.properties else { + return lookup; + }; + for (name, schema) in properties.iter() { + let own = schema.format.as_deref(); + let items = schema + .items + .as_deref() + .and_then(|item_schema| item_schema.format.as_deref()); + lookup.insert(name.as_str(), RawPropertyFormat { own, items }); + } + lookup +} + +/// Classifies one form-body property. +/// +/// Accepts a scalar, an array of scalars, a binary, and an array of +/// binaries; every other shape fails with the matching [`Reject`]. +fn classify_body_field_type( + ty: &SchemaType, + raw_format: RawPropertyFormat<'_>, + field_name: &str, + body: FormBody<'_>, +) -> Result { + let FormBody { + kind, + method, + path, + reporter, + } = body; + match ty { + SchemaType::Scalar(SchemaScalar::String) if raw_format.own == Some("binary") => match kind { + FormKind::Multipart => Ok(BodyFieldType::Binary), + FormKind::UrlEncoded => Err(Diagnostic::policy_violation( + reporter, + URLENCODED_BINARY_FIELD, + format!( + "body field '{field_name}' in {method} {path}: binary fields are not supported in application/x-www-form-urlencoded." + ), + )), + }, + // An array of binary is detected through the item's `format` hint. + SchemaType::Array(inner) + if matches!(inner.as_ref(), SchemaType::Scalar(SchemaScalar::String)) + && raw_format.items == Some("binary") => + { + match kind { + FormKind::Multipart => Ok(BodyFieldType::ArrayOfBinary), + FormKind::UrlEncoded => Err(Diagnostic::policy_violation( + reporter, + URLENCODED_BINARY_FIELD, + format!( + "body field '{field_name}' in {method} {path}: array-of-binary fields are not supported in application/x-www-form-urlencoded." + ), + )), + } + } + SchemaType::Scalar(scalar) => Ok(BodyFieldType::Scalar(scalar.clone())), + SchemaType::Array(inner) => match inner.as_ref() { + SchemaType::Scalar(scalar) => Ok(BodyFieldType::ArrayOfScalar(scalar.clone())), + + _ => Err(Diagnostic::policy_violation( + reporter, + kind.subcode(Reject::ComposedField), + format!( + "body field '{field_name}' in {method} {path}: array items must be scalar or binary." + ), + )), + }, + SchemaType::InlineObject { .. } | SchemaType::Ref(_) => Err(Diagnostic::policy_violation( + reporter, + kind.subcode(Reject::NestedObject), + format!( + "body field '{field_name}' in {method} {path}: nested objects are not supported in {} bodies.", + kind.label(), + ), + )), + // Composition, nullable, map, non-string enum and `Any` all report + // as composed. + _ => Err(Diagnostic::policy_violation( + reporter, + kind.subcode(Reject::ComposedField), + format!( + "body field '{field_name}' in {method} {path}: composed schemas are not supported in {} bodies.", + kind.label(), + ), + )), + } +} + +#[cfg(test)] +mod tests { + use super::super::OperationCx; + + fn test_cx<'a>( + schemas: &'a BTreeMap<&'a str, &'a SchemaType>, + reporter: &'a crate::error::Reporter, + ) -> OperationCx<'a> { + OperationCx::new("POST", "/x", schemas, &[], reporter) + } + use super::URLENCODED_BINARY_FIELD; + use std::collections::BTreeMap; + + use crate::ir::canonical::{BodyContent, BodyFieldType}; + use crate::ir::schema::{SchemaProperty, SchemaScalar, SchemaType}; + use crate::parse::openapi_model::RequestBody; + use crate::test_support::test_reporter; + + use super::super::body::normalize_request_body; + + fn parse_request_body(yaml: &str) -> RequestBody { + serde_yml::from_str(yaml).expect("fixture parses as RequestBody") + } + + fn empty_schema_index<'a>() -> BTreeMap<&'a str, &'a SchemaType> { + BTreeMap::new() + } + + #[test] + fn accepts_multipart_with_scalar_array_and_binary_fields() { + let yaml = r#" +content: + multipart/form-data: + schema: + type: object + required: [status, avatar] + properties: + status: { type: string } + tagIds: { type: array, items: { type: number } } + avatar: { type: string, format: binary } + nickname: { type: string } +"#; + let body = parse_request_body(yaml); + let ctx = test_reporter(); + let result = normalize_request_body(Some(&body), test_cx(&empty_schema_index(), &ctx)) + .expect("normalize ok") + .expect("body present"); + + match result.content { + BodyContent::Multipart { body_ref, fields } => { + assert_eq!(body_ref, None); + let names: Vec<&str> = fields.iter().map(|f| f.name.as_str()).collect(); + // Sorted alphabetically. + assert_eq!(names, vec!["avatar", "nickname", "status", "tagIds"]); + let avatar = fields.iter().find(|f| f.name.as_str() == "avatar").unwrap(); + assert_eq!(avatar.ty, BodyFieldType::Binary); + assert!(avatar.required); + let status = fields.iter().find(|f| f.name.as_str() == "status").unwrap(); + assert!(matches!( + status.ty, + BodyFieldType::Scalar(SchemaScalar::String) + )); + assert!(status.required); + let nickname = fields + .iter() + .find(|f| f.name.as_str() == "nickname") + .unwrap(); + assert!(!nickname.required); + let tag_ids = fields.iter().find(|f| f.name.as_str() == "tagIds").unwrap(); + assert!(matches!( + tag_ids.ty, + BodyFieldType::ArrayOfScalar(SchemaScalar::Number) + )); + } + other => panic!("expected Multipart, got {other:?}"), + } + } + + #[test] + fn accepts_multipart_with_array_of_binary_fields() { + let yaml = r#" +content: + multipart/form-data: + schema: + type: object + required: [galleries] + properties: + galleries: { type: array, items: { type: string, format: binary } } +"#; + let body = parse_request_body(yaml); + let ctx = test_reporter(); + let result = normalize_request_body(Some(&body), test_cx(&empty_schema_index(), &ctx)) + .expect("normalize ok") + .expect("body present"); + + match result.content { + BodyContent::Multipart { fields, .. } => { + assert_eq!(fields.len(), 1); + assert_eq!(fields[0].ty, BodyFieldType::ArrayOfBinary); + } + other => panic!("expected Multipart, got {other:?}"), + } + } + + #[test] + fn accepts_multipart_with_ref_to_named_object() { + let yaml = r#" +content: + multipart/form-data: + schema: + $ref: '#/components/schemas/UploadForm' +"#; + let body = parse_request_body(yaml); + let upload_form_body = SchemaType::InlineObject { + properties: vec![SchemaProperty { + name: "status".into(), + required: true, + ty: SchemaType::Scalar(SchemaScalar::String), + description: None, + deprecated: false, + }], + }; + let schema_index = BTreeMap::from([("UploadForm", &upload_form_body)]); + let ctx = test_reporter(); + let result = normalize_request_body(Some(&body), test_cx(&schema_index, &ctx)) + .expect("normalize ok") + .expect("body present"); + + match result.content { + BodyContent::Multipart { body_ref, fields } => { + assert_eq!(body_ref.as_deref(), Some("UploadForm")); + assert_eq!(fields.len(), 1); + assert_eq!(fields[0].name.as_str(), "status"); + } + other => panic!("expected Multipart, got {other:?}"), + } + } + + #[test] + fn rejects_multipart_with_nested_object_field() { + let yaml = r#" +content: + multipart/form-data: + schema: + type: object + properties: + metadata: + type: object + properties: + authorId: { type: string } +"#; + let body = parse_request_body(yaml); + let ctx = test_reporter(); + let err = normalize_request_body(Some(&body), test_cx(&empty_schema_index(), &ctx)) + .expect_err("nested object should fail"); + assert_eq!(err.subcode, Some("multipart-nested-object")); + } + + #[test] + fn rejects_multipart_with_composed_field() { + let yaml = r#" +content: + multipart/form-data: + schema: + type: object + properties: + variant: + oneOf: + - { type: string } + - { type: number } +"#; + let body = parse_request_body(yaml); + let ctx = test_reporter(); + let err = normalize_request_body(Some(&body), test_cx(&empty_schema_index(), &ctx)) + .expect_err("composed field should fail"); + assert_eq!(err.subcode, Some("multipart-composed-field")); + } + + #[test] + fn rejects_multipart_with_additional_properties_true() { + let yaml = r#" +content: + multipart/form-data: + schema: + type: object + additionalProperties: true + properties: + status: { type: string } +"#; + let body = parse_request_body(yaml); + let ctx = test_reporter(); + let err = normalize_request_body(Some(&body), test_cx(&empty_schema_index(), &ctx)) + .expect_err("open schema should fail"); + assert_eq!(err.subcode, Some("multipart-open-schema")); + } + + #[test] + fn rejects_multipart_with_non_object_top_level_schema() { + let yaml = r#" +content: + multipart/form-data: + schema: + type: string +"#; + let body = parse_request_body(yaml); + let ctx = test_reporter(); + let err = normalize_request_body(Some(&body), test_cx(&empty_schema_index(), &ctx)) + .expect_err("non-object body should fail"); + assert_eq!(err.subcode, Some("multipart-non-object-body")); + } + + #[test] + fn accepts_urlencoded_with_scalar_and_array_of_scalar_fields() { + let yaml = r#" +content: + application/x-www-form-urlencoded: + schema: + type: object + required: [status] + properties: + status: { type: string } + tagIds: { type: array, items: { type: number } } +"#; + let body = parse_request_body(yaml); + let ctx = test_reporter(); + let result = normalize_request_body(Some(&body), test_cx(&empty_schema_index(), &ctx)) + .expect("normalize ok") + .expect("body present"); + + match result.content { + BodyContent::UrlEncoded { fields, .. } => { + assert_eq!( + fields.iter().map(|f| f.name.as_str()).collect::>(), + vec!["status", "tagIds"] + ); + } + other => panic!("expected UrlEncoded, got {other:?}"), + } + } + + #[test] + fn rejects_urlencoded_with_binary_field() { + let yaml = r#" +content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + avatar: { type: string, format: binary } +"#; + let body = parse_request_body(yaml); + let ctx = test_reporter(); + let err = normalize_request_body(Some(&body), test_cx(&empty_schema_index(), &ctx)) + .expect_err("binary in urlencoded should fail"); + assert_eq!(err.subcode, Some(URLENCODED_BINARY_FIELD)); + } + + #[test] + fn rejects_urlencoded_with_nested_object_field() { + let yaml = r#" +content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + metadata: + type: object + properties: + authorId: { type: string } +"#; + let body = parse_request_body(yaml); + let ctx = test_reporter(); + let err = normalize_request_body(Some(&body), test_cx(&empty_schema_index(), &ctx)) + .expect_err("nested object should fail"); + assert_eq!(err.subcode, Some("urlencoded-nested-object")); + } + + #[test] + fn rejects_urlencoded_with_composed_field() { + let yaml = r#" +content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + variant: + oneOf: + - { type: string } + - { type: number } +"#; + let body = parse_request_body(yaml); + let ctx = test_reporter(); + let err = normalize_request_body(Some(&body), test_cx(&empty_schema_index(), &ctx)) + .expect_err("composed field should fail"); + assert_eq!(err.subcode, Some("urlencoded-composed-field")); + } + + #[test] + fn rejects_urlencoded_with_non_object_top_level_schema() { + let yaml = r#" +content: + application/x-www-form-urlencoded: + schema: + type: string +"#; + let body = parse_request_body(yaml); + let ctx = test_reporter(); + let err = normalize_request_body(Some(&body), test_cx(&empty_schema_index(), &ctx)) + .expect_err("non-object urlencoded body should fail"); + assert_eq!(err.subcode, Some("urlencoded-non-object-body")); + } + + #[test] + fn rejects_urlencoded_with_additional_properties_true() { + let yaml = r#" +content: + application/x-www-form-urlencoded: + schema: + type: object + additionalProperties: true + properties: + status: { type: string } +"#; + let body = parse_request_body(yaml); + let ctx = test_reporter(); + let err = normalize_request_body(Some(&body), test_cx(&empty_schema_index(), &ctx)) + .expect_err("open urlencoded schema should fail"); + assert_eq!(err.subcode, Some("urlencoded-open-schema")); + } + + #[test] + fn rejects_multipart_with_invalid_field_name_kebab_case() { + let yaml = r#" +content: + multipart/form-data: + schema: + type: object + properties: + x-y: { type: string } +"#; + let body = parse_request_body(yaml); + let ctx = test_reporter(); + let err = normalize_request_body(Some(&body), test_cx(&empty_schema_index(), &ctx)) + .expect_err("kebab-case field name must reject"); + assert_eq!(err.subcode, Some("invalid-form-field-name")); + } + + #[test] + fn rejects_urlencoded_with_invalid_field_name_digits_first() { + let yaml = r#" +content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + "1foo": { type: string } +"#; + let body = parse_request_body(yaml); + let ctx = test_reporter(); + let err = normalize_request_body(Some(&body), test_cx(&empty_schema_index(), &ctx)) + .expect_err("digits-first field name must reject"); + assert_eq!(err.subcode, Some("invalid-form-field-name")); + } +} diff --git a/src/ir/normalize/operations/mod.rs b/src/ir/normalize/operations/mod.rs new file mode 100644 index 0000000..8ca1bf9 --- /dev/null +++ b/src/ir/normalize/operations/mod.rs @@ -0,0 +1,162 @@ +//! OpenAPI paths → canonical `OperationDef`s. +//! +//! Each submodule owns one slot of an operation: the path template, the +//! parameters, the request body, and the responses. + +mod body; +mod form; +mod parameters; +mod path_template; +mod responses; + +use std::collections::BTreeMap; + +use crate::error::{Diagnostic, Reporter}; +use crate::ir::canonical::{ + HttpMethod, OperationDef, RequestDef, RequestInputDef, RequestInputSource, +}; +use crate::ir::schema::SchemaType; +use crate::options::ResponseTypeMapping; +use crate::parse::openapi_model::{Operation, PathItem}; + +use super::unsupported; +use body::normalize_request_body; +use parameters::normalize_request_inputs; +use path_template::validate_path_template; +use responses::{normalize_error_responses, normalize_success_response}; + +/// Everything an operation's lowering needs besides the operation itself: +/// where it sits, the schemas its `$ref`s may resolve to, the caller's +/// response-kind overrides, and the diagnostic sink. +/// +/// `method` is the canonical upper-case name. +#[derive(Clone, Copy)] +pub(super) struct OperationCx<'a> { + method: &'a str, + path: &'a str, + schemas: &'a BTreeMap<&'a str, &'a SchemaType>, + response_types: &'a [ResponseTypeMapping], + reporter: &'a Reporter, +} + +impl<'a> OperationCx<'a> { + pub(super) const fn new( + method: &'a str, + path: &'a str, + schemas: &'a BTreeMap<&'a str, &'a SchemaType>, + response_types: &'a [ResponseTypeMapping], + reporter: &'a Reporter, + ) -> Self { + Self { + method, + path, + schemas, + response_types, + reporter, + } + } + + pub(super) const fn method(&self) -> &'a str { + self.method + } + + pub(super) const fn path(&self) -> &'a str { + self.path + } + + pub(super) const fn schemas(&self) -> &'a BTreeMap<&'a str, &'a SchemaType> { + self.schemas + } + + pub(super) const fn response_types(&self) -> &'a [ResponseTypeMapping] { + self.response_types + } + + pub(super) const fn reporter(&self) -> &'a Reporter { + self.reporter + } +} + +pub(super) fn normalize_operations( + paths: &BTreeMap, + schemas: &BTreeMap<&str, &SchemaType>, + response_types: &[ResponseTypeMapping], + reporter: &Reporter, +) -> Result, Diagnostic> { + paths + .iter() + .map(|(path, path_item)| { + validate_path_template(path, reporter)?; + path_item + .operations() + .map(|(method, operation)| { + normalize_operation(path, method, operation, schemas, response_types, reporter) + }) + .collect::, Diagnostic>>() + }) + .collect::, Diagnostic>>() + .map(|per_path| per_path.into_iter().flatten().collect()) +} + +fn normalize_operation( + path: &str, + declared_method: &str, + operation: &Operation, + schemas: &BTreeMap<&str, &SchemaType>, + response_types: &[ResponseTypeMapping], + reporter: &Reporter, +) -> Result { + let method = HttpMethod::from_lowercase(declared_method) + .ok_or_else(|| unsupported(reporter, unsupported_method_detail(declared_method, path)))?; + + let operation_id = operation + .operation_id + .clone() + .unwrap_or_else(|| format!("{declared_method}_{}", path.replace(['/', '{', '}'], "_"))); + + let cx = OperationCx::new(method.as_str(), path, schemas, response_types, reporter); + + Ok(OperationDef { + request: normalize_request(operation, &operation_id, cx)?, + response: normalize_success_response(operation.responses.as_deref(), cx)?, + errors: normalize_error_responses(operation.responses.as_deref(), cx)?, + operation_id, + tags: operation.tags.clone(), + method, + path: path.to_string(), + description: operation.merged_description(), + deprecated: operation.deprecated, + }) +} + +fn unsupported_method_detail(declared_method: &str, path: &str) -> String { + if declared_method == "trace" { + format!( + "HTTP method TRACE for {path} is not supported; remove the trace operation or split it into a non-generated client." + ) + } else { + format!("unknown HTTP method {declared_method} for {path}.") + } +} + +fn normalize_request( + operation: &Operation, + operation_id: &str, + cx: OperationCx<'_>, +) -> Result { + let (inputs, headers) = normalize_request_inputs(&operation.parameters, operation_id, cx)?; + Ok(RequestDef { + inputs, + headers, + body: normalize_request_body(operation.request_body.as_ref(), cx)?, + }) +} + +pub(super) fn request_input_sort_key(value: &RequestInputDef) -> (u8, &str) { + let weight = match value.source { + RequestInputSource::Path => 0, + RequestInputSource::Query => 1, + }; + + (weight, &value.name) +} diff --git a/src/ir/normalize/operations/parameters.rs b/src/ir/normalize/operations/parameters.rs new file mode 100644 index 0000000..7788412 --- /dev/null +++ b/src/ir/normalize/operations/parameters.rs @@ -0,0 +1,117 @@ +//! `in: path` / `in: query` / `in: header` parameter lowering. + +use crate::error::{Diagnostic, DiagnosticCode}; +use crate::ir::canonical::{HeaderDef, RequestInputDef, RequestInputSource}; +use crate::ir::schema::SchemaType; + +use super::super::schema::normalize_schema; +use super::super::{SchemaWalk, bail_unsupported, unsupported}; +use crate::error::Context; + +use super::{OperationCx, request_input_sort_key}; + +/// Which slot of the request contract a parameter lands in. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Destination { + Input(RequestInputSource), + Header, +} + +/// Lowers an operation's parameters into its path/query inputs and its +/// header list, each sorted by name. +/// +/// A `cookie` parameter is dropped with a warning; any other unsupported +/// location fails. +pub(super) fn normalize_request_inputs( + parameters: &[crate::parse::openapi_model::Parameter], + operation_id: &str, + cx: OperationCx<'_>, +) -> Result<(Vec, Vec), Diagnostic> { + let (method, path, reporter) = (cx.method(), cx.path(), cx.reporter()); + let mut inputs = Vec::with_capacity(parameters.len()); + let mut headers = Vec::new(); + + for parameter in parameters { + let name = ¶meter.name; + let destination = match parameter.location.as_str() { + "path" => Destination::Input(RequestInputSource::Path), + "query" => Destination::Input(RequestInputSource::Query), + "header" => Destination::Header, + "cookie" => { + reporter.warning( + DiagnosticCode::UnsupportedSemantic, + Some("unsupported-parameter-location"), + format!( + "operationId '{operation_id}': parameter '{name}' uses location 'cookie', which is not supported in the generated service contract and will be omitted.", + ), + ); + continue; + } + other => { + bail_unsupported!( + reporter, + "parameter {name} for {method} {path} uses unsupported location {other}." + ); + } + }; + + let required = parameter.required; + + if destination == Destination::Input(RequestInputSource::Path) && !required { + bail_unsupported!( + reporter, + "path parameter {name} for {method} {path} must be required." + ); + } + + if parameter.content.is_some() { + bail_unsupported!( + reporter, + "parameter {name} for {method} {path} must use schema, not content." + ); + } + + let schema = parameter.schema.as_ref().ok_or_else(|| { + unsupported( + reporter, + format!("parameter {name} for {method} {path} must define schema."), + ) + })?; + + let walk = SchemaWalk::root(Context::Parameter { method, path }, reporter); + let ty = normalize_schema(schema, walk)?; + match ty { + SchemaType::InlineObject { .. } => { + bail_unsupported!( + reporter, + "parameter {name} for {method} {path} uses an inline object schema, which is outside the supported subset." + ); + } + SchemaType::Any => { + bail_unsupported!( + reporter, + "parameter {name} for {method} {path} uses an empty schema, which is outside the supported subset." + ); + } + _ => {} + } + + match destination { + Destination::Input(source) => inputs.push(RequestInputDef { + name: name.as_str().into(), + source, + required, + ty, + }), + Destination::Header => headers.push(HeaderDef { + name: name.as_str().into(), + required, + ty, + }), + } + } + + inputs.sort_by(|left, right| request_input_sort_key(left).cmp(&request_input_sort_key(right))); + headers.sort_by(|left, right| left.name.cmp(&right.name)); + Ok((inputs, headers)) +} diff --git a/src/ir/normalize/operations/path_template.rs b/src/ir/normalize/operations/path_template.rs new file mode 100644 index 0000000..0ab33df --- /dev/null +++ b/src/ir/normalize/operations/path_template.rs @@ -0,0 +1,104 @@ +//! Path-template validation. + +use crate::error::{Diagnostic, Reporter, bail_policy}; +use crate::ident::is_ident; + +use super::super::bail_unsupported; + +/// Fails when `path`'s braces are unbalanced or nested, or when a +/// placeholder wraps a name that is not a bare identifier. +pub(super) fn validate_path_template(path: &str, reporter: &Reporter) -> Result<(), Diagnostic> { + let mut rest = path; + while let Some(open) = rest.find('{') { + let after_open = &rest[open + 1..]; + if let Some(stray) = after_open.find('{') { + let close = after_open.find('}'); + if close.is_none_or(|c| stray < c) { + bail_unsupported!( + reporter, + "path template {path} contains nested '{{' which is not a valid OpenAPI parameter placeholder." + ); + } + } + let Some(close) = after_open.find('}') else { + bail_unsupported!( + reporter, + "path template {path} has an unbalanced '{{' with no matching '}}'." + ); + }; + let name = &after_open[..close]; + if !is_ident(name) { + bail_policy!( + reporter, + "invalid-path-parameter-name", + "path template {path}: parameter name '{name}' is not a valid JavaScript identifier. Rename the parameter or split this path into a non-generated client." + ); + } + rest = &after_open[close + 1..]; + } + if let Some(stray) = rest.find('}') { + let _ = stray; + bail_unsupported!( + reporter, + "path template {path} has an unbalanced '}}' with no matching '{{'." + ); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::validate_path_template; + use crate::test_support::test_reporter; + + #[test] + fn validate_path_template_accepts_well_formed_paths() { + let ctx = test_reporter(); + for path in [ + "/pets", + "/pets/{id}", + "/users/{userId}/pets/{petId}", + "/_internal/{$ref}", + ] { + validate_path_template(path, &ctx) + .unwrap_or_else(|err| panic!("path {path} should validate, got: {err:?}")); + } + } + + #[test] + fn validate_path_template_rejects_invalid_identifier_parameter_name() { + let ctx = test_reporter(); + let err = + validate_path_template("/pets/{it's}", &ctx).expect_err("invalid identifier must reject"); + assert_eq!(err.subcode, Some("invalid-path-parameter-name")); + } + + #[test] + fn validate_path_template_rejects_digits_first_parameter_name() { + let ctx = test_reporter(); + let err = validate_path_template("/pets/{1foo}", &ctx).expect_err("digits-first must reject"); + assert_eq!(err.subcode, Some("invalid-path-parameter-name")); + } + + #[test] + fn validate_path_template_rejects_kebab_case_parameter_name() { + let ctx = test_reporter(); + let err = validate_path_template("/pets/{pet-id}", &ctx).expect_err("kebab-case must reject"); + assert_eq!(err.subcode, Some("invalid-path-parameter-name")); + } + + #[test] + fn validate_path_template_still_rejects_unbalanced_braces() { + let ctx = test_reporter(); + let err = validate_path_template("/pets/{id", &ctx).expect_err("unbalanced { must reject"); + // unsupported() uses code, not subcode; just confirm it's an error. + assert_eq!(err.code, crate::error::DiagnosticCode::UnsupportedSemantic); + } + + #[test] + fn validate_path_template_still_rejects_stray_close_brace() { + let ctx = test_reporter(); + let err = validate_path_template("/pets/id}", &ctx).expect_err("stray } must reject"); + assert_eq!(err.code, crate::error::DiagnosticCode::UnsupportedSemantic); + } +} diff --git a/src/ir/normalize/operations/responses.rs b/src/ir/normalize/operations/responses.rs new file mode 100644 index 0000000..5c675db --- /dev/null +++ b/src/ir/normalize/operations/responses.rs @@ -0,0 +1,425 @@ +//! Response lowering: the typed success body and the 4xx/5xx error map. + +use std::collections::BTreeMap; + +use crate::error::{Context, Diagnostic}; +use crate::ir::canonical::{ErrorResponse, ResponseContent}; +use crate::options::{ResponseType, ResponseTypeMapping}; +use crate::parse::openapi_model::{MediaType, Response}; + +use super::super::SchemaWalk; +use super::super::schema::normalize_schema; +use super::OperationCx; + +pub(super) fn normalize_success_response( + responses: Option<&BTreeMap>, + cx: OperationCx<'_>, +) -> Result, Diagnostic> { + let Some(responses) = responses else { + return Ok(None); + }; + + let Some((_status, response)) = responses + .iter() + .find(|(status, _)| is_success_status(status)) + else { + return Ok(None); + }; + + let Some(content) = &response.content else { + return Ok(None); + }; + + let Some((mime, media)) = pick_response_media(content, cx.response_types()) else { + return Ok(None); + }; + + let kind = classify_response_kind(mime, cx.response_types()); + let walk = SchemaWalk::root( + Context::ResponseSchema { + method: cx.method(), + path: cx.path(), + }, + cx.reporter(), + ); + + Ok(Some(match kind { + ResponseKind::Json => { + let schema = match &media.schema { + Some(schema) => Some(normalize_schema(schema, walk)?), + None => None, + }; + ResponseContent::Json(schema) + } + ResponseKind::Blob => ResponseContent::Blob, + ResponseKind::Text => ResponseContent::Text, + ResponseKind::ArrayBuffer => ResponseContent::ArrayBuffer, + })) +} + +/// Collects the 4xx and 5xx responses that declare a JSON schema, sorted +/// by status ascending. +/// +/// Skips a schemaless response, a non-JSON one, and the `default` key. +pub(super) fn normalize_error_responses( + responses: Option<&BTreeMap>, + cx: OperationCx<'_>, +) -> Result, Diagnostic> { + let Some(responses) = responses else { + return Ok(Vec::new()); + }; + + let walk = SchemaWalk::root( + Context::ResponseSchema { + method: cx.method(), + path: cx.path(), + }, + cx.reporter(), + ); + let mut errors = responses + .iter() + .filter_map(|(status, response)| { + let status = parse_error_status(status)?; + let schema = response + .content + .as_ref()? + .get("application/json")? + .schema + .as_ref()?; + Some((status, schema)) + }) + .map(|(status, schema)| { + Ok(ErrorResponse { + status, + body: normalize_schema(schema, walk)?, + }) + }) + .collect::, Diagnostic>>()?; + errors.sort_by_key(|error| error.status); + Ok(errors) +} + +/// Parses a response key as a 4xx or 5xx HTTP status code. Returns `None` +/// for 2xx, 1xx, 3xx, the `default` key, and malformed values. +fn parse_error_status(status: &str) -> Option { + if status.len() != 3 { + return None; + } + let leading = status.as_bytes()[0]; + if leading != b'4' && leading != b'5' { + return None; + } + status.parse::().ok() +} + +/// Picks the media entry carrying a response's typed body: the first that +/// does not classify as `Blob`, else the first `Blob`. +/// +/// Determinism comes from `BTreeMap` iterating alphabetically by key. +fn pick_response_media<'a>( + content: &'a BTreeMap, + user_mapping: &[ResponseTypeMapping], +) -> Option<(&'a str, &'a MediaType)> { + let mut first_blob: Option<(&str, &MediaType)> = None; + for (mime, media) in content { + let kind = classify_response_kind(mime, user_mapping); + if kind != ResponseKind::Blob { + return Some((mime.as_str(), media)); + } + if first_blob.is_none() { + first_blob = Some((mime.as_str(), media)); + } + } + first_blob +} + +fn is_success_status(status: &str) -> bool { + status.starts_with('2') +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ResponseKind { + Json, + Blob, + Text, + ArrayBuffer, +} + +fn classify_response_kind( + content_type: &str, + user_mapping: &[ResponseTypeMapping], +) -> ResponseKind { + let normalized = content_type.to_ascii_lowercase(); + + if let Some(m) = user_mapping + .iter() + .find(|m| m.content_type.eq_ignore_ascii_case(&normalized)) + { + return match m.response_type { + ResponseType::Json => ResponseKind::Json, + ResponseType::Blob => ResponseKind::Blob, + ResponseType::Text => ResponseKind::Text, + ResponseType::ArrayBuffer => ResponseKind::ArrayBuffer, + }; + } + + if normalized == "application/json" || normalized.ends_with("+json") { + return ResponseKind::Json; + } + if normalized.starts_with("text/") { + return ResponseKind::Text; + } + ResponseKind::Blob +} + +#[cfg(test)] +mod tests { + use super::super::OperationCx; + + fn test_cx<'a>( + response_types: &'a [ResponseTypeMapping], + reporter: &'a crate::error::Reporter, + ) -> OperationCx<'a> { + static EMPTY: std::sync::LazyLock> = + std::sync::LazyLock::new(BTreeMap::new); + OperationCx::new("GET", "/x", &EMPTY, response_types, reporter) + } + use std::collections::BTreeMap; + + use super::{ + ResponseKind, classify_response_kind, normalize_error_responses, normalize_success_response, + parse_error_status, pick_response_media, + }; + + use crate::options::{ResponseType, ResponseTypeMapping}; + use crate::parse::openapi_model::{MediaType, Response, Schema}; + + fn json_schema() -> Schema { + Schema::default_string() + } + + fn btreemap_with(key: K, value: V) -> BTreeMap { + BTreeMap::from([(key, value)]) + } + use crate::test_support::test_reporter; + + #[test] + fn classifies_application_json_as_json() { + assert_eq!( + classify_response_kind("application/json", &[]), + ResponseKind::Json + ); + } + + #[test] + fn classifies_problem_json_as_json() { + assert_eq!( + classify_response_kind("application/problem+json", &[]), + ResponseKind::Json + ); + assert_eq!( + classify_response_kind("application/vnd.api+json", &[]), + ResponseKind::Json + ); + } + + #[test] + fn classifies_text_plain_as_text() { + assert_eq!( + classify_response_kind("text/plain", &[]), + ResponseKind::Text + ); + assert_eq!(classify_response_kind("text/csv", &[]), ResponseKind::Text); + } + + #[test] + fn classifies_application_pdf_as_blob_via_default() { + assert_eq!( + classify_response_kind("application/pdf", &[]), + ResponseKind::Blob + ); + } + + #[test] + fn classifies_octet_stream_as_blob_via_default() { + assert_eq!( + classify_response_kind("application/octet-stream", &[]), + ResponseKind::Blob + ); + } + + #[test] + fn user_mapping_overrides_default() { + let mapping = vec![ResponseTypeMapping { + content_type: "application/octet-stream".into(), + response_type: ResponseType::ArrayBuffer, + }]; + assert_eq!( + classify_response_kind("application/octet-stream", &mapping), + ResponseKind::ArrayBuffer + ); + } + + #[test] + fn user_mapping_matches_case_insensitively() { + let mapping = vec![ResponseTypeMapping { + content_type: "application/PDF".into(), + response_type: ResponseType::ArrayBuffer, + }]; + assert_eq!( + classify_response_kind("application/pdf", &mapping), + ResponseKind::ArrayBuffer + ); + } + + #[test] + fn pick_response_media_prefers_non_blob_classification() { + let mut content = BTreeMap::::new(); + content.insert( + "application/json".into(), + MediaType { + schema: Some(json_schema()), + }, + ); + content.insert( + "application/octet-stream".into(), + MediaType { schema: None }, + ); + + let (mime, _) = pick_response_media(&content, &[]).expect("at least one media"); + assert_eq!(mime, "application/json"); + } + + #[test] + fn pick_response_media_returns_first_blob_when_only_blob_kinds() { + let mut content = BTreeMap::::new(); + content.insert("application/pdf".into(), MediaType { schema: None }); + content.insert("application/zip".into(), MediaType { schema: None }); + let (mime, _) = pick_response_media(&content, &[]).expect("at least one media"); + // BTreeMap iteration order is sorted; "application/pdf" sorts before "application/zip". + assert_eq!(mime, "application/pdf"); + } + + #[test] + fn no_response_content_yields_none_response() { + // A response with no `content` block at all. + let response = Response { content: None }; + let ctx = test_reporter(); + let result = normalize_success_response( + Some(&btreemap_with("200".to_string(), response)), + test_cx(&[], &ctx), + ) + .expect("normalize ok"); + assert!(result.is_none(), "missing response content => None"); + } + + /// Builds a Response with a single JSON content entry carrying the + /// given schema. Helper for the error-response tests below. + fn json_response(schema: Schema) -> Response { + Response { + content: Some( + BTreeMap::from([( + "application/json".to_string(), + MediaType { + schema: Some(schema), + }, + )]) + .into(), + ), + } + } + + #[test] + fn parse_error_status_accepts_4xx_and_5xx_only() { + assert_eq!(parse_error_status("400"), Some(400)); + assert_eq!(parse_error_status("404"), Some(404)); + assert_eq!(parse_error_status("500"), Some(500)); + assert_eq!(parse_error_status("503"), Some(503)); + // 2xx, 1xx, 3xx, default key, and malformed values all reject. + assert_eq!(parse_error_status("200"), None); + assert_eq!(parse_error_status("101"), None); + assert_eq!(parse_error_status("301"), None); + assert_eq!(parse_error_status("default"), None); + assert_eq!(parse_error_status("4xx"), None); + assert_eq!(parse_error_status(""), None); + } + + #[test] + fn collects_4xx_and_5xx_responses_with_json_schemas_sorted_by_status() { + let mut responses = BTreeMap::new(); + responses.insert("200".to_string(), json_response(Schema::default_string())); + responses.insert("500".to_string(), json_response(Schema::default_string())); + responses.insert("400".to_string(), json_response(Schema::default_string())); + responses.insert("404".to_string(), json_response(Schema::default_string())); + + let ctx = test_reporter(); + let errors = + normalize_error_responses(Some(&responses), test_cx(&[], &ctx)).expect("normalize ok"); + + assert_eq!( + errors.iter().map(|e| e.status).collect::>(), + vec![400, 404, 500] + ); + } + + #[test] + fn skips_schemaless_and_non_json_error_responses() { + let mut responses = BTreeMap::new(); + responses.insert("400".to_string(), json_response(Schema::default_string())); + // 503: no content block at all — must be skipped without error. + responses.insert("503".to_string(), Response { content: None }); + // 502: content block, but JSON entry has no schema — must be skipped. + responses.insert( + "502".to_string(), + Response { + content: Some( + BTreeMap::from([("application/json".to_string(), MediaType { schema: None })]).into(), + ), + }, + ); + // 504: only non-JSON content — must be skipped. + responses.insert( + "504".to_string(), + Response { + content: Some( + BTreeMap::from([( + "text/plain".to_string(), + MediaType { + schema: Some(Schema::default_string()), + }, + )]) + .into(), + ), + }, + ); + + let ctx = test_reporter(); + let errors = + normalize_error_responses(Some(&responses), test_cx(&[], &ctx)).expect("normalize ok"); + + assert_eq!( + errors.iter().map(|e| e.status).collect::>(), + vec![400] + ); + } + + #[test] + fn skips_default_response_key() { + let mut responses = BTreeMap::new(); + responses.insert( + "default".to_string(), + json_response(Schema::default_string()), + ); + responses.insert("400".to_string(), json_response(Schema::default_string())); + + let ctx = test_reporter(); + let errors = + normalize_error_responses(Some(&responses), test_cx(&[], &ctx)).expect("normalize ok"); + + // Only 400 survives — `default` is intentionally not surfaced. + assert_eq!( + errors.iter().map(|e| e.status).collect::>(), + vec![400] + ); + } +} diff --git a/src/ir/normalize/schema.rs b/src/ir/normalize/schema.rs deleted file mode 100644 index 5042a5e..0000000 --- a/src/ir/normalize/schema.rs +++ /dev/null @@ -1,701 +0,0 @@ -use std::collections::BTreeMap; - -use crate::error::{Context, Diagnostic, DiagnosticCode, Reporter}; -use crate::ir::canonical::ModelSymbol; -use crate::ir::schema::{Discriminator, SchemaProperty, SchemaScalar, SchemaType}; -use crate::parse::openapi_model::{AdditionalProperties, Schema}; - -use super::{MAX_NORMALIZE_DEPTH, check_unsupported_not, unsupported}; - -pub(super) fn normalize_schemas( - schemas: &BTreeMap, - reporter: &mut Reporter<'_>, -) -> Result, Diagnostic> { - let mut normalized = Vec::with_capacity(schemas.len()); - - for (name, schema) in schemas { - normalized.push(normalize_named_schema(name, schema, reporter)?); - } - // `normalize_named_schema` starts each top-level schema walk at depth - // 0 (see calls below); the recursive helpers carry the counter down so - // a pathological spec is rejected by MAX_NORMALIZE_DEPTH before - // overflowing the thread stack. - - // Discriminator narrowing happens in `normalize::semantic::finalize` - // (the final step of `normalize_api_model`), not here. This file is a - // pure "OpenAPI schema → canonical" stage; the discriminator patch is - // emit-driven so it runs after operation lowering. - - Ok(normalized) -} - -fn normalize_named_schema( - schema_name: &str, - schema: &Schema, - reporter: &mut Reporter<'_>, -) -> Result { - let context = Context::Schema(schema_name); - check_unsupported_not(schema, &context, reporter)?; - - if let Some(values) = &schema.enum_ { - validate_string_enum_type(schema, &context, reporter)?; - return Ok(ModelSymbol { - name: schema_name.into(), - description: schema.description.clone(), - deprecated: schema.deprecated, - body: SchemaType::StringLiterals { - values: normalize_string_enum(values, &context, reporter)?, - }, - }); - } - - if schema.type_.as_deref() == Some("object") - && schema.ref_.is_none() - && !has_supported_composition(schema) - && !is_additional_properties_constraint(schema) - && !is_any_type_schema(schema) - { - return Ok(ModelSymbol { - name: schema_name.into(), - description: schema.description.clone(), - deprecated: schema.deprecated, - body: SchemaType::InlineObject { - properties: normalize_object_properties(schema, schema_name, 0, reporter)?, - }, - }); - } - - Ok(ModelSymbol { - name: schema_name.into(), - description: schema.description.clone(), - deprecated: schema.deprecated, - body: normalize_schema(schema, &context, 0, reporter)?, - }) -} - -fn normalize_object_properties( - schema: &Schema, - schema_name: &str, - depth: u16, - reporter: &mut Reporter<'_>, -) -> Result, Diagnostic> { - normalize_properties(schema, &Context::Schema(schema_name), depth, reporter) -} - -pub(super) fn normalize_properties( - schema: &Schema, - context: &Context<'_>, - depth: u16, - reporter: &mut Reporter<'_>, -) -> Result, Diagnostic> { - check_unsupported_not(schema, context, reporter)?; - - if is_additional_properties_constraint(schema) { - return Err(unsupported( - format!( - "{} uses additionalProperties after composition, which remains outside the supported subset.", - context.render() - ), - reporter, - false, - )); - } - - let Some(properties) = &schema.properties else { - return Ok(Vec::new()); - }; - - let required: std::collections::HashSet<&str> = - schema.required.iter().map(String::as_str).collect(); - let mut normalized = Vec::with_capacity(properties.len()); - - for (name, property_schema) in properties { - let required_flag = required.contains(name.as_str()); - let prop_context = Context::Property { - parent: context, - name, - }; - let base_ty = normalize_schema_raw(property_schema, &prop_context, depth, reporter)?; - let ty = apply_nullable_flag(base_ty, property_schema.nullable.unwrap_or(false)); - normalized.push(SchemaProperty { - name: name.as_str().into(), - required: required_flag, - ty, - description: property_schema.description.clone(), - deprecated: property_schema.deprecated, - }); - } - - Ok(normalized) -} - -pub(super) fn normalize_schema( - schema: &Schema, - context: &Context<'_>, - depth: u16, - reporter: &mut Reporter<'_>, -) -> Result { - let base = normalize_schema_raw(schema, context, depth, reporter)?; - Ok(apply_nullable_flag(base, schema.nullable.unwrap_or(false))) -} - -fn normalize_schema_raw( - schema: &Schema, - context: &Context<'_>, - depth: u16, - reporter: &mut Reporter<'_>, -) -> Result { - // Single chokepoint for the recursion guard: every schema-shape branch - // below either bottoms out (scalar / ref / enum / Any) or routes back - // through one of the recursive helpers, all of which forward - // `depth + 1`. Checking here keeps the bound enforceable from one - // place rather than scattered across each recursive call. - if depth >= MAX_NORMALIZE_DEPTH { - return Err(unsupported( - format!( - "{} nesting exceeds {MAX_NORMALIZE_DEPTH} levels (likely cyclic or pathological spec).", - context.render() - ), - reporter, - false, - )); - } - - // OpenAPI `format` hints (e.g. `uuid`, `date-time`, `int32`) carry - // semantic information that the current IR does not preserve — the - // generator emits the base type without format-specific narrowing. - // Surface every occurrence as a warning so spec authors see what's - // being dropped instead of the field being silently ignored. - if let Some(format) = &schema.format { - reporter.warning( - DiagnosticCode::UnsupportedSemantic, - Some("format-dropped"), - format!( - "{} declares format '{format}', which is currently dropped — the generator emits the base type without format-specific narrowing.", - context.render() - ), - ); - } - - check_unsupported_not(schema, context, reporter)?; - - if is_additional_properties_constraint(schema) { - // Safe to unwrap-via-match: is_additional_properties_constraint is true - // only when `additional_properties` is `Some(Schema)` or `Some(Boolean(true))`. - if let Some(ap) = &schema.additional_properties { - return normalize_additional_properties(schema, ap, context, depth, reporter); - } - } - - if let Some(composition) = normalize_composition(schema, context, depth, reporter)? { - return Ok(composition); - } - - if is_any_type_schema(schema) { - return Ok(SchemaType::Any); - } - - if let Some(reference) = &schema.ref_ { - return Ok(SchemaType::Ref(normalize_reference( - reference, context, reporter, - )?)); - } - - if let Some(values) = &schema.enum_ { - validate_string_enum_type(schema, context, reporter)?; - return Ok(SchemaType::StringLiterals { - values: normalize_string_enum(values, context, reporter)?, - }); - } - - match schema.type_.as_deref() { - Some("string") => Ok(SchemaType::Scalar(SchemaScalar::String)), - Some("integer" | "number") => Ok(SchemaType::Scalar(SchemaScalar::Number)), - Some("boolean") => Ok(SchemaType::Scalar(SchemaScalar::Boolean)), - Some("array") => { - let items = schema.items.as_deref().ok_or_else(|| { - unsupported( - format!("{} array schemas must define items.", context.render()), - reporter, - true, - ) - })?; - Ok(SchemaType::Array(Box::new(normalize_schema( - items, - context, - depth + 1, - reporter, - )?))) - } - Some("object") => Ok(SchemaType::InlineObject { - properties: normalize_properties(schema, context, depth + 1, reporter)?, - }), - Some(other) => Err(unsupported( - format!("{} uses unsupported type {other}.", context.render()), - reporter, - true, - )), - None => Err(unsupported( - format!( - "{} must define a supported type, $ref, or supported composition.", - context.render() - ), - reporter, - true, - )), - } -} - -fn normalize_additional_properties( - schema: &Schema, - ap: &AdditionalProperties, - context: &Context<'_>, - depth: u16, - reporter: &mut Reporter<'_>, -) -> Result { - if has_supported_composition(schema) { - return Err(unsupported( - format!( - "{} must not combine additionalProperties with composition keywords.", - context.render() - ), - reporter, - false, - )); - } - - if schema.properties.is_some() || !schema.required.is_empty() { - return Err(unsupported( - format!( - "{} combines additionalProperties with named object properties, which remains outside the supported subset.", - context.render() - ), - reporter, - false, - )); - } - - if schema.ref_.is_some() { - return Err(unsupported( - format!( - "{} must not combine additionalProperties with $ref.", - context.render() - ), - reporter, - false, - )); - } - - if let Some(type_) = &schema.type_ - && type_ != "object" - { - return Err(unsupported( - format!( - "{} uses additionalProperties with non-object type {type_}.", - context.render() - ), - reporter, - false, - )); - } - - let ap_schema = match ap { - AdditionalProperties::Schema(s) => s.as_ref(), - AdditionalProperties::Boolean(_) => { - return Err(unsupported( - format!( - "{} must define additionalProperties as a schema object.", - context.render() - ), - reporter, - false, - )); - } - }; - - let ap_context = Context::AdditionalProperties { parent: context }; - Ok(SchemaType::Map(Box::new(normalize_schema( - ap_schema, - &ap_context, - depth + 1, - reporter, - )?))) -} - -fn normalize_composition( - schema: &Schema, - context: &Context<'_>, - depth: u16, - reporter: &mut Reporter<'_>, -) -> Result, Diagnostic> { - let composition_count = [ - schema.one_of.is_some(), - schema.any_of.is_some(), - schema.all_of.is_some(), - ] - .into_iter() - .filter(|&present| present) - .count(); - - if composition_count == 0 { - return Ok(None); - } - - if composition_count > 1 { - return Err(unsupported( - format!( - "{} must not combine multiple composition keywords.", - context.render() - ), - reporter, - true, - )); - } - - if let Some(entries) = &schema.one_of { - return normalize_composition_entries( - entries, - context, - depth, - reporter, - CompositionKind::Union, - schema.discriminator.as_ref(), - ) - .map(Some); - } - - if let Some(entries) = &schema.any_of { - return normalize_composition_entries( - entries, - context, - depth, - reporter, - CompositionKind::Union, - None, - ) - .map(Some); - } - - let Some(entries) = schema.all_of.as_deref() else { - // Defensive guard: today's invariant is that `composition_count > 0` - // with one_of/any_of None implies all_of is Some, since - // `composition_count` is the population count of exactly those three - // booleans. A future refactor that adds a fourth composition keyword - // without updating this proof would otherwise crash the host Node - // process via `unreachable!`. Surfacing a typed error keeps such a - // regression user-visible. - return Err(unsupported( - format!( - "{} internal: composition counted {composition_count} keywords but none matched.", - context.render() - ), - reporter, - false, - )); - }; - normalize_composition_entries( - entries, - context, - depth, - reporter, - CompositionKind::Intersection, - None, - ) - .map(Some) -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum CompositionKind { - Union, - Intersection, -} - -fn normalize_composition_entries( - entries: &[Schema], - context: &Context<'_>, - depth: u16, - reporter: &mut Reporter<'_>, - kind: CompositionKind, - discriminator: Option<&crate::parse::openapi_model::Discriminator>, -) -> Result { - if entries.is_empty() { - return Err(unsupported( - format!( - "{} composition must contain at least one member.", - context.render() - ), - reporter, - true, - )); - } - - let mut normalized = Vec::with_capacity(entries.len()); - for (index, entry) in entries.iter().enumerate() { - let member_context = Context::CompositionMember { - parent: context, - index: index + 1, - }; - normalized.push(normalize_schema( - entry, - &member_context, - depth + 1, - reporter, - )?); - } - - if normalized.len() == 1 { - return Ok(normalized.remove(0)); - } - - Ok(match kind { - CompositionKind::Union => { - let discriminator = match discriminator.filter(|d| !d.property_name.is_empty()) { - None => None, - Some(d) => Some(resolve_discriminator(d, context, reporter)?), - }; - SchemaType::Union { - members: normalized, - discriminator, - } - } - CompositionKind::Intersection => SchemaType::Intersection(normalized), - }) -} - -/// Build the IR-side `Discriminator` from the parse-stage one, resolving -/// every `mapping` value to a bare schema name. -/// -/// Per the OpenAPI spec, a `discriminator.mapping` value is either a bare -/// schema name (`Cat`) or a full `$ref` (`#/components/schemas/Cat`). A -/// value that contains a `/` is treated as ref-shaped and routed through -/// `normalize_reference`, which is the single source of truth for `$ref` -/// validation across this crate — that gives external refs -/// (`http://...`), sibling-file refs (`./other.yaml#/...`), and other -/// unsupported shapes the same `E_UNSUPPORTED_SEMANTIC` diagnostic the -/// rest of the pipeline emits, instead of silently passing the literal -/// through where it would never match a union member. -/// -/// Bare names (no `/`) are accepted as-is so the common spec idiom keeps -/// working without forcing authors to write the full ref form. -fn resolve_discriminator( - parsed: &crate::parse::openapi_model::Discriminator, - context: &Context<'_>, - reporter: &Reporter<'_>, -) -> Result { - let mut mapping = std::collections::BTreeMap::new(); - for (wire_value, schema_ref) in &parsed.mapping { - let resolved = if schema_ref.contains('/') { - normalize_reference(schema_ref, context, reporter)? - } else { - schema_ref.as_str().into() - }; - mapping.insert(wire_value.as_str().into(), resolved); - } - Ok(Discriminator { - property_name: parsed.property_name.as_str().into(), - mapping, - }) -} - -/// Wrap `base` in `SchemaType::Nullable` when the OpenAPI `nullable: true` -/// flag is set. Idempotent over already-`Nullable` types. -fn apply_nullable_flag(base: SchemaType, nullable: bool) -> SchemaType { - if !nullable || matches!(base, SchemaType::Nullable(_)) { - return base; - } - SchemaType::Nullable(Box::new(base)) -} - -const fn is_any_type_schema(schema: &Schema) -> bool { - schema.type_.is_none() - && schema.ref_.is_none() - && schema.enum_.is_none() - && !has_supported_composition(schema) - && schema.additional_properties.is_none() -} - -const fn has_supported_composition(schema: &Schema) -> bool { - schema.one_of.is_some() || schema.any_of.is_some() || schema.all_of.is_some() -} - -/// True when `additionalProperties` actually constrains emission (a schema -/// object or `Boolean(true)`). `Boolean(false)` is treated as a no-op — -/// OpenAPI semantics are "no extras beyond the declared `properties`", -/// which is structurally the same as not setting the field at all for our -/// emit purposes (TS interfaces with declared properties don't accept -/// arbitrary extras by default). -const fn is_additional_properties_constraint(schema: &Schema) -> bool { - matches!( - schema.additional_properties, - Some(AdditionalProperties::Schema(_) | AdditionalProperties::Boolean(true)) - ) -} - -fn normalize_reference( - reference: &str, - context: &Context<'_>, - reporter: &Reporter<'_>, -) -> Result, Diagnostic> { - let name = reference - .strip_prefix("#/components/schemas/") - .ok_or_else(|| { - unsupported( - format!( - "{} uses unsupported reference {reference}.", - context.render() - ), - reporter, - true, - ) - })?; - - // Reject `$ref: '#/components/schemas/'` (trailing slash, empty target - // name) before the empty `Box` flows downstream — every downstream - // consumer of a ref name (naming helpers, emit-side identifier checks) - // assumes a non-empty token, so failing here keeps the host process - // alive with a clean diagnostic instead of producing nameless output. - if name.is_empty() { - return Err(unsupported( - format!( - "{} $ref target name is empty (reference {reference}).", - context.render() - ), - reporter, - true, - )); - } - - Ok(Box::from(name)) -} - -fn normalize_string_enum( - values: &[serde_json::Value], - context: &Context<'_>, - reporter: &Reporter<'_>, -) -> Result, Diagnostic> { - let mut result = Vec::with_capacity(values.len()); - for entry in values { - let Some(s) = entry.as_str() else { - return Err(unsupported( - format!("{} enum must contain only strings.", context.render()), - reporter, - true, - )); - }; - - if s.contains('\u{0000}') { - return Err(unsupported( - format!( - "{} enum values must not contain null bytes.", - context.render() - ), - reporter, - true, - )); - } - - result.push(s.to_string()); - } - - Ok(result) -} - -fn validate_string_enum_type( - schema: &Schema, - context: &Context<'_>, - reporter: &Reporter<'_>, -) -> Result<(), Diagnostic> { - match schema.type_.as_deref() { - Some("string") | None => Ok(()), - Some(other) => Err(unsupported( - format!( - "{} enum is supported only for string schemas, found type {other}.", - context.render() - ), - reporter, - true, - )), - } -} - -#[cfg(test)] -mod proptests { - use std::rc::Rc; - - use proptest::prelude::*; - - use super::normalize_named_schema; - use crate::error::{DiagnosticCode, Reporter}; - use crate::parse::openapi_model::Schema; - - fn arb_schema(max_depth: u32) -> impl Strategy { - let leaf = Just(Schema::default_string()); - leaf.prop_recursive(max_depth, 32, 4, |inner| { - prop_oneof![ - inner.clone().prop_map(Schema::wrap_array), - proptest::collection::vec(inner.clone(), 0..3).prop_map(Schema::wrap_one_of), - inner.prop_map(Schema::wrap_nullable), - ] - }) - } - - proptest! { - #![proptest_config(ProptestConfig { - cases: 128, - ..ProptestConfig::default() - })] - - #[test] - fn normalize_named_schema_never_panics(schema in arb_schema(40)) { - let mut warnings = Vec::new(); - let path: Rc = Rc::from("test"); - let mut reporter = Reporter::new(path, &mut warnings); - let result = normalize_named_schema("Root", &schema, &mut reporter); - - if let Err(diag) = result { - prop_assert!( - matches!( - diag.code, - DiagnosticCode::UnsupportedSemantic | DiagnosticCode::PolicyViolation, - ), - "unexpected diagnostic code: {:?}", diag.code, - ); - } - } - } -} - -#[cfg(test)] -mod tests { - use std::rc::Rc; - - use super::normalize_named_schema; - use crate::error::Reporter; - use crate::parse::openapi_model::Schema; - - #[test] - fn depth_exceeded_diagnostic_includes_breadcrumb_chain() { - // Build a 40-level-deep schema by wrapping in array; MAX_NORMALIZE_DEPTH is 32. - let mut schema = Schema::default_string(); - for _ in 0..40 { - schema = Schema::wrap_array(schema); - } - - let mut warnings = Vec::new(); - let path: Rc = Rc::from("test"); - let mut reporter = Reporter::new(path, &mut warnings); - let err = normalize_named_schema("Root", &schema, &mut reporter) - .expect_err("should fail with depth exceeded"); - - assert!( - err.message.contains("32"), - "expected depth limit in message: {}", - err.message, - ); - assert!( - err.message.contains("Root"), - "expected root breadcrumb in message: {}", - err.message, - ); - } -} diff --git a/src/ir/normalize/schema/composition.rs b/src/ir/normalize/schema/composition.rs new file mode 100644 index 0000000..0fe9897 --- /dev/null +++ b/src/ir/normalize/schema/composition.rs @@ -0,0 +1,123 @@ +//! `oneOf` / `anyOf` / `allOf` lowering, and the discriminator carried by a +//! discriminated `oneOf`. + +use std::collections::BTreeMap; + +use crate::error::Diagnostic; +use crate::ir::schema::{Discriminator, SchemaType}; +use crate::parse::openapi_model::{self, Schema}; + +use super::super::{SchemaWalk, bail_unsupported}; +use super::{normalize_reference, normalize_schema}; + +/// Lowers whichever composition keyword `schema` declares, or `None` when it +/// declares none. Rejects a schema that mixes two of them. +pub(super) fn normalize_composition( + schema: &Schema, + walk: SchemaWalk<'_>, +) -> Result, Diagnostic> { + let declared = [ + schema + .one_of + .as_deref() + .map(|entries| (Kind::Union, entries)), + schema + .any_of + .as_deref() + .map(|entries| (Kind::Union, entries)), + schema + .all_of + .as_deref() + .map(|entries| (Kind::Intersection, entries)), + ]; + let present = declared.iter().flatten().count(); + + if present > 1 { + bail_unsupported!( + walk.reporter(), + "{} must not combine multiple composition keywords.", + walk.here() + ); + } + let Some((kind, entries)) = declared.into_iter().flatten().next() else { + return Ok(None); + }; + + // Only `oneOf` carries a discriminator; `anyOf` and `allOf` ignore it. + let discriminator = schema + .discriminator + .as_ref() + .filter(|_| schema.one_of.is_some()); + normalize_entries(entries, kind, discriminator, walk).map(Some) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Kind { + Union, + Intersection, +} + +fn normalize_entries( + entries: &[Schema], + kind: Kind, + discriminator: Option<&openapi_model::Discriminator>, + walk: SchemaWalk<'_>, +) -> Result { + if entries.is_empty() { + bail_unsupported!( + walk.reporter(), + "{} composition must contain at least one member.", + walk.here() + ); + } + + let mut members = entries + .iter() + .enumerate() + .map(|(index, entry)| normalize_schema(entry, walk.composition_member(index + 1))) + .collect::, Diagnostic>>()?; + + // A one-member composition is its member. + if members.len() == 1 { + return Ok(members.remove(0)); + } + + Ok(match kind { + Kind::Union => SchemaType::Union { + members, + discriminator: discriminator + .filter(|declared| !declared.property_name.is_empty()) + .map(|declared| resolve_discriminator(declared, walk)) + .transpose()?, + }, + Kind::Intersection => SchemaType::Intersection(members), + }) +} + +/// Resolves every `mapping` value to a bare schema name. +/// +/// A value is either a bare name (`Cat`) or a full ref +/// (`#/components/schemas/Cat`); one containing `/` is resolved through +/// [`normalize_reference`] and fails on the shapes that rejects. +fn resolve_discriminator( + declared: &openapi_model::Discriminator, + walk: SchemaWalk<'_>, +) -> Result { + let mapping = declared + .mapping + .iter() + .map(|(wire_value, target)| { + let resolved = if target.contains('/') { + normalize_reference(target, walk)? + } else { + target.as_str().into() + }; + Ok((wire_value.as_str().into(), resolved)) + }) + .collect::, Diagnostic>>()?; + + Ok(Discriminator { + property_name: declared.property_name.as_str().into(), + mapping, + }) +} diff --git a/src/ir/normalize/schema/enums.rs b/src/ir/normalize/schema/enums.rs new file mode 100644 index 0000000..2e2c967 --- /dev/null +++ b/src/ir/normalize/schema/enums.rs @@ -0,0 +1,51 @@ +//! `enum` lowering. Only string enums are supported; they become a +//! TypeScript literal union. + +use crate::error::Diagnostic; + +use super::super::{SchemaWalk, bail_unsupported}; +use crate::parse::openapi_model::Schema; + +/// Collects the enum's values, rejecting a non-string member or a value +/// carrying a null byte. +pub(super) fn normalize_string_enum( + values: &[serde_json::Value], + walk: SchemaWalk<'_>, +) -> Result, Diagnostic> { + values + .iter() + .map(|entry| { + let Some(value) = entry.as_str() else { + bail_unsupported!( + walk.reporter(), + "{} enum must contain only strings.", + walk.here() + ); + }; + if value.contains('\u{0000}') { + bail_unsupported!( + walk.reporter(), + "{} enum values must not contain null bytes.", + walk.here() + ); + } + Ok(value.to_string()) + }) + .collect() +} + +/// Accepts `type: string` or an absent `type`; every other declared type +/// rejects. +pub(super) fn validate_string_enum_type( + schema: &Schema, + walk: SchemaWalk<'_>, +) -> Result<(), Diagnostic> { + match schema.type_.as_deref() { + Some("string") | None => Ok(()), + Some(other) => bail_unsupported!( + walk.reporter(), + "{} enum is supported only for string schemas, found type {other}.", + walk.here() + ), + } +} diff --git a/src/ir/normalize/schema/map.rs b/src/ir/normalize/schema/map.rs new file mode 100644 index 0000000..1d02aa1 --- /dev/null +++ b/src/ir/normalize/schema/map.rs @@ -0,0 +1,64 @@ +//! `additionalProperties` lowering into `Record`. + +use crate::error::Diagnostic; +use crate::ir::schema::SchemaType; +use crate::parse::openapi_model::{AdditionalProperties, Schema}; + +use super::super::{SchemaWalk, bail_unsupported_rule}; +use super::normalize_schema; + +/// Lowers a schema whose `additionalProperties` constrains emission into +/// [`SchemaType::Map`]. +/// +/// The supported shape is `additionalProperties` alone. Combining it with +/// `properties`, `required`, `$ref`, a composition keyword or a non-object +/// `type` fails, naming the rule it broke. +pub(super) fn normalize_additional_properties( + schema: &Schema, + additional: &AdditionalProperties, + walk: SchemaWalk<'_>, +) -> Result { + if super::has_supported_composition(schema) { + bail_unsupported_rule!( + walk.reporter(), + "{} must not combine additionalProperties with composition keywords.", + walk.here() + ); + } + if schema.properties.is_some() || !schema.required.is_empty() { + bail_unsupported_rule!( + walk.reporter(), + "{} combines additionalProperties with named object properties, which remains outside the supported subset.", + walk.here() + ); + } + if schema.ref_.is_some() { + bail_unsupported_rule!( + walk.reporter(), + "{} must not combine additionalProperties with $ref.", + walk.here() + ); + } + if let Some(declared) = &schema.type_ + && declared != "object" + { + bail_unsupported_rule!( + walk.reporter(), + "{} uses additionalProperties with non-object type {declared}.", + walk.here() + ); + } + + let AdditionalProperties::Schema(values) = additional else { + bail_unsupported_rule!( + walk.reporter(), + "{} must define additionalProperties as a schema object.", + walk.here() + ); + }; + + Ok(SchemaType::Map(Box::new(normalize_schema( + values, + walk.additional_properties(), + )?))) +} diff --git a/src/ir/normalize/schema/mod.rs b/src/ir/normalize/schema/mod.rs new file mode 100644 index 0000000..9d14f83 --- /dev/null +++ b/src/ir/normalize/schema/mod.rs @@ -0,0 +1,242 @@ +//! OpenAPI schema → canonical `SchemaType`. +//! +//! Entry points are [`normalize_schemas`] for `components.schemas` and +//! [`normalize_schema`] / [`normalize_properties`] for the schemas embedded +//! in operations. Discriminator narrowing is not done here — it runs in +//! [`super::semantic`] once operation lowering has finished. + +mod composition; +mod enums; +mod map; +mod reference; +#[cfg(test)] +mod tests; + +use std::collections::{BTreeMap, HashSet}; + +use crate::error::{Context, Diagnostic, Reporter}; +use crate::ir::canonical::ModelSymbol; +use crate::ir::schema::{SchemaProperty, SchemaScalar, SchemaType}; +use crate::parse::openapi_model::{AdditionalProperties, Schema}; + +use super::{SchemaWalk, bail_unsupported, bail_unsupported_rule, check_unsupported_not}; +use composition::normalize_composition; +use enums::{normalize_string_enum, validate_string_enum_type}; +use map::normalize_additional_properties; +use reference::normalize_reference; + +pub(super) fn normalize_schemas( + schemas: &BTreeMap, + reporter: &Reporter, +) -> Result, Diagnostic> { + schemas + .iter() + .map(|(name, schema)| normalize_named_schema(name, schema, reporter)) + .collect() +} + +fn normalize_named_schema( + name: &str, + schema: &Schema, + reporter: &Reporter, +) -> Result { + let walk = SchemaWalk::root(Context::Schema(name), reporter); + check_unsupported_not(schema, walk)?; + + let body = if let Some(values) = &schema.enum_ { + validate_string_enum_type(schema, walk)?; + SchemaType::StringLiterals { + values: normalize_string_enum(values, walk)?, + } + } else if declares_a_plain_object(schema) { + SchemaType::InlineObject { + properties: normalize_properties(schema, walk)?, + } + } else { + normalize_schema(schema, walk)? + }; + + Ok(ModelSymbol { + name: name.into(), + description: schema.description.clone(), + deprecated: schema.deprecated, + body, + }) +} + +/// True when the schema is an object declared by its `properties` alone, so +/// it emits as `export interface` rather than a type alias. +fn declares_a_plain_object(schema: &Schema) -> bool { + schema.type_.as_deref() == Some("object") + && schema.ref_.is_none() + && !has_supported_composition(schema) + && !is_additional_properties_constraint(schema) + && !is_any_type_schema(schema) +} + +pub(super) fn normalize_properties( + schema: &Schema, + walk: SchemaWalk<'_>, +) -> Result, Diagnostic> { + check_unsupported_not(schema, walk)?; + + if is_additional_properties_constraint(schema) { + bail_unsupported_rule!( + walk.reporter(), + "{} uses additionalProperties after composition, which remains outside the supported subset.", + walk.here() + ); + } + + let Some(properties) = &schema.properties else { + return Ok(Vec::new()); + }; + + let required: HashSet<&str> = schema.required.iter().map(String::as_str).collect(); + + properties + .iter() + .map(|(name, property)| { + let base = normalize_type(property, walk.property(name))?; + Ok(SchemaProperty { + name: name.as_str().into(), + required: required.contains(name.as_str()), + ty: apply_nullable_flag(base, property.nullable.unwrap_or(false)), + description: property.description.clone(), + deprecated: property.deprecated, + }) + }) + .collect() +} + +/// Normalizes `schema` at `walk`, folding its own `nullable: true` into the +/// result. +pub(super) fn normalize_schema( + schema: &Schema, + walk: SchemaWalk<'_>, +) -> Result { + let base = normalize_type(schema, walk)?; + Ok(apply_nullable_flag(base, schema.nullable.unwrap_or(false))) +} + +/// Dispatches on the schema's shape. The caller folds in `nullable`; this is +/// the single chokepoint for the depth guard, because every shape either +/// bottoms out or descends through a [`SchemaWalk`]. +fn normalize_type(schema: &Schema, walk: SchemaWalk<'_>) -> Result { + walk.check_depth()?; + warn_dropped_format(schema, walk); + check_unsupported_not(schema, walk)?; + + if is_additional_properties_constraint(schema) + && let Some(additional) = &schema.additional_properties + { + return normalize_additional_properties(schema, additional, walk); + } + + if let Some(composition) = normalize_composition(schema, walk)? { + return Ok(composition); + } + + if is_any_type_schema(schema) { + return Ok(SchemaType::Any); + } + + if let Some(reference) = &schema.ref_ { + return Ok(SchemaType::Ref(normalize_reference(reference, walk)?)); + } + + if let Some(values) = &schema.enum_ { + validate_string_enum_type(schema, walk)?; + return Ok(SchemaType::StringLiterals { + values: normalize_string_enum(values, walk)?, + }); + } + + normalize_declared_type(schema, walk) +} + +fn normalize_declared_type( + schema: &Schema, + walk: SchemaWalk<'_>, +) -> Result { + match schema.type_.as_deref() { + Some("string") => Ok(SchemaType::Scalar(SchemaScalar::String)), + Some("integer" | "number") => Ok(SchemaType::Scalar(SchemaScalar::Number)), + Some("boolean") => Ok(SchemaType::Scalar(SchemaScalar::Boolean)), + Some("array") => { + let Some(items) = schema.items.as_deref() else { + bail_unsupported!( + walk.reporter(), + "{} array schemas must define items.", + walk.here() + ); + }; + Ok(SchemaType::Array(Box::new(normalize_schema( + items, + walk.item(), + )?))) + } + Some("object") => Ok(SchemaType::InlineObject { + properties: normalize_properties(schema, walk.item())?, + }), + Some(other) => bail_unsupported!( + walk.reporter(), + "{} uses unsupported type {other}.", + walk.here() + ), + None => bail_unsupported!( + walk.reporter(), + "{} must define a supported type, $ref, or supported composition.", + walk.here() + ), + } +} + +/// Reports every `format` the IR drops. +fn warn_dropped_format(schema: &Schema, walk: SchemaWalk<'_>) { + if let Some(format) = &schema.format { + walk.reporter().warning( + crate::error::DiagnosticCode::UnsupportedSemantic, + Some("format-dropped"), + format!( + "{} declares format '{format}', which is currently dropped — the generator emits the base type without format-specific narrowing.", + walk.here() + ), + ); + } +} + +/// Wraps `base` in [`SchemaType::Nullable`] when `nullable` is set. +/// Idempotent over an already-nullable type. +fn apply_nullable_flag(base: SchemaType, nullable: bool) -> SchemaType { + if !nullable || matches!(base, SchemaType::Nullable(_)) { + return base; + } + SchemaType::Nullable(Box::new(base)) +} + +/// True for a schema with no constraints at all, which renders as +/// `unknown`. +const fn is_any_type_schema(schema: &Schema) -> bool { + schema.type_.is_none() + && schema.ref_.is_none() + && schema.enum_.is_none() + && !has_supported_composition(schema) + && schema.additional_properties.is_none() +} + +const fn has_supported_composition(schema: &Schema) -> bool { + schema.one_of.is_some() || schema.any_of.is_some() || schema.all_of.is_some() +} + +/// True when `additionalProperties` constrains emission — a schema object, +/// or literal `true`. +/// +/// `additionalProperties: false` is a no-op here: "no members beyond +/// `properties`" is what a TypeScript interface already means. +const fn is_additional_properties_constraint(schema: &Schema) -> bool { + matches!( + schema.additional_properties, + Some(AdditionalProperties::Schema(_) | AdditionalProperties::Boolean(true)) + ) +} diff --git a/src/ir/normalize/schema/reference.rs b/src/ir/normalize/schema/reference.rs new file mode 100644 index 0000000..6964c70 --- /dev/null +++ b/src/ir/normalize/schema/reference.rs @@ -0,0 +1,33 @@ +//! `$ref` resolution. The supported form is an internal reference into +//! `components.schemas`. + +use crate::error::Diagnostic; + +use super::super::{SchemaWalk, bail_unsupported}; + +const INTERNAL_SCHEMA_PREFIX: &str = "#/components/schemas/"; + +/// Returns the bare schema name a `$ref` targets. +/// +/// Rejects a reference outside `components.schemas` — an external file, a +/// URL, another component section — and one whose target name is empty. +pub(in crate::ir::normalize::schema) fn normalize_reference( + reference: &str, + walk: SchemaWalk<'_>, +) -> Result, Diagnostic> { + let Some(name) = reference.strip_prefix(INTERNAL_SCHEMA_PREFIX) else { + bail_unsupported!( + walk.reporter(), + "{} uses unsupported reference {reference}.", + walk.here() + ); + }; + if name.is_empty() { + bail_unsupported!( + walk.reporter(), + "{} $ref target name is empty (reference {reference}).", + walk.here() + ); + } + Ok(Box::from(name)) +} diff --git a/src/ir/normalize/schema/tests.rs b/src/ir/normalize/schema/tests.rs new file mode 100644 index 0000000..c0b5514 --- /dev/null +++ b/src/ir/normalize/schema/tests.rs @@ -0,0 +1,69 @@ +//! Property tests for the schema walk. + +use std::rc::Rc; + +use proptest::prelude::*; + +use super::normalize_named_schema; +use crate::error::{DiagnosticCode, Reporter}; +use crate::parse::openapi_model::Schema; + +fn arb_schema(max_depth: u32) -> impl Strategy { + let leaf = Just(Schema::default_string()); + leaf.prop_recursive(max_depth, 32, 4, |inner| { + prop_oneof![ + inner.clone().prop_map(Schema::wrap_array), + proptest::collection::vec(inner.clone(), 0..3).prop_map(Schema::wrap_one_of), + inner.prop_map(Schema::wrap_nullable), + ] + }) +} + +proptest! { + #![proptest_config(ProptestConfig { + cases: 128, + ..ProptestConfig::default() + })] + + #[test] + fn normalize_named_schema_never_panics(schema in arb_schema(40)) { + let path: Rc = Rc::from("test"); + let reporter = Reporter::new(path); + let result = normalize_named_schema("Root", &schema, &reporter); + + if let Err(diag) = result { + prop_assert!( + matches!( + diag.code, + DiagnosticCode::UnsupportedSemantic | DiagnosticCode::PolicyViolation, + ), + "unexpected diagnostic code: {:?}", diag.code, + ); + } + } +} + +#[test] +fn depth_exceeded_diagnostic_includes_breadcrumb_chain() { + // Build a 40-level-deep schema by wrapping in array; MAX_NORMALIZE_DEPTH is 32. + let mut schema = Schema::default_string(); + for _ in 0..40 { + schema = Schema::wrap_array(schema); + } + + let path: Rc = Rc::from("test"); + let reporter = Reporter::new(path); + let err = normalize_named_schema("Root", &schema, &reporter) + .expect_err("should fail with depth exceeded"); + + assert!( + err.message.contains("32"), + "expected depth limit in message: {}", + err.message, + ); + assert!( + err.message.contains("Root"), + "expected root breadcrumb in message: {}", + err.message, + ); +} diff --git a/src/ir/normalize/semantic.rs b/src/ir/normalize/semantic.rs index 2a61a1c..ca589d3 100644 --- a/src/ir/normalize/semantic.rs +++ b/src/ir/normalize/semantic.rs @@ -1,23 +1,16 @@ -//! Final semantic step of `normalize_api_model`: sort schemas, narrow -//! discriminator member properties, and validate `$ref` resolution. -//! -//! These transforms run after schema and operation lowering. They are -//! kept in a sibling file (rather than inlined into `mod.rs`) so the -//! discriminator-narrowing / reference-validation invariants are easy -//! to find and edit independently — but they are not a separate -//! pipeline stage. +//! The semantic pass that runs once schema and operation lowering are +//! done: schema sorting, discriminator narrowing and `$ref` validation. use std::collections::{BTreeMap, BTreeSet}; -use crate::error::{Diagnostic, DiagnosticCode, Reporter}; +use crate::error::{Diagnostic, DiagnosticCode, Reporter, bail, bail_policy}; use crate::ir::canonical::{ApiModel, BodyContent, ModelSymbol, ResponseContent}; use crate::ir::schema::{SchemaProperty, SchemaScalar, SchemaType, collect_type_references}; -/// Sort the schema list alphabetically (stable iteration order), narrow -/// discriminator member properties to single-value enums, and validate -/// every `$ref` resolves to a declared top-level schema. Mutates the -/// model in place. -pub(super) fn finalize(model: &mut ApiModel, reporter: &Reporter<'_>) -> Result<(), Diagnostic> { +/// Sorts the schemas by name, narrows the discriminator properties, and +/// fails on a `$ref` that resolves to no declared schema. Mutates `model` +/// in place. +pub(super) fn finalize(model: &mut ApiModel, reporter: &Reporter) -> Result<(), Diagnostic> { model .schemas .sort_by(|left, right| left.name.cmp(&right.name)); @@ -26,26 +19,19 @@ pub(super) fn finalize(model: &mut ApiModel, reporter: &Reporter<'_>) -> Result< validate_references(model, reporter) } -/// Pre-emit transform: for each discriminated union, patches the -/// discriminator property on every member interface to a single-value -/// string literal type. Lets the TypeScript compiler narrow the union to -/// the concrete member type. +/// Narrows each discriminated union member's discriminator property to a +/// single-value string literal, which is what lets the TypeScript compiler +/// narrow the union to a concrete member. /// -/// Before patching, validates that every member interface actually -/// declares the discriminator property. A member that omits it would -/// otherwise be patched with a synthetic single-value literal that -/// never existed on the source schema — producing TS that does not -/// narrow correctly and silently diverges from the original spec. Emit -/// `E_POLICY_VIOLATION` with subcode `missing-discriminator-property` -/// so consumers see the gap loudly. +/// Fails with `missing-discriminator-property` when a member does not +/// declare the property, and `discriminator-property-must-be-string` when +/// it declares it with a non-string type. fn narrow_discriminator_properties( symbols: &mut [ModelSymbol], - reporter: &Reporter<'_>, + reporter: &Reporter, ) -> Result<(), Diagnostic> { - // Per member: map from discriminator property name to the literal - // value to assign. Building a map lets the per-property pass below do - // an O(log K) lookup instead of scanning K (prop_name, value) pairs - // per property — the original shape was O(P · K). + // Per member: the literal value to assign to each of its discriminator + // properties. let mut narrowings: BTreeMap, BTreeMap, Box>> = BTreeMap::new(); for symbol in symbols.iter() { if let SchemaType::Union { @@ -56,11 +42,9 @@ fn narrow_discriminator_properties( { for member in members { if let SchemaType::Ref(schema_name) = member { - // Honor OpenAPI `discriminator.mapping`: when an entry's - // value (pre-resolved to the bare schema name at IR-build - // time) matches this member, use the entry's key as the - // wire-value literal. Fall back to a lowercased schema name - // so unmapped specs keep their previous narrowing shape. + // A `discriminator.mapping` entry whose target is this member + // supplies the wire value; without one it is the lowercased + // schema name. let literal_value: Box = discriminator .mapping .iter() @@ -82,13 +66,8 @@ fn narrow_discriminator_properties( return Ok(()); } - // First pass: validate that each member that needs a discriminator - // narrowing actually declares the property — walking InlineObject, - // Intersection (the canonical `allOf: [Base, {kind: '…'}]` shape), - // Ref, and Nullable so allOf-composed variants don't silently skip. - // Also confirms the existing property type is string-shaped before any - // mutation happens, so an integer discriminator surfaces a loud - // diagnostic instead of being coerced into a synthetic string literal. + // Validate every member before mutating any of them, so a rejected + // spec leaves the model untouched. let by_name: BTreeMap<&str, &SchemaType> = symbols .iter() .map(|symbol| (symbol.name.as_ref(), &symbol.body)) @@ -100,34 +79,28 @@ fn narrow_discriminator_properties( }; for property_name in props.keys() { let Some(property) = find_property(&symbol.body, property_name, &by_name) else { - return Err(Diagnostic::policy_violation( + bail_policy!( reporter, "missing-discriminator-property", - format!( - "Failed to validate spec: oneOf member '{}' does not declare the discriminator property '{}'. Add the property to the member schema (typically as `type: string`) or remove the discriminator.", - symbol.name, property_name - ), - )); + "Failed to validate spec: oneOf member '{}' does not declare the discriminator property '{}'. Add the property to the member schema (typically as `type: string`) or remove the discriminator.", + symbol.name, + property_name + ); }; if !is_string_discriminator_shape(&property.ty) { - return Err(Diagnostic::policy_violation( + bail_policy!( reporter, "discriminator-property-must-be-string", - format!( - "Failed to validate spec: oneOf member '{}' declares discriminator property '{}' with a non-string type. Discriminator properties must be `type: string` (optionally with an enum); change the property type or remove the discriminator.", - symbol.name, property_name - ), - )); + "Failed to validate spec: oneOf member '{}' declares discriminator property '{}' with a non-string type. Discriminator properties must be `type: string` (optionally with an enum); change the property type or remove the discriminator.", + symbol.name, + property_name + ); } } } - // Second pass: mutate. Only mutates InlineObject members directly - // (either as a symbol body, or as an inline part of an Intersection). - // Ref-shaped members inherit narrowing from the referenced symbol's - // own mutation — no double-write needed. An Intersection of only - // Refs is left alone (the referenced symbols mutate themselves if - // they're also union members). + // Only inline objects are mutated: a `Ref` member is narrowed when the + // symbol it names is reached by this same loop. for symbol in symbols.iter_mut() { let Some(props) = narrowings.get(&symbol.name) else { continue; @@ -140,10 +113,8 @@ fn narrow_discriminator_properties( Ok(()) } -/// Resolve a property by name across the shapes that can carry one -/// after normalization. Used by the validation pass so a discriminator -/// property hidden behind `allOf` (Intersection) or a base-class `$ref` -/// is still found. +/// Finds a property by name through the shapes that can carry one: an +/// inline object, an `allOf` part, a `$ref` target, or a nullable wrapper. fn find_property<'a>( body: &'a SchemaType, name: &str, @@ -164,10 +135,8 @@ fn find_property<'a>( } } -/// Predicate for the validation pass: the existing property type must -/// already be string-shaped — bare `string`, or a `'a' | 'b'` enum. -/// Anything else (integer, nullable, ref to another schema, …) is -/// rejected as `discriminator-property-must-be-string`. +/// True for the property types a discriminator may declare: bare `string` +/// or a string-literal enum. const fn is_string_discriminator_shape(ty: &SchemaType) -> bool { matches!( ty, @@ -175,14 +144,11 @@ const fn is_string_discriminator_shape(ty: &SchemaType) -> bool { ) } -/// Narrow the named property in `body` to a single-value string literal. -/// Recurses into Intersection so a property declared on an inline part -/// of an `allOf` is mutated in place. Returns silently when the property -/// can't be reached through inline shapes — the validation pass has -/// already confirmed it exists somewhere reachable; for a Ref-only -/// intersection that points at a non-union-member base, the type just -/// stays as its original `string` shape (TS narrowing is partial in -/// that case but the surface still compiles). +/// Narrows the named property to a single-value string literal, returning +/// whether it was found. +/// +/// A property reachable only through a `$ref` is left alone: it keeps its +/// declared `string` type, which still compiles but narrows only partly. fn narrow_property_in_body(body: &mut SchemaType, name: &str, literal_value: &str) -> bool { match body { SchemaType::InlineObject { properties } => { @@ -210,7 +176,7 @@ fn narrow_property_in_body(body: &mut SchemaType, name: &str, literal_value: &st } } -fn validate_references(document: &ApiModel, reporter: &Reporter<'_>) -> Result<(), Diagnostic> { +fn validate_references(document: &ApiModel, reporter: &Reporter) -> Result<(), Diagnostic> { let symbol_index: BTreeSet<&str> = document .schemas .iter() @@ -232,18 +198,17 @@ fn validate_references(document: &ApiModel, reporter: &Reporter<'_>) -> Result<( if let Some(body) = &operation.request.body { match &body.content { BodyContent::Json(ty) => collect_type_references(ty, &mut refs), - // Multipart / UrlEncoded bodies are not yet produced by - // normalize; their field-type references will be collected - // when the walkers land in a later phase. + // A form body's fields are typed by `BodyFieldType`, which + // carries no schema reference; its `body_ref` was resolved + // against the schema index at lowering time. BodyContent::Multipart { .. } | BodyContent::UrlEncoded { .. } => {} } } if let Some(response) = &operation.response { match response { ResponseContent::Json(Some(ty)) => collect_type_references(ty, &mut refs), - // `Json(None)` carries no schema, and the non-JSON variants - // have fixed TS surfaces (`Blob` / `string` / `ArrayBuffer`) - // that never reference user-declared schemas. + // `Json(None)` carries no schema, and the other variants carry + // no payload. ResponseContent::Json(None) | ResponseContent::Blob | ResponseContent::Text @@ -254,12 +219,11 @@ fn validate_references(document: &ApiModel, reporter: &Reporter<'_>) -> Result<( for name in refs { if !symbol_index.contains(name) { - return Err(reporter.error( + bail!( + reporter, DiagnosticCode::InvalidReference, - format!( - "Failed to validate spec: unresolved schema reference {name}. Check for typos in the $ref and confirm that components.schemas defines a top-level entry named '{name}'." - ), - )); + "Failed to validate spec: unresolved schema reference {name}. Check for typos in the $ref and confirm that components.schemas defines a top-level entry named '{name}'." + ); } } @@ -271,7 +235,7 @@ mod tests { use super::narrow_discriminator_properties; use crate::ir::canonical::ModelSymbol; use crate::ir::schema::{Discriminator, SchemaProperty, SchemaScalar, SchemaType}; - use crate::test_support::test_ctx; + use crate::test_support::test_reporter; use std::collections::BTreeMap; fn property(name: &str, ty: SchemaType) -> SchemaProperty { @@ -306,8 +270,6 @@ mod tests { } } - // ── Issue 2a: Intersection walk ───────────────────────────────────────── - #[test] fn narrows_discriminator_property_on_intersection_member() { // Cat: allOf: [Animal, {kind: string, whiskers: number}] @@ -331,8 +293,8 @@ mod tests { symbol("Pet", pet_union(vec!["Cat"])), ]; - let mut ctx = test_ctx(); - narrow_discriminator_properties(&mut symbols, &ctx.reporter()).expect("ok"); + let ctx = test_reporter(); + narrow_discriminator_properties(&mut symbols, &ctx).expect("ok"); let cat = symbols.iter().find(|s| s.name.as_ref() == "Cat").unwrap(); let SchemaType::Intersection(parts) = &cat.body else { @@ -376,8 +338,8 @@ mod tests { ), symbol("Pet", pet_union(vec!["Cat"])), ]; - let mut ctx = test_ctx(); - narrow_discriminator_properties(&mut symbols, &ctx.reporter()) + let ctx = test_reporter(); + narrow_discriminator_properties(&mut symbols, &ctx) .expect("Ref-shaped intersection should validate via the referenced base"); } @@ -405,14 +367,12 @@ mod tests { ), symbol("Pet", pet_union(vec!["Cat"])), ]; - let mut ctx = test_ctx(); - let err = narrow_discriminator_properties(&mut symbols, &ctx.reporter()) + let ctx = test_reporter(); + let err = narrow_discriminator_properties(&mut symbols, &ctx) .expect_err("missing kind anywhere must reject"); assert_eq!(err.subcode, Some("missing-discriminator-property")); } - // ── Issue 2b: type-check before clobbering ────────────────────────────── - #[test] fn rejects_integer_discriminator_property() { let mut symbols = vec![ @@ -424,8 +384,8 @@ mod tests { ), symbol("Pet", pet_union(vec!["Cat"])), ]; - let mut ctx = test_ctx(); - let err = narrow_discriminator_properties(&mut symbols, &ctx.reporter()) + let ctx = test_reporter(); + let err = narrow_discriminator_properties(&mut symbols, &ctx) .expect_err("integer discriminator must reject"); assert_eq!(err.subcode, Some("discriminator-property-must-be-string")); } @@ -444,8 +404,8 @@ mod tests { ), symbol("Pet", pet_union(vec!["Cat"])), ]; - let mut ctx = test_ctx(); - let err = narrow_discriminator_properties(&mut symbols, &ctx.reporter()) + let ctx = test_reporter(); + let err = narrow_discriminator_properties(&mut symbols, &ctx) .expect_err("nullable string discriminator must reject"); assert_eq!(err.subcode, Some("discriminator-property-must-be-string")); } @@ -469,7 +429,7 @@ mod tests { ), symbol("Pet", pet_union(vec!["Cat"])), ]; - let mut ctx = test_ctx(); - narrow_discriminator_properties(&mut symbols, &ctx.reporter()).expect("ok"); + let ctx = test_reporter(); + narrow_discriminator_properties(&mut symbols, &ctx).expect("ok"); } } diff --git a/src/ir/normalize/tests.rs b/src/ir/normalize/tests.rs index 181d692..40e5462 100644 --- a/src/ir/normalize/tests.rs +++ b/src/ir/normalize/tests.rs @@ -15,7 +15,7 @@ use crate::ir::canonical::{ use crate::ir::normalize::normalize_document; use crate::ir::normalize::semantic; use crate::ir::schema::SchemaType; -use crate::test_support::{TestReporter, test_ctx}; +use crate::test_support::{reporter_for, test_reporter}; fn parse_fixture(source: &str) -> Value { serde_yml::from_str(source).expect("fixture parses as YAML") @@ -33,8 +33,8 @@ fn normalize_lowers_oneof_anyof_and_collapses_single_entry_composition() { let document = parse_fixture(include_str!( "../../../test/fixtures/oneof-anyof-composition.openapi.yaml" )); - let mut sink = TestReporter::new("test/fixtures/oneof-anyof-composition.openapi.yaml"); - let normalized = normalize_document(&document, &mut sink.reporter()) + let sink = reporter_for("test/fixtures/oneof-anyof-composition.openapi.yaml"); + let normalized = normalize_document(&document, &sink) .expect("normalize succeeds for supported oneOf/anyOf fixture"); let pet_union = find_symbol(&normalized.schemas, "PetUnion"); @@ -62,8 +62,8 @@ fn normalize_lowers_oneof_anyof_and_collapses_single_entry_composition() { let single_entry = parse_fixture(include_str!( "../../../test/fixtures/single-entry-composition.openapi.yaml" )); - let mut sink = TestReporter::new("test/fixtures/single-entry-composition.openapi.yaml"); - let normalized_single = normalize_document(&single_entry, &mut sink.reporter()) + let sink = reporter_for("test/fixtures/single-entry-composition.openapi.yaml"); + let normalized_single = normalize_document(&single_entry, &sink) .expect("normalize succeeds for single-entry composition fixture"); let animal_view = find_symbol(&normalized_single.schemas, "AnimalView"); @@ -78,9 +78,9 @@ fn normalize_supports_inline_object_allof_members_and_preserves_additional_prope let document = parse_fixture(include_str!( "../../../test/fixtures/allof-composition.openapi.yaml" )); - let mut sink = TestReporter::new("test/fixtures/allof-composition.openapi.yaml"); - let normalized = normalize_document(&document, &mut sink.reporter()) - .expect("normalize succeeds for supported allOf fixture"); + let sink = reporter_for("test/fixtures/allof-composition.openapi.yaml"); + let normalized = + normalize_document(&document, &sink).expect("normalize succeeds for supported allOf fixture"); let adopter_profile = find_symbol(&normalized.schemas, "AdopterProfile"); match &adopter_profile.body { @@ -96,8 +96,8 @@ fn normalize_supports_inline_object_allof_members_and_preserves_additional_prope let unsupported = parse_fixture(include_str!( "../../../test/fixtures/unsupported-semantic.openapi.yaml" )); - let mut sink = TestReporter::new("test/fixtures/unsupported-semantic.openapi.yaml"); - let error = normalize_document(&unsupported, &mut sink.reporter()) + let sink = reporter_for("test/fixtures/unsupported-semantic.openapi.yaml"); + let error = normalize_document(&unsupported, &sink) .expect_err("unsupported fixture should fail at additionalProperties boundary"); assert_eq!(error.code, DiagnosticCode::UnsupportedSemantic); @@ -110,9 +110,9 @@ fn normalize_supports_inline_object_model_shapes_outside_allof() { let document = parse_fixture(include_str!( "../../../test/fixtures/inline-model.openapi.yaml" )); - let mut sink = TestReporter::new("test/fixtures/inline-model.openapi.yaml"); - let normalized = normalize_document(&document, &mut sink.reporter()) - .expect("normalize succeeds for inline model fixture"); + let sink = reporter_for("test/fixtures/inline-model.openapi.yaml"); + let normalized = + normalize_document(&document, &sink).expect("normalize succeeds for inline model fixture"); let pet_profile = find_symbol(&normalized.schemas, "PetProfile"); @@ -169,8 +169,8 @@ fn normalize_supports_typed_additional_properties_for_nested_and_named_object_ma let document = parse_fixture(include_str!( "../../../test/fixtures/additional-properties.openapi.yaml" )); - let mut sink = TestReporter::new("test/fixtures/additional-properties.openapi.yaml"); - let normalized = normalize_document(&document, &mut sink.reporter()) + let sink = reporter_for("test/fixtures/additional-properties.openapi.yaml"); + let normalized = normalize_document(&document, &sink) .expect("normalize succeeds for typed additionalProperties fixture"); let pet_catalog = find_symbol(&normalized.schemas, "PetCatalog"); @@ -243,9 +243,9 @@ components: "#, ); - let mut sink = TestReporter::new("test/fixtures/required-fields.yaml"); - let normalized = normalize_document(&document, &mut sink.reporter()) - .expect("normalize succeeds for required fields fixture"); + let sink = reporter_for("test/fixtures/required-fields.yaml"); + let normalized = + normalize_document(&document, &sink).expect("normalize succeeds for required fields fixture"); let schema = find_symbol(&normalized.schemas, "RequiredExample"); @@ -268,8 +268,8 @@ fn normalize_rejects_non_string_enums_and_invalid_enum_values() { let non_string_enum = parse_fixture(include_str!( "../../../test/fixtures/invalid-enum-type.openapi.yaml" )); - let mut sink = TestReporter::new("test/fixtures/invalid-enum-type.openapi.yaml"); - let non_string_error = normalize_document(&non_string_enum, &mut sink.reporter()) + let sink = reporter_for("test/fixtures/invalid-enum-type.openapi.yaml"); + let non_string_error = normalize_document(&non_string_enum, &sink) .expect_err("non-string enum should fail normalization"); assert_eq!(non_string_error.code, DiagnosticCode::UnsupportedSemantic); @@ -280,8 +280,8 @@ fn normalize_rejects_non_string_enums_and_invalid_enum_values() { "../../../test/fixtures/invalid-enum-value.openapi.json" )) .expect("fixture parses as JSON"); - let mut sink = TestReporter::new("test/fixtures/invalid-enum-value.openapi.json"); - let invalid_value_error = normalize_document(&invalid_enum_value, &mut sink.reporter()) + let sink = reporter_for("test/fixtures/invalid-enum-value.openapi.json"); + let invalid_value_error = normalize_document(&invalid_enum_value, &sink) .expect_err("enum value with null byte should fail normalization"); assert_eq!( @@ -297,8 +297,8 @@ fn normalize_rejects_empty_schema_parameters_outside_model_generation_scope() { let document = parse_fixture(include_str!( "../../../test/fixtures/empty-parameter.openapi.yaml" )); - let mut sink = TestReporter::new("test/fixtures/empty-parameter.openapi.yaml"); - let error = normalize_document(&document, &mut sink.reporter()) + let sink = reporter_for("test/fixtures/empty-parameter.openapi.yaml"); + let error = normalize_document(&document, &sink) .expect_err("empty parameter schema should fail normalization"); assert_eq!(error.code, DiagnosticCode::UnsupportedSemantic); @@ -321,8 +321,8 @@ components: "#, ); - let mut sink = TestReporter::new("test/fixtures/empty-ref-target.yaml"); - let error = normalize_document(&document, &mut sink.reporter()) + let sink = reporter_for("test/fixtures/empty-ref-target.yaml"); + let error = normalize_document(&document, &sink) .expect_err("$ref with empty target name should fail normalization"); assert_eq!(error.code, DiagnosticCode::UnsupportedSemantic); @@ -335,9 +335,9 @@ fn normalize_rejects_trace_operations_with_specific_diagnostic() { let document = parse_fixture(include_str!( "../../../test/fixtures/unsupported-trace.openapi.yaml" )); - let mut sink = TestReporter::new("test/fixtures/unsupported-trace.openapi.yaml"); - let error = normalize_document(&document, &mut sink.reporter()) - .expect_err("trace operations should fail normalization"); + let sink = reporter_for("test/fixtures/unsupported-trace.openapi.yaml"); + let error = + normalize_document(&document, &sink).expect_err("trace operations should fail normalization"); assert_eq!(error.code, DiagnosticCode::UnsupportedSemantic); assert!(error.message.contains("TRACE")); @@ -352,8 +352,8 @@ fn normalize_rejects_paths_with_unbalanced_braces() { let document = parse_fixture(include_str!( "../../../test/fixtures/unbalanced-path-template.openapi.yaml" )); - let mut sink = TestReporter::new("test/fixtures/unbalanced-path-template.openapi.yaml"); - let error = normalize_document(&document, &mut sink.reporter()) + let sink = reporter_for("test/fixtures/unbalanced-path-template.openapi.yaml"); + let error = normalize_document(&document, &sink) .expect_err("unbalanced path template should fail normalization"); assert_eq!(error.code, DiagnosticCode::UnsupportedSemantic); @@ -361,8 +361,6 @@ fn normalize_rejects_paths_with_unbalanced_braces() { assert!(error.message.contains("/pets/{id")); } -// ── semantic finalize (discriminator narrowing + ref validation) ───────── - #[test] fn semantic_finalize_lowers_operations_with_inputs_body_and_response() { let mut model = ApiModel { @@ -419,8 +417,8 @@ fn semantic_finalize_lowers_operations_with_inputs_body_and_response() { }], }; - let mut ctx = test_ctx(); - semantic::finalize(&mut model, &ctx.reporter()).expect("semantic finalize succeeds"); + let ctx = test_reporter(); + semantic::finalize(&mut model, &ctx).expect("semantic finalize succeeds"); let operation = model.operations.first().expect("operation lowered"); assert_eq!(operation.operation_id, "createExample"); @@ -471,8 +469,8 @@ fn semantic_finalize_rejects_unresolved_schema_reference() { operations: Vec::new(), }; - let mut ctx = test_ctx(); - let err = semantic::finalize(&mut model, &ctx.reporter()).expect_err("unresolved ref must error"); + let ctx = test_reporter(); + let err = semantic::finalize(&mut model, &ctx).expect_err("unresolved ref must error"); assert_eq!(err.code, crate::error::DiagnosticCode::InvalidReference); assert!(err.message.contains("Missing")); } diff --git a/src/ir/normalize/walk.rs b/src/ir/normalize/walk.rs new file mode 100644 index 0000000..36508c0 --- /dev/null +++ b/src/ir/normalize/walk.rs @@ -0,0 +1,94 @@ +//! Position of a schema walk: breadcrumb, nesting depth and diagnostic +//! sink, carried as one value. + +use crate::error::{Context, Diagnostic, Reporter}; + +use super::{MAX_NORMALIZE_DEPTH, unsupported_rule}; + +/// One position in a schema tree. +/// +/// Every constructor except [`SchemaWalk::root`] descends exactly one +/// level. Call [`SchemaWalk::check_depth`] before recursing. +#[derive(Clone, Copy)] +pub(crate) struct SchemaWalk<'a> { + context: Context<'a>, + depth: u16, + reporter: &'a Reporter, +} + +impl<'a> SchemaWalk<'a> { + /// Starts a walk at a top-level schema, parameter, request body or + /// response. + pub(crate) const fn root(context: Context<'a>, reporter: &'a Reporter) -> Self { + Self { + context, + depth: 0, + reporter, + } + } + + /// Descends into the named object property. + pub(crate) const fn property<'s>(&'s self, name: &'s str) -> SchemaWalk<'s> { + self.descend(Context::Property { + parent: &self.context, + name, + }) + } + + /// Descends into the 1-based member of a `oneOf` / `anyOf` / `allOf`. + pub(crate) const fn composition_member<'s>(&'s self, index: usize) -> SchemaWalk<'s> { + self.descend(Context::CompositionMember { + parent: &self.context, + index, + }) + } + + /// Descends into the `additionalProperties` sub-schema. + pub(crate) const fn additional_properties<'s>(&'s self) -> SchemaWalk<'s> { + self.descend(Context::AdditionalProperties { + parent: &self.context, + }) + } + + /// Descends into an array's item schema, which shares the array's + /// breadcrumb. + pub(crate) const fn item(&self) -> Self { + Self { + context: self.context, + depth: self.depth + 1, + reporter: self.reporter, + } + } + + const fn descend<'s>(&'s self, context: Context<'s>) -> SchemaWalk<'s> { + SchemaWalk { + context, + depth: self.depth + 1, + reporter: self.reporter, + } + } + + /// The breadcrumb for this position, for use in a diagnostic message. + /// Allocates, so call it only while building one. + pub(crate) fn here(&self) -> String { + self.context.render() + } + + pub(crate) const fn reporter(&self) -> &'a Reporter { + self.reporter + } + + /// Fails once the walk has descended past [`MAX_NORMALIZE_DEPTH`]. + pub(crate) fn check_depth(&self) -> Result<(), Diagnostic> { + if self.depth < MAX_NORMALIZE_DEPTH { + return Ok(()); + } + Err(unsupported_rule( + self.reporter, + format!( + "{} nesting exceeds {MAX_NORMALIZE_DEPTH} levels (likely cyclic or pathological spec).", + self.here() + ), + )) + } +} diff --git a/src/ir/tests.rs b/src/ir/tests.rs index 15330c1..0ac82c6 100644 --- a/src/ir/tests.rs +++ b/src/ir/tests.rs @@ -8,11 +8,11 @@ use serde_json::Value; -use crate::emit::typescript::render_type_reference; +use crate::emit::ts::types::render_to_string; use crate::ir::canonical::ModelSymbol; use crate::ir::normalize::normalize_document; use crate::ir::schema::SchemaType; -use crate::test_support::TestReporter; +use crate::test_support::reporter_for; fn parse_fixture(source: &str) -> Value { serde_yml::from_str(source).expect("fixture parses as YAML") @@ -30,13 +30,13 @@ fn normalize_supports_empty_schema_any_type_and_empty_object_shapes() { let document = parse_fixture(include_str!( "../../test/fixtures/empty-shapes.openapi.yaml" )); - let mut sink = TestReporter::new("test/fixtures/empty-shapes.openapi.yaml"); - let ir = normalize_document(&document, &mut sink.reporter()) - .expect("normalize succeeds for empty schema fixture"); + let sink = reporter_for("test/fixtures/empty-shapes.openapi.yaml"); + let ir = + normalize_document(&document, &sink).expect("normalize succeeds for empty schema fixture"); let any_value = find_symbol(&ir.schemas, "AnyValue"); assert!(!matches!(&any_value.body, SchemaType::Ref(_))); - assert_eq!(render_type_reference(&any_value.body), "unknown"); + assert_eq!(render_to_string(&any_value.body), "unknown"); for schema_name in ["EmptyObject", "EmptyObjectWithProperties"] { let empty_object = find_symbol(&ir.schemas, schema_name); @@ -61,7 +61,7 @@ fn normalize_supports_empty_schema_any_type_and_empty_object_shapes() { .iter() .find(|property| property.name.as_ref() == "anything") .expect("anything property exists"); - assert_eq!(render_type_reference(&anything.ty), "unknown"); + assert_eq!(render_to_string(&anything.ty), "unknown"); let empty_inline = properties .iter() @@ -89,7 +89,7 @@ fn normalize_supports_empty_schema_any_type_and_empty_object_shapes() { match &empty_array.ty { SchemaType::Array(items) => { - assert_eq!(render_type_reference(&empty_array.ty), "unknown[]"); + assert_eq!(render_to_string(&empty_array.ty), "unknown[]"); assert!(!matches!(items.as_ref(), SchemaType::Ref(_))); } other => panic!("expected array, got {other:?}"), @@ -97,10 +97,7 @@ fn normalize_supports_empty_schema_any_type_and_empty_object_shapes() { match &empty_map.ty { SchemaType::Map(values) => { - assert_eq!( - render_type_reference(&empty_map.ty), - "Record" - ); + assert_eq!(render_to_string(&empty_map.ty), "Record"); assert!(!matches!(values.as_ref(), SchemaType::Ref(_))); } other => panic!("expected map, got {other:?}"), @@ -112,9 +109,8 @@ fn ir_renders_union_and_intersection_type_fragments_from_normalized_composition( let oneof_document = parse_fixture(include_str!( "../../test/fixtures/oneof-anyof-composition.openapi.yaml" )); - let mut sink = TestReporter::new("test/fixtures/oneof-anyof-composition.openapi.yaml"); - let oneof_ir = - normalize_document(&oneof_document, &mut sink.reporter()).expect("normalize succeeds"); + let sink = reporter_for("test/fixtures/oneof-anyof-composition.openapi.yaml"); + let oneof_ir = normalize_document(&oneof_document, &sink).expect("normalize succeeds"); let pet_union = oneof_ir .schemas @@ -127,14 +123,13 @@ fn ir_renders_union_and_intersection_type_fragments_from_normalized_composition( } }) .expect("PetUnion alias exists in IR"); - assert_eq!(render_type_reference(pet_union), "Cat | Dog"); + assert_eq!(render_to_string(pet_union), "Cat | Dog"); let allof_document = parse_fixture(include_str!( "../../test/fixtures/allof-composition.openapi.yaml" )); - let mut sink = TestReporter::new("test/fixtures/allof-composition.openapi.yaml"); - let allof_ir = - normalize_document(&allof_document, &mut sink.reporter()).expect("normalize succeeds"); + let sink = reporter_for("test/fixtures/allof-composition.openapi.yaml"); + let allof_ir = normalize_document(&allof_document, &sink).expect("normalize succeeds"); let adopter_profile = allof_ir .schemas @@ -151,7 +146,7 @@ fn ir_renders_union_and_intersection_type_fragments_from_normalized_composition( match adopter_profile { SchemaType::Intersection(members) => { assert_eq!(members.len(), 3); - let rendered = render_type_reference(adopter_profile); + let rendered = render_to_string(adopter_profile); assert!(rendered.contains("AuditFields & ContactFields & {")); assert!(rendered.contains("nickname?: string | null;")); } @@ -161,10 +156,9 @@ fn ir_renders_union_and_intersection_type_fragments_from_normalized_composition( let additional_properties_document = parse_fixture(include_str!( "../../test/fixtures/additional-properties.openapi.yaml" )); - let mut sink = TestReporter::new("test/fixtures/additional-properties.openapi.yaml"); + let sink = reporter_for("test/fixtures/additional-properties.openapi.yaml"); let additional_properties_ir = - normalize_document(&additional_properties_document, &mut sink.reporter()) - .expect("normalize succeeds"); + normalize_document(&additional_properties_document, &sink).expect("normalize succeeds"); let pet_catalog_pets_by_breed = additional_properties_ir .schemas @@ -178,7 +172,7 @@ fn ir_renders_union_and_intersection_type_fragments_from_normalized_composition( }) .expect("PetCatalog.petsByBreed exists in IR"); assert_eq!( - render_type_reference(pet_catalog_pets_by_breed), + render_to_string(pet_catalog_pets_by_breed), "Record" ); @@ -194,7 +188,7 @@ fn ir_renders_union_and_intersection_type_fragments_from_normalized_composition( }) .expect("PetCatalog.scope exists in IR"); assert_eq!( - render_type_reference(pet_catalog_scope), + render_to_string(pet_catalog_scope), "'available' | 'adopted' | 'foster'" ); @@ -210,7 +204,7 @@ fn ir_renders_union_and_intersection_type_fragments_from_normalized_composition( }) .expect("PetMetadataByTag alias exists in IR"); assert_eq!( - render_type_reference(pet_metadata_by_tag), + render_to_string(pet_metadata_by_tag), "Record" ); } diff --git a/src/lib.rs b/src/lib.rs index 82cc288..e76ed17 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,6 +3,7 @@ mod bindings; mod emit; mod error; +mod ident; mod io; mod ir; mod options; diff --git a/src/options.rs b/src/options.rs index 34150d8..299cb4d 100644 --- a/src/options.rs +++ b/src/options.rs @@ -4,15 +4,15 @@ use napi_derive::napi; use crate::{ bindings::{EmitTarget, InputFormat, NamingOptions}, - error::{Diagnostic, DiagnosticCode, Reporter}, + error::{Diagnostic, DiagnosticCode, Reporter, bail}, + ident::is_ident, }; -/// Canonical mapped-type record. Used as user input (from CLI/JS options) -/// and as the planning record (after schema-name validation). +/// One caller-declared mapped type: replace the generated declaration for +/// `schema` with `ty` imported from `import`. /// -/// Field names match the CLI YAML config vocabulary (schema/import/type/ -/// alias). `ty` is the Rust-side name; the NAPI surface renames it to -/// `type` so the JS API stays idiomatic. +/// Field names match the config vocabulary. `ty` crosses the NAPI +/// boundary as `type`. #[napi(object)] #[derive(Clone, Debug, PartialEq, Eq)] pub struct MappedType { @@ -23,12 +23,10 @@ pub struct MappedType { pub alias: Option, } -/// User mapping: override the response-kind decoded for a specific -/// response content-type. Pure data — Phase-3 normalize-side reads -/// this when picking the `responseKind` for an operation's response -/// content. Keys are matched case-insensitively against the lowercased -/// media-type from the spec; the `responseType` is one of the JS-facing -/// HttpClient response kinds (`'json' | 'blob' | 'text' | 'arrayBuffer'`). +/// Overrides the response kind decoded for one content type. +/// +/// `content_type` is matched case-insensitively against the media type +/// the spec declares. #[napi(object)] #[derive(Clone, Debug, PartialEq, Eq)] pub struct ResponseTypeMapping { @@ -36,11 +34,7 @@ pub struct ResponseTypeMapping { pub response_type: ResponseType, } -/// JS-facing response-kind values. Mirrors the names Angular's -/// `HttpClient.request({ responseType })` and `httpResource.()` -/// expose, so the config vocabulary stays in JS conventions. The emit -/// boundary translates `ArrayBuffer` to the lowercase `'arraybuffer'` -/// string `HttpClient.request` requires. +/// How a response body is decoded, named as the JS runtime names it. #[napi(string_enum = "camelCase")] #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ResponseType { @@ -50,9 +44,7 @@ pub enum ResponseType { ArrayBuffer, } -/// Resolved generation config. The `emit` set replaces three booleans: -/// callers (and the validator/pipeline) read membership with -/// `emit.contains(&EmitTarget::Models)`. +/// A generation request after validation. #[derive(Clone, Debug)] pub struct GenerateConfig { /// Set when the caller passed `input_path`; mutually exclusive with @@ -71,9 +63,8 @@ pub struct GenerateConfig { pub(crate) fn validate_generate_config( config: &mut GenerateConfig, - reporter: &mut Reporter<'_>, + reporter: &Reporter, ) -> Result<(), Diagnostic> { - // Exactly one of inputPath / inputContents must be set. match (config.input_path.is_some(), config.input_contents.is_some()) { (true, true) | (false, false) => { return Err(reporter.error( @@ -100,11 +91,9 @@ pub(crate) fn validate_generate_config( validate_emit_targets(&mut config.emit, reporter)?; validate_mapped_types(&config.mapped_types, reporter)?; validate_response_type_mapping(&config.response_type_mapping, reporter)?; - config.naming = resolve_naming_options(config.naming_options.take(), reporter)?; + config.naming = crate::plan::naming::lower(config.naming_options.take(), reporter)?; - // `output_path` is either omitted (in-memory only) or a real path. An empty - // string is never a valid path — reject it outright instead of silently - // coercing to in-memory. + // Omitted means in-memory; an empty string is neither. if matches!(config.output_path.as_deref(), Some("")) { return Err(reporter.error( DiagnosticCode::InvalidOption, @@ -116,7 +105,7 @@ pub(crate) fn validate_generate_config( fn validate_emit_targets( emit: &mut BTreeSet, - reporter: &mut Reporter<'_>, + reporter: &Reporter, ) -> Result<(), Diagnostic> { if emit.is_empty() { return Err(reporter.error( @@ -124,8 +113,7 @@ fn validate_emit_targets( "emit must include at least one target ('models' or 'angular').", )); } - // Angular services reference the generated model types. Auto-include - // `models` and warn rather than rejecting the caller's emit set. + // Angular services import the generated model types. if emit.contains(&EmitTarget::Angular) && !emit.contains(&EmitTarget::Models) { emit.insert(EmitTarget::Models); reporter.warning( @@ -139,7 +127,7 @@ fn validate_emit_targets( fn validate_mapped_types( mapped_types: &[MappedType], - reporter: &Reporter<'_>, + reporter: &Reporter, ) -> Result<(), Diagnostic> { let mut seen = std::collections::BTreeSet::<&str>::new(); for mapped_type in mapped_types { @@ -153,35 +141,32 @@ fn validate_mapped_types( )); } - if !is_valid_ts_identifier(&mapped_type.ty) { - return Err(reporter.error( + if !is_ident(&mapped_type.ty) { + bail!( + reporter, DiagnosticCode::InvalidOption, - format!( - "Failed to resolve generation options: mapped type type '{}' is not a valid TypeScript identifier (expected /^[A-Za-z_$][A-Za-z0-9_$]*$/).", - mapped_type.ty, - ), - )); + "Failed to resolve generation options: mapped type type '{}' is not a valid TypeScript identifier (expected /^[A-Za-z_$][A-Za-z0-9_$]*$/).", + mapped_type.ty, + ); } if let Some(alias) = mapped_type.alias.as_deref() - && !is_valid_ts_identifier(alias) + && !is_ident(alias) { - return Err(reporter.error( + bail!( + reporter, DiagnosticCode::InvalidOption, - format!( - "Failed to resolve generation options: mapped type alias '{alias}' is not a valid TypeScript identifier." - ), - )); + "Failed to resolve generation options: mapped type alias '{alias}' is not a valid TypeScript identifier." + ); } if !seen.insert(mapped_type.schema.as_str()) { - return Err(reporter.error( + bail!( + reporter, DiagnosticCode::InvalidOption, - format!( - "Failed to resolve generation options: mapped type schema '{}' is duplicated; each schema must appear at most once.", - mapped_type.schema, - ), - )); + "Failed to resolve generation options: mapped type schema '{}' is duplicated; each schema must appear at most once.", + mapped_type.schema, + ); } } @@ -190,7 +175,7 @@ fn validate_mapped_types( fn validate_response_type_mapping( mappings: &[ResponseTypeMapping], - reporter: &Reporter<'_>, + reporter: &Reporter, ) -> Result<(), Diagnostic> { let mut seen = std::collections::BTreeSet::::new(); for m in mappings { @@ -202,149 +187,30 @@ fn validate_response_type_mapping( )); } if !lc.contains('/') { - return Err(reporter.error( + bail!( + reporter, DiagnosticCode::InvalidOption, - format!("responseTypeMapping.contentType {lc:?} must contain '/'."), - )); + "responseTypeMapping.contentType {lc:?} must contain '/'." + ); } if !seen.insert(lc.clone()) { - return Err(reporter.error( + bail!( + reporter, DiagnosticCode::InvalidOption, - format!("responseTypeMapping has duplicate contentType {lc:?} (case-insensitive)."), - )); + "responseTypeMapping has duplicate contentType {lc:?} (case-insensitive)." + ); } } Ok(()) } -fn is_valid_ts_identifier(value: &str) -> bool { - let mut chars = value.chars(); - let Some(first) = chars.next() else { - return false; - }; - if !(first.is_ascii_alphabetic() || first == '_' || first == '$') { - return false; - } - chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$') -} - -pub(crate) fn resolve_naming_options( - options: Option, - reporter: &Reporter<'_>, -) -> Result { - use crate::plan::naming::{Case, Naming, NamingConfig, Rule, RuleEntry, compile_parse_spec}; - - let Some(opts) = options else { - return Ok(NamingConfig::default()); - }; - - fn lower_entry( - string: Option, - rule: Option, - reporter: &Reporter<'_>, - path: &str, - ) -> Result { - match (string, rule) { - (Some(s), None) => Ok(RuleEntry::Shorthand(s)), - (None, Some(r)) => { - let case = match r.case_.as_deref() { - None => None, - Some(s) => Some(Case::parse(s).ok_or_else(|| { - reporter.error( - DiagnosticCode::InvalidOption, - format!( - "naming.{path}.case: '{s}' is not one of 'camel', 'pascal', 'snake', 'kebab', 'constant'.", - ), - ) - })?), - }; - let parse = r - .parse - .map(|spec| { - compile_parse_spec(&spec.source, &spec.flags).map_err(|err| { - reporter.error( - DiagnosticCode::InvalidOption, - format!( - "naming.{path}.parse: failed to compile regex `{}` (flags=`{}`): {:?}", - spec.source, spec.flags, err, - ), - ) - }) - }) - .transpose()?; - if parse.is_some() && r.format.is_none() { - return Err(reporter.error( - DiagnosticCode::InvalidOption, - format!("naming.{path}: when `parse` is present, `format` is required."), - )); - } - Ok(RuleEntry::Rule(Rule { - from: r.from, - parse, - format: r.format, - case, - })) - } - (Some(_), Some(_)) => Err(reporter.error( - DiagnosticCode::InvalidOption, - format!("naming.{path}: a chain item cannot set both `string` and `rule`."), - )), - (None, None) => Err(reporter.error( - DiagnosticCode::InvalidOption, - format!("naming.{path}: a chain item must set exactly one of `string` or `rule`."), - )), - } - } - - fn lower_value( - value: Option, - reporter: &Reporter<'_>, - key: &str, - ) -> Result, Diagnostic> { - let Some(v) = value else { - return Ok(None); - }; - let count = - u8::from(v.string.is_some()) + u8::from(v.rule.is_some()) + u8::from(v.chain.is_some()); - if count != 1 { - return Err(reporter.error( - DiagnosticCode::InvalidOption, - format!( - "naming.{key}: must set exactly one of `string`, `rule`, or `chain` (got {count})." - ), - )); - } - if let Some(s) = v.string { - return Ok(Some(Naming::Single(RuleEntry::Shorthand(s)))); - } - if let Some(r) = v.rule { - let entry = lower_entry(None, Some(r), reporter, key)?; - return Ok(Some(Naming::Single(entry))); - } - let Some(items) = v.chain else { - unreachable!("count==1 guarantees v.chain is Some when string/rule are None") - }; - let mut entries = Vec::with_capacity(items.len()); - for (i, item) in items.into_iter().enumerate() { - let path = format!("{key}[{i}]"); - entries.push(lower_entry(item.string, item.rule, reporter, &path)?); - } - Ok(Some(Naming::Chain(entries))) - } - - Ok(NamingConfig { - method_name: lower_value(opts.method_name, reporter, "methodName")?, - group: lower_value(opts.group, reporter, "group")?, - }) -} - #[cfg(test)] mod tests { use super::{ GenerateConfig, MappedType, ResponseType, ResponseTypeMapping, validate_generate_config, }; use crate::bindings::EmitTarget; - use crate::test_support::test_ctx; + use crate::test_support::test_reporter; fn config(input_path: &str) -> GenerateConfig { GenerateConfig { @@ -372,14 +238,13 @@ mod tests { #[test] fn validator_accepts_in_memory_default_when_output_path_is_omitted() { - let mut ctx = test_ctx(); + let ctx = test_reporter(); let mut config = GenerateConfig { output_path: None, emit: [EmitTarget::Models].into_iter().collect(), ..config("spec.yaml") }; - validate_generate_config(&mut config, &mut ctx.reporter()) - .expect("generate options should validate"); + validate_generate_config(&mut config, &ctx).expect("generate options should validate"); assert_eq!(config.input_path.as_deref(), Some("spec.yaml")); assert_eq!(config.output_path, None); @@ -390,12 +255,12 @@ mod tests { #[test] fn validator_rejects_empty_string_output_path_as_invalid_option() { - let mut ctx = test_ctx(); + let ctx = test_reporter(); let mut config = GenerateConfig { output_path: Some(String::new()), ..config("spec.yaml") }; - let error = validate_generate_config(&mut config, &mut ctx.reporter()) + let error = validate_generate_config(&mut config, &ctx) .expect_err("empty outputPath should fail during option validation"); assert_eq!(error.code, crate::error::DiagnosticCode::InvalidOption); @@ -404,12 +269,12 @@ mod tests { #[test] fn validator_rejects_empty_emit_set() { - let mut ctx = test_ctx(); + let ctx = test_reporter(); let mut config = GenerateConfig { emit: std::collections::BTreeSet::new(), ..config("spec.yaml") }; - let error = validate_generate_config(&mut config, &mut ctx.reporter()) + let error = validate_generate_config(&mut config, &ctx) .expect_err("empty emit set should fail during option validation"); assert_eq!(error.code, crate::error::DiagnosticCode::InvalidOption); @@ -418,17 +283,17 @@ mod tests { #[test] fn validator_auto_includes_models_when_angular_is_requested_alone() { - let mut warnings = Vec::new(); let path: std::rc::Rc = std::rc::Rc::from("spec.yaml"); - let mut reporter = crate::error::Reporter::new(path, &mut warnings); + let reporter = crate::error::Reporter::new(path); let mut config = GenerateConfig { emit: std::iter::once(EmitTarget::Angular).collect(), ..config("spec.yaml") }; - validate_generate_config(&mut config, &mut reporter) + validate_generate_config(&mut config, &reporter) .expect("auto-include should be a warning, not a fatal"); assert!(config.emit.contains(&EmitTarget::Models)); + let warnings = reporter.into_warnings(); assert_eq!(warnings.len(), 1); assert_eq!( warnings[0].code, @@ -440,23 +305,22 @@ mod tests { #[test] fn validator_emits_no_warning_when_models_already_present() { - let mut warnings = Vec::new(); let path: std::rc::Rc = std::rc::Rc::from("spec.yaml"); - let mut reporter = crate::error::Reporter::new(path, &mut warnings); + let reporter = crate::error::Reporter::new(path); let mut config = GenerateConfig { emit: [EmitTarget::Models, EmitTarget::Angular] .into_iter() .collect(), ..config("spec.yaml") }; - validate_generate_config(&mut config, &mut reporter).expect("explicit models silences warning"); + validate_generate_config(&mut config, &reporter).expect("explicit models silences warning"); - assert!(warnings.is_empty()); + assert!(reporter.into_warnings().is_empty()); } #[test] fn validator_rejects_blank_mapped_type_entries_as_invalid_option() { - let mut ctx = test_ctx(); + let ctx = test_reporter(); let mut config = GenerateConfig { mapped_types: vec![MappedType { schema: "UserId".to_string(), @@ -466,7 +330,7 @@ mod tests { }], ..config("spec.yaml") }; - let error = validate_generate_config(&mut config, &mut ctx.reporter()) + let error = validate_generate_config(&mut config, &ctx) .expect_err("blank mapped type fields should fail during option validation"); assert_eq!(error.code, crate::error::DiagnosticCode::InvalidOption); @@ -475,7 +339,7 @@ mod tests { #[test] fn validator_rejects_naming_chain_item_with_both_string_and_rule() { - let mut ctx = test_ctx(); + let ctx = test_reporter(); let mut config = GenerateConfig { naming_options: Some(crate::bindings::NamingOptions { method_name: Some(crate::bindings::NamingValue { @@ -492,15 +356,15 @@ mod tests { }), ..config("spec.yaml") }; - let error = validate_generate_config(&mut config, &mut ctx.reporter()) - .expect_err("exclusive fields should fail"); + let error = + validate_generate_config(&mut config, &ctx).expect_err("exclusive fields should fail"); assert_eq!(error.code, crate::error::DiagnosticCode::InvalidOption); assert!(error.message.contains("exactly one")); } #[test] fn validator_rejects_parse_without_format() { - let mut ctx = test_ctx(); + let ctx = test_reporter(); let mut config = GenerateConfig { naming_options: Some(crate::bindings::NamingOptions { method_name: Some(crate::bindings::NamingValue { @@ -520,22 +384,22 @@ mod tests { }), ..config("spec.yaml") }; - let error = validate_generate_config(&mut config, &mut ctx.reporter()) - .expect_err("parse without format should fail"); + let error = + validate_generate_config(&mut config, &ctx).expect_err("parse without format should fail"); assert_eq!(error.code, crate::error::DiagnosticCode::InvalidOption); assert!(error.message.contains("`format` is required")); } #[test] fn validator_rejects_both_input_path_and_input_contents_set() { - let mut ctx = test_ctx(); + let ctx = test_reporter(); let mut config = GenerateConfig { input_path: Some("spec.yaml".to_string()), input_contents: Some("openapi: 3.0.3\n".to_string()), display_path: Some("inline".to_string()), ..config("spec.yaml") }; - let error = validate_generate_config(&mut config, &mut ctx.reporter()) + let error = validate_generate_config(&mut config, &ctx) .expect_err("input_path + input_contents must be rejected"); assert_eq!(error.code, crate::error::DiagnosticCode::InvalidOption); @@ -546,13 +410,13 @@ mod tests { #[test] fn validator_rejects_neither_input_path_nor_input_contents_set() { - let mut ctx = test_ctx(); + let ctx = test_reporter(); let mut config = GenerateConfig { input_path: None, input_contents: None, ..config("ignored") }; - let error = validate_generate_config(&mut config, &mut ctx.reporter()) + let error = validate_generate_config(&mut config, &ctx) .expect_err("missing both inputs must be rejected"); assert_eq!(error.code, crate::error::DiagnosticCode::InvalidOption); @@ -561,14 +425,14 @@ mod tests { #[test] fn validator_rejects_input_contents_without_display_path() { - let mut ctx = test_ctx(); + let ctx = test_reporter(); let mut config = GenerateConfig { input_path: None, input_contents: Some("openapi: 3.0.3\n".to_string()), display_path: None, ..config("ignored") }; - let error = validate_generate_config(&mut config, &mut ctx.reporter()) + let error = validate_generate_config(&mut config, &ctx) .expect_err("inputContents without displayPath must be rejected"); assert_eq!(error.code, crate::error::DiagnosticCode::InvalidOption); @@ -578,13 +442,13 @@ mod tests { #[test] fn validator_rejects_input_format_with_input_path() { - let mut ctx = test_ctx(); + let ctx = test_reporter(); let mut config = GenerateConfig { input_path: Some("spec.yaml".to_string()), input_format: Some(crate::bindings::InputFormat::Json), ..config("spec.yaml") }; - let error = validate_generate_config(&mut config, &mut ctx.reporter()) + let error = validate_generate_config(&mut config, &ctx) .expect_err("inputFormat with inputPath must be rejected"); assert_eq!(error.code, crate::error::DiagnosticCode::InvalidOption); @@ -594,19 +458,19 @@ mod tests { #[test] fn rejects_empty_content_type_string() { - let mut ctx = test_ctx(); + let ctx = test_reporter(); let mut config = config_with_mappings(vec![ResponseTypeMapping { content_type: "".into(), response_type: ResponseType::Blob, }]); - let err = validate_generate_config(&mut config, &mut ctx.reporter()) - .expect_err("empty contentType should fail"); + let err = + validate_generate_config(&mut config, &ctx).expect_err("empty contentType should fail"); assert_eq!(err.code, crate::error::DiagnosticCode::InvalidOption); } #[test] fn rejects_duplicate_content_type_after_lowercase_normalisation() { - let mut ctx = test_ctx(); + let ctx = test_reporter(); let mut config = config_with_mappings(vec![ ResponseTypeMapping { content_type: "application/PDF".into(), @@ -617,26 +481,26 @@ mod tests { response_type: ResponseType::ArrayBuffer, }, ]); - let err = validate_generate_config(&mut config, &mut ctx.reporter()) - .expect_err("duplicate contentType should fail"); + let err = + validate_generate_config(&mut config, &ctx).expect_err("duplicate contentType should fail"); assert!(err.message.contains("application/pdf")); } #[test] fn rejects_content_type_without_slash() { - let mut ctx = test_ctx(); + let ctx = test_reporter(); let mut config = config_with_mappings(vec![ResponseTypeMapping { content_type: "notamediatype".into(), response_type: ResponseType::Blob, }]); - let err = validate_generate_config(&mut config, &mut ctx.reporter()) - .expect_err("contentType without '/' should fail"); + let err = + validate_generate_config(&mut config, &ctx).expect_err("contentType without '/' should fail"); assert!(err.message.contains("must contain")); } #[test] fn accepts_well_formed_response_type_mapping() { - let mut ctx = test_ctx(); + let ctx = test_reporter(); let mut config = config_with_mappings(vec![ ResponseTypeMapping { content_type: "application/pdf".into(), @@ -647,6 +511,6 @@ mod tests { response_type: ResponseType::Text, }, ]); - validate_generate_config(&mut config, &mut ctx.reporter()).expect("well-formed mapping passes"); + validate_generate_config(&mut config, &ctx).expect("well-formed mapping passes"); } } diff --git a/src/parse/input.rs b/src/parse/input.rs index db3c095..f39391b 100644 --- a/src/parse/input.rs +++ b/src/parse/input.rs @@ -1,103 +1,20 @@ -use std::{fs, path::Path, rc::Rc, sync::OnceLock}; +use std::{fs, path::Path, rc::Rc}; use crate::{ bindings::InputFormat, error::{Diagnostic, DiagnosticCode}, io::host_cwd::resolve_against_host_cwd, - parse::openapi_model::OpenApiDocument, + parse::{ + limits::{MAX_EXPANSION_RATIO, MAX_INPUT_BYTES}, + openapi_model::OpenApiDocument, + unique_map::DUPLICATE_KEY, + }, }; -const DEFAULT_MAX_INPUT_BYTES: u64 = 16 * 1024 * 1024; -pub(crate) const DEFAULT_MAX_SCHEMAS: usize = 10_000; -pub(crate) const DEFAULT_MAX_OPERATIONS: usize = 10_000; -/// Maximum acceptable ratio of YAML-re-serialised parsed bytes to source -/// bytes. Anchors that fan out 50× or more from source are rejected before -/// the typed parse runs — see `decode_openapi_input`. The default is sized -/// well above any legitimate spec (Swagger Petstore re-serialises near 1×; -/// hand-written specs that lean on anchors stay well under 10×). -pub(crate) const DEFAULT_MAX_EXPANSION_RATIO: usize = 50; - -/// Parse the cap value from an optional env-var string. Returns the default -/// when the argument is `None` or not a valid `u64`. -fn max_input_bytes_from(env: Option<&str>) -> u64 { - env - .and_then(|s| s.parse::().ok()) - .unwrap_or(DEFAULT_MAX_INPUT_BYTES) -} - -/// Process-lifetime cached cap. Reads `OPENAPI_NG_MAX_INPUT_BYTES` exactly -/// once and falls back to `DEFAULT_MAX_INPUT_BYTES` on parse failure or -/// absence. -fn max_input_bytes() -> u64 { - static CACHED: OnceLock = OnceLock::new(); - *CACHED.get_or_init(|| { - max_input_bytes_from(std::env::var("OPENAPI_NG_MAX_INPUT_BYTES").ok().as_deref()) - }) -} - -/// Parse the schemas cap from an optional env-var string. Returns the -/// default when the argument is `None` or not a valid `usize`. Mirrors -/// `max_input_bytes_from` so policy-side cap checks stay testable without -/// touching process env state. -pub(crate) fn max_schemas_from(env: Option<&str>) -> usize { - env - .and_then(|s| s.parse::().ok()) - .unwrap_or(DEFAULT_MAX_SCHEMAS) -} - -/// Parse the operations cap from an optional env-var string. Returns the -/// default when the argument is `None` or not a valid `usize`. -pub(crate) fn max_operations_from(env: Option<&str>) -> usize { - env - .and_then(|s| s.parse::().ok()) - .unwrap_or(DEFAULT_MAX_OPERATIONS) -} - -/// Process-lifetime cached schemas cap. Reads `OPENAPI_NG_MAX_SCHEMAS` once -/// per process and falls back to `DEFAULT_MAX_SCHEMAS` on parse failure or -/// absence. -pub(crate) fn max_schemas() -> usize { - static CACHED: OnceLock = OnceLock::new(); - *CACHED.get_or_init(|| max_schemas_from(std::env::var("OPENAPI_NG_MAX_SCHEMAS").ok().as_deref())) -} - -/// Process-lifetime cached operations cap. Reads `OPENAPI_NG_MAX_OPERATIONS` -/// once per process and falls back to `DEFAULT_MAX_OPERATIONS` on parse -/// failure or absence. -pub(crate) fn max_operations() -> usize { - static CACHED: OnceLock = OnceLock::new(); - *CACHED - .get_or_init(|| max_operations_from(std::env::var("OPENAPI_NG_MAX_OPERATIONS").ok().as_deref())) -} - -/// Parse the expansion-ratio cap from an optional env-var string. Returns the -/// default when the argument is `None` or not a valid `usize`. Mirrors the -/// other cap helpers so the expansion guard stays testable without touching -/// process env state. -pub(crate) fn max_expansion_ratio_from(env: Option<&str>) -> usize { - env - .and_then(|s| s.parse::().ok()) - .unwrap_or(DEFAULT_MAX_EXPANSION_RATIO) -} - -/// Process-lifetime cached expansion-ratio cap. Reads -/// `OPENAPI_NG_MAX_EXPANSION_RATIO` once per process and falls back to -/// `DEFAULT_MAX_EXPANSION_RATIO` on parse failure or absence. -pub(crate) fn max_expansion_ratio() -> usize { - static CACHED: OnceLock = OnceLock::new(); - *CACHED.get_or_init(|| { - max_expansion_ratio_from( - std::env::var("OPENAPI_NG_MAX_EXPANSION_RATIO") - .ok() - .as_deref(), - ) - }) -} - -/// Read the input file and decode it into a typed `OpenApiDocument`. The -/// display path is owned by the pipeline boundary (`execute_generate`) and -/// passed in so every diagnostic — read, decode, normalize, plan, write — -/// carries the exact same `Rc` without re-deriving it at each layer. +/// Reads and decodes the file at `input_path`, failing when it exceeds +/// the input byte cap. +/// +/// Every diagnostic raised carries `display_path`. pub(crate) fn read_and_decode( input_path: &str, display_path: &Rc, @@ -111,7 +28,7 @@ pub(crate) fn read_and_decode( Rc::clone(display_path), ) })?; - let max_bytes = max_input_bytes(); + let max_bytes = MAX_INPUT_BYTES.get(); if metadata.len() > max_bytes { return Err(Diagnostic::new( DiagnosticCode::InputInvalid, @@ -143,24 +60,18 @@ pub(crate) fn decode_openapi_input( decode_openapi_input_with_hint(path, source, display_path, None) } -/// Entry point for the `inputContents` branch. Enforces the byte cap on -/// the supplied source (the 16 MiB default that `read_and_decode` enforces -/// for file inputs via `fs::metadata().len()` — without this check a -/// caller who bypasses the JS-side fetch cap could pass an arbitrarily -/// large string), then delegates to the hint-aware decoder. +/// Decodes a spec supplied as source text, failing when it exceeds the +/// input byte cap. /// -/// The synthetic `Path::new("")` is fine: when `hint` is `Some`, the -/// decoder skips extension lookup entirely; when `hint` is `None`, the -/// extension is `None` and the decoder falls through to the -/// sniff-both-parsers branch (which is the desired behaviour for -/// hint-less inputContents anyway). +/// Without a `hint` the format is sniffed, since there is no file +/// extension to dispatch on. pub(crate) fn decode_input_contents( source: &str, hint: Option, display_path: &Rc, ) -> Result { let len_bytes = source.len(); - let max_bytes = max_input_bytes(); + let max_bytes = MAX_INPUT_BYTES.get(); if (len_bytes as u64) > max_bytes { return Err(Diagnostic::new( DiagnosticCode::InputInvalid, @@ -180,7 +91,6 @@ pub(crate) fn decode_openapi_input_with_hint( display_path: &Rc, hint: Option, ) -> Result { - // Explicit hint wins over extension/sniff. if let Some(format) = hint { return match format { InputFormat::Json => serde_json::from_str(source).map_err(|error| { @@ -194,18 +104,13 @@ pub(crate) fn decode_openapi_input_with_hint( }; } - // No hint: extension-based dispatch (unchanged behaviour). let extension = path .extension() .and_then(|ext| ext.to_str()) .map(str::to_ascii_lowercase); - // Both `serde_json::Error` and `serde_yml::Error` already include the - // source position ("at line X column Y") in their `Display` impls, so we - // forward the raw error text verbatim — adding our own `(line X, column Y)` - // prefix would just duplicate what serde already prints. If we ever switch - // to a parser that omits position info, lift `err.line()/err.column()` - // (serde_json) or `err.location()` (serde_yml) into the message here. + // Both decoders' `Display` already ends in "at line X column Y", which + // every message below forwards verbatim. match extension.as_deref() { Some("json") => serde_json::from_str(source).map_err(|error| { Diagnostic::new( @@ -230,115 +135,78 @@ pub(crate) fn decode_openapi_input_with_hint( } } -/// Decode a YAML source into an `OpenApiDocument`, applying the duplicate-key -/// and anchor-fanout guards. Sequencing rationale, post-T4.1: -/// -/// 1. **Value parse always runs.** It is required by both behavioural -/// guarantees: the typed `BTreeMap` deserialiser silently last-wins on -/// duplicate keys, so we need the Value-side "duplicate entry" error to -/// surface the `duplicate-schema-name` diagnostic; and the expansion -/// guard from T3.4 needs the parsed Value to measure post-decode size. -/// The duplicate-key fixture itself has no `&`, so we cannot gate the -/// Value parse on anchor presence without regressing that diagnostic. +/// Decodes YAML into an `OpenApiDocument`. /// -/// 2. **`to_string` re-serialisation is gated on `source.contains('&')`.** -/// That is the genuinely expensive part of T3.4's expansion guard — for -/// a document with no anchors the re-serialised output is bytewise -/// close to the source and the guard is structurally unreachable. -/// Skipping the re-serialisation eliminates the bulk of the T3.4 cost -/// on every anchor-free spec (the common case) without weakening -/// defense on the anchor path. `&` may appear inside string literals; -/// the false-positive is harmless (we just pay the re-serialisation -/// once on a spec that has no real anchors). +/// Repeated mapping keys are rejected by the model's `UniqueMap` / +/// `UniqueIndexMap` fields during this single typed parse; a repeat under +/// `components.schemas` is reported with the `duplicate-schema-name` +/// subcode, and every other position keeps the decode error verbatim +/// (serde already prints the field path and the source line and column). /// -/// 3. **Typed parse runs last**, on the original source (serde decodes from -/// `&str`, not from a `Value`), and its error carries the field-path -/// context users expect. -/// -/// The T4.1 plan called for "typed-first, Value-fallback only on error", -/// but that ordering pre-dated T3.4 and breaks both the duplicate-key -/// detection (typed never fails on duplicates) and the expansion guard -/// (needs the Value). The `&`-gated re-serialisation is the cleanest -/// reconciliation: the Value parse stays cheap, the re-serialisation is -/// elided on the no-anchor common case. +/// The anchor-expansion guard runs only when the source contains `&`, +/// without which no alias can expand. fn decode_yaml(source: &str, display_path: &Rc) -> Result { - // Step 1: Value parse — catches duplicate mapping keys. `serde_yml` rejects - // duplicate keys when deserialising to `Value` (which preserves key - // ordering) but silently last-wins into a `BTreeMap`. We exploit this - // difference here. - match serde_yml::from_str::(source) { - Err(value_err) => { - let msg = value_err.to_string(); - if msg.contains("duplicate entry") { - // The serde_yml error message format for a duplicate key in a - // mapping deserialised as `Value` is: - // ": duplicate entry with key \"\" at line N column M" - // Extract the key name from between the quotes. - let key_name = extract_duplicate_key_name(&msg).unwrap_or(""); - return Err(Diagnostic { - code: DiagnosticCode::PolicyViolation, - subcode: Some("duplicate-schema-name"), - message: format!( - "Failed to decode OpenAPI input: schema name '{key_name}' is defined more than once in components.schemas.", - ), - path: Rc::clone(display_path), - }); - } - // Non-duplicate Value error: fall through to the typed decode below so - // the message carries field-path context. - } - Ok(value) => { - // Step 2: anchor-fanout guard. The re-serialisation is the expensive - // operation; skip it entirely when the source has no anchor markers, - // since the guard is structurally unreachable on anchor-free input. - // This is the T4.1 perf win: anchor-free specs pay only the Value - // parse, not the re-serialisation. - if source.contains('&') - && let Ok(expanded) = serde_yml::to_string(&value) - { - let source_len = source.len().max(1); - let cap = max_expansion_ratio(); - // Saturating arithmetic on the cap multiplication: source.len() is - // already bounded by the input-byte cap upstream, but the product - // could overflow on a pathologically small source × huge cap. - let threshold = source_len.saturating_mul(cap); - if expanded.len() > threshold { - let ratio = expanded.len() / source_len; - return Err(Diagnostic { - code: DiagnosticCode::PolicyViolation, - subcode: Some("mapping-expansion-exceeded"), - message: format!( - "Failed to decode OpenAPI input: YAML anchor expansion produced {expanded_len} bytes from {source_len} bytes of source — {ratio}× ratio exceeds the cap of {cap}×. The spec likely uses anchors with deep fan-out; inline the aliases or set OPENAPI_NG_MAX_EXPANSION_RATIO to override.", - expanded_len = expanded.len(), - ), - path: Rc::clone(display_path), - }); - } - } - } + if source.contains('&') { + check_anchor_expansion(source, display_path)?; } - // Step 3: typed decode on the original source. serde_yml deserialises from - // `&str`, not from a `Value`, so this is a second parse of the same bytes. - // Field-path context lives in the typed decoder's error path. - serde_yml::from_str(source).map_err(|error| { - Diagnostic::new( - DiagnosticCode::InputInvalid, - format!("Failed to decode OpenAPI input as YAML: {error}"), - Rc::clone(display_path), - ) - }) + serde_yml::from_str(source).map_err(|error| decode_failure(&error.to_string(), display_path)) } -/// Extract the duplicate key name from a `serde_yml` "duplicate entry" error -/// message. The message format is: -/// ": duplicate entry with key \"\" at line N column M" -/// Returns the text between the first pair of double-quotes, or `None` if the -/// pattern is not found (defensive fallback). -fn extract_duplicate_key_name(msg: &str) -> Option<&str> { - let start = msg.find('"')?; - let end = msg[start + 1..].find('"')?; - Some(&msg[start + 1..start + 1 + end]) +/// Projects a `serde_yml` decode error onto a diagnostic. A duplicate key +/// under `components.schemas` carries the `duplicate-schema-name` subcode so +/// consumers can route on it; anything else is a plain decode failure. +fn decode_failure(message: &str, display_path: &Rc) -> Diagnostic { + if message.contains(DUPLICATE_KEY) && message.contains(SCHEMAS_FIELD_PATH) { + return Diagnostic { + code: DiagnosticCode::PolicyViolation, + subcode: Some("duplicate-schema-name"), + message: format!( + "Failed to decode OpenAPI input: {message}. Each schema name must be declared once." + ), + path: Rc::clone(display_path), + }; + } + Diagnostic::new( + DiagnosticCode::InputInvalid, + format!("Failed to decode OpenAPI input as YAML: {message}"), + Rc::clone(display_path), + ) +} + +/// Field path `serde_yml` prefixes onto an error raised while deserialising +/// `components.schemas`. +const SCHEMAS_FIELD_PATH: &str = "components.schemas"; + +/// Rejects a source whose YAML aliases expand far beyond its own size. +/// +/// Measures the parsed node tree by re-serialising it, which inlines every +/// alias. A source this cannot parse or re-serialise passes, leaving the +/// typed parse to report the real error. +fn check_anchor_expansion(source: &str, display_path: &Rc) -> Result<(), Diagnostic> { + let Ok(value) = serde_yml::from_str::(source) else { + return Ok(()); + }; + let Ok(expanded) = serde_yml::to_string(&value) else { + return Ok(()); + }; + + let source_len = source.len().max(1); + let cap = MAX_EXPANSION_RATIO.get(); + if expanded.len() <= source_len.saturating_mul(cap) { + return Ok(()); + } + + Err(Diagnostic { + code: DiagnosticCode::PolicyViolation, + subcode: Some("mapping-expansion-exceeded"), + message: format!( + "Failed to decode OpenAPI input: YAML anchor expansion produced {expanded_len} bytes from {source_len} bytes of source — {ratio}× ratio exceeds the cap of {cap}×. The spec likely uses anchors with deep fan-out; inline the aliases or set OPENAPI_NG_MAX_EXPANSION_RATIO to override.", + expanded_len = expanded.len(), + ratio = expanded.len() / source_len, + ), + path: Rc::clone(display_path), + }) } #[cfg(test)] @@ -349,9 +217,7 @@ mod tests { time::{SystemTime, UNIX_EPOCH}, }; - use super::{ - DEFAULT_MAX_INPUT_BYTES, decode_openapi_input, max_input_bytes_from, read_and_decode, - }; + use super::{decode_openapi_input, read_and_decode}; use crate::error::DiagnosticCode; use std::path::PathBuf; @@ -378,11 +244,9 @@ mod tests { let _ = fs::remove_file(path); } - // Regression guard for Phase 4.4: the user-facing decode message must - // surface the source position so authors can jump to the offending byte - // without re-parsing the file by hand. `serde_json::Error::Display` already - // appends "at line X column Y"; if a future upgrade drops that, this test - // fails and forces us to construct the position ourselves. + // Every decode message forwards the parser's own position suffix + // verbatim. A parser upgrade that drops it fails here rather than + // silently costing spec authors the line number. #[test] fn decode_error_for_malformed_json_includes_line_and_column() { let path = PathBuf::from("spec.json"); @@ -413,11 +277,8 @@ mod tests { ); } - // Inline-source variant of the duplicate-key regression: pins behaviour - // independently of the fixture file. Together with - // `duplicate_schema_name_is_rejected_in_yaml`, this guards against silent - // BTreeMap last-wins regressions if T4.1's typed-first reorder ever drops - // the Value-parse probe on the no-anchor success path. + // Inline-source variant of the fixture test below, so the behaviour is + // pinned independently of the file on disk. #[test] fn duplicate_schema_name_in_yaml_is_diagnosed() { let yaml = r#" @@ -491,60 +352,6 @@ components: ); } - // Pure-function tests for the cap helper — not affected by OnceLock state. - - #[test] - fn cap_helper_default() { - assert_eq!(max_input_bytes_from(None), DEFAULT_MAX_INPUT_BYTES); - assert_eq!(max_input_bytes_from(None), 16 * 1024 * 1024); - } - - #[test] - fn cap_helper_respects_valid_env() { - assert_eq!(max_input_bytes_from(Some("1024")), 1024); - assert_eq!(max_input_bytes_from(Some("0")), 0); - } - - #[test] - fn cap_helper_rejects_invalid_env_uses_default() { - assert_eq!( - max_input_bytes_from(Some("not-a-number")), - DEFAULT_MAX_INPUT_BYTES - ); - assert_eq!(max_input_bytes_from(Some("")), DEFAULT_MAX_INPUT_BYTES); - assert_eq!(max_input_bytes_from(Some("-1")), DEFAULT_MAX_INPUT_BYTES); - } - - // --- expansion-ratio cap tests --- - - #[test] - fn max_expansion_ratio_from_default_value() { - use super::{DEFAULT_MAX_EXPANSION_RATIO, max_expansion_ratio_from}; - assert_eq!(max_expansion_ratio_from(None), DEFAULT_MAX_EXPANSION_RATIO); - assert_eq!(max_expansion_ratio_from(None), 50); - } - - #[test] - fn max_expansion_ratio_from_env_override() { - use super::{DEFAULT_MAX_EXPANSION_RATIO, max_expansion_ratio_from}; - assert_eq!(max_expansion_ratio_from(Some("100")), 100); - assert_eq!(max_expansion_ratio_from(Some("1")), 1); - assert_eq!(max_expansion_ratio_from(Some("0")), 0); - // Invalid forms fall back to default. - assert_eq!( - max_expansion_ratio_from(Some("not-a-number")), - DEFAULT_MAX_EXPANSION_RATIO, - ); - assert_eq!( - max_expansion_ratio_from(Some("")), - DEFAULT_MAX_EXPANSION_RATIO, - ); - assert_eq!( - max_expansion_ratio_from(Some("-1")), - DEFAULT_MAX_EXPANSION_RATIO, - ); - } - #[test] fn anchor_expansion_within_ratio_accepts() { // A handful of aliases on a small anchor stays well under the default @@ -586,10 +393,7 @@ components: )); } for r in 0..500 { - let aliases: String = std::iter::repeat("*b") - .take(16) - .collect::>() - .join(", "); + let aliases: String = std::iter::repeat_n("*b", 16).collect::>().join(", "); yaml.push_str(&format!(" A{r:04}: {{ allOf: [{aliases}] }}\n")); } @@ -612,16 +416,9 @@ components: ); } - // Sanity check on the YAML success path: a spec with no `&` anchors - // decodes cleanly and lands every schema. This test is intentionally - // structural — it does NOT directly verify that T4.1's - // `source.contains('&')` gate skips the `to_string` re-serialisation; - // observing that skip would require `cfg(test)`-gated instrumentation on - // the decode hot path, which is out of proportion for a single assertion. - // The perf-relevant skip is verified by `pnpm bench` medians (see - // `decode_yaml`'s docstring and commit `ab550fb`); this test would still - // pass even if the gate were deleted. It guards the surrounding shape: - // that anchor-free YAML still decodes successfully through the helper. + // Anchor-free YAML decodes cleanly and lands every schema. Structural + // only: it does not observe whether the `&` gate skipped the + // re-serialisation, which `bun run bench` covers. #[test] fn anchor_free_yaml_decodes_successfully() { let mut yaml = String::from( @@ -708,13 +505,8 @@ components: use super::decode_openapi_input_with_hint; use crate::bindings::InputFormat; - // A YAML hint must force YAML decoding even when the source is - // wire-compatible JSON — this proves the hint suppresses the - // sniff fallback rather than just steering it. - // - // JSON-shaped maps happen to parse as YAML (flow-style), so we - // pick content that is unambiguously NOT yaml: a leading tab inside - // a flow mapping, which serde_yml rejects. + // A JSON-shaped map also parses as flow-style YAML, so the source + // has to be one YAML rejects: a tab inside a flow mapping. let path = PathBuf::from("ambiguous"); let display: Rc = Rc::from("ambiguous"); let source = "{\t\"openapi\": \"3.0.3\"}"; diff --git a/src/parse/limits.rs b/src/parse/limits.rs new file mode 100644 index 0000000..52196d5 --- /dev/null +++ b/src/parse/limits.rs @@ -0,0 +1,83 @@ +//! Per-document input caps, each overridable by its environment +//! variable. + +use std::str::FromStr; +use std::sync::OnceLock; + +/// A cap read once per process from the environment, falling back to a +/// compile-time default when the variable is absent or unparsable. +pub(crate) struct EnvCap { + variable: &'static str, + default: T, + cached: OnceLock, +} + +impl EnvCap { + pub(crate) const fn new(variable: &'static str, default: T) -> Self { + Self { + variable, + default, + cached: OnceLock::new(), + } + } + + pub(crate) fn get(&self) -> T { + *self + .cached + .get_or_init(|| self.parse(std::env::var(self.variable).ok().as_deref())) + } + + /// Resolves the cap from `raw` rather than from the environment, and + /// caches nothing. + pub(crate) fn parse(&self, raw: Option<&str>) -> T { + raw + .and_then(|value| value.parse().ok()) + .unwrap_or(self.default) + } +} + +/// Largest accepted input, in bytes. +pub(crate) static MAX_INPUT_BYTES: EnvCap = + EnvCap::new("OPENAPI_NG_MAX_INPUT_BYTES", 16 * 1024 * 1024); + +/// Largest accepted `components.schemas` count. +pub(crate) static MAX_SCHEMAS: EnvCap = EnvCap::new("OPENAPI_NG_MAX_SCHEMAS", 10_000); + +/// Largest accepted operation count across all paths. +pub(crate) static MAX_OPERATIONS: EnvCap = EnvCap::new("OPENAPI_NG_MAX_OPERATIONS", 10_000); + +/// Largest accepted ratio of re-serialised parsed bytes to source bytes. +/// +/// The default sits well above real specs: the Swagger Petstore +/// re-serialises near 1×, and anchor-heavy hand-written specs under 10×. +pub(crate) static MAX_EXPANSION_RATIO: EnvCap = + EnvCap::new("OPENAPI_NG_MAX_EXPANSION_RATIO", 50); + +#[cfg(test)] +mod tests { + use super::{EnvCap, MAX_EXPANSION_RATIO, MAX_INPUT_BYTES, MAX_OPERATIONS, MAX_SCHEMAS}; + + #[test] + fn absent_or_unparsable_values_fall_back_to_the_default() { + let cap: EnvCap = EnvCap::new("UNUSED", 42); + assert_eq!(cap.parse(None), 42); + assert_eq!(cap.parse(Some("")), 42); + assert_eq!(cap.parse(Some("not-a-number")), 42); + assert_eq!(cap.parse(Some("-1")), 42); + } + + #[test] + fn a_parsable_value_overrides_the_default() { + let cap: EnvCap = EnvCap::new("UNUSED", 42); + assert_eq!(cap.parse(Some("7")), 7); + assert_eq!(cap.parse(Some("0")), 0); + } + + #[test] + fn declared_defaults_match_the_documented_values() { + assert_eq!(MAX_INPUT_BYTES.parse(None), 16 * 1024 * 1024); + assert_eq!(MAX_SCHEMAS.parse(None), 10_000); + assert_eq!(MAX_OPERATIONS.parse(None), 10_000); + assert_eq!(MAX_EXPANSION_RATIO.parse(None), 50); + } +} diff --git a/src/parse/mod.rs b/src/parse/mod.rs index f192fba..8d5166c 100644 --- a/src/parse/mod.rs +++ b/src/parse/mod.rs @@ -1,6 +1,8 @@ pub(crate) mod input; +pub(crate) mod limits; pub(crate) mod openapi_model; pub(crate) mod policy; +pub(crate) mod unique_map; pub(crate) use input::{decode_input_contents, read_and_decode}; pub(crate) use policy::{validate_generation_policy, validate_openapi_version}; diff --git a/src/parse/openapi_model.rs b/src/parse/openapi_model.rs index 4925195..47a73c9 100644 --- a/src/parse/openapi_model.rs +++ b/src/parse/openapi_model.rs @@ -1,14 +1,13 @@ -use std::collections::BTreeMap; - -use indexmap::IndexMap; use serde::Deserialize; use serde_json::Value; +use crate::parse::unique_map::{UniqueIndexMap, UniqueMap}; + #[derive(Debug, Deserialize)] pub(crate) struct OpenApiDocument { pub(crate) openapi: String, pub(crate) info: OpenApiInfo, - pub(crate) paths: BTreeMap, + pub(crate) paths: UniqueMap, #[serde(default)] pub(crate) components: Components, } @@ -21,11 +20,10 @@ pub(crate) struct OpenApiInfo { #[derive(Debug, Deserialize, Default)] pub(crate) struct Components { #[serde(default)] - pub(crate) schemas: BTreeMap, + pub(crate) schemas: UniqueMap, } -/// A path item in OpenAPI 3.x. Fields are in alphabetical method order to match -/// the BTreeMap ordering that the previous untyped implementation produced. +/// A path item, its methods declared in alphabetical order. #[derive(Debug, Deserialize, Default)] pub(crate) struct PathItem { pub(crate) delete: Option, @@ -39,9 +37,8 @@ pub(crate) struct PathItem { } impl PathItem { - /// Iterate over all operations in this path item, yielding (method, operation) pairs. - /// Methods are yielded in alphabetical order (delete, get, head, ...) matching the - /// BTreeMap ordering of the previous untyped implementation. + /// Yields each declared `(method, operation)` pair in alphabetical + /// method order. pub(crate) fn operations(&self) -> impl Iterator { [ ("delete", self.delete.as_ref()), @@ -67,12 +64,10 @@ pub(crate) struct Operation { #[serde(default)] pub(crate) parameters: Vec, pub(crate) request_body: Option, - pub(crate) responses: Option>, + pub(crate) responses: Option>, pub(crate) summary: Option, pub(crate) description: Option, - /// OpenAPI `deprecated: true` on the operation. Emitted as `@deprecated` - /// in the JSDoc above the service method so call sites surface the IDE - /// deprecation marker. + /// OpenAPI `deprecated: true` on the operation. #[serde(default)] pub(crate) deprecated: bool, } @@ -114,7 +109,7 @@ pub(crate) struct Parameter { #[derive(Debug, Deserialize)] pub(crate) struct RequestBody { - pub(crate) content: BTreeMap, + pub(crate) content: UniqueMap, #[serde(default)] pub(crate) required: bool, } @@ -126,7 +121,7 @@ pub(crate) struct MediaType { #[derive(Debug, Deserialize)] pub(crate) struct Response { - pub(crate) content: Option>, + pub(crate) content: Option>, } #[derive(Debug, Deserialize, Default)] @@ -143,10 +138,8 @@ pub(crate) struct Schema { pub(crate) any_of: Option>, pub(crate) all_of: Option>, pub(crate) not: Option>, - /// Preserves spec-author insertion order so generated TypeScript matches the source document. - /// `BTreeMap` would silently re-sort properties alphabetically, destroying meaningful - /// ordering (e.g. id/name/status/tags/nickname becoming id/name/nickname/status/tags). - pub(crate) properties: Option>, + /// Ordered as the spec author declared them. + pub(crate) properties: Option>, #[serde(default)] pub(crate) required: Vec, pub(crate) additional_properties: Option, @@ -154,17 +147,12 @@ pub(crate) struct Schema { pub(crate) nullable: Option, pub(crate) discriminator: Option, pub(crate) description: Option, - /// OpenAPI `deprecated: true` on the schema. Emitted as `@deprecated` in - /// the JSDoc above the corresponding TypeScript declaration (top-level - /// model or property) so consumers see the IDE deprecation marker at the - /// reference site. + /// OpenAPI `deprecated: true` on the schema. #[serde(default)] pub(crate) deprecated: bool, - /// OpenAPI `format` hint (e.g. `uuid`, `date-time`, `int32`). Currently - /// not carried into the IR — the schema walker surfaces every occurrence - /// as an `E_UNSUPPORTED_SEMANTIC` warning (subcode `format-dropped`) so - /// spec authors see what's being dropped instead of the field being - /// silently ignored. + /// OpenAPI `format` hint (`uuid`, `date-time`, `int32`, …). Read only + /// to detect `binary` on a form-body field; every other value is + /// reported as dropped. pub(crate) format: Option, } @@ -173,13 +161,11 @@ pub(crate) struct Schema { #[serde(rename_all = "camelCase")] pub(crate) struct Discriminator { pub(crate) property_name: String, - /// OpenAPI `discriminator.mapping`: maps a wire-value string to either - /// a full `$ref` (`#/components/schemas/Cat`) or a bare schema name. - /// Resolved at IR-build time to bare schema names so the emit-time - /// narrowing pass can compare against `SchemaType::Ref` payloads - /// directly. Defaults to empty when the spec omits the field. + /// OpenAPI `discriminator.mapping`: a wire value against either a full + /// `$ref` (`#/components/schemas/Cat`) or a bare schema name. Empty + /// when the spec omits the field. #[serde(default)] - pub(crate) mapping: BTreeMap, + pub(crate) mapping: UniqueMap, } #[cfg(test)] @@ -219,9 +205,5 @@ impl Schema { #[serde(untagged)] pub(crate) enum AdditionalProperties { Schema(Box), - // The bool value (true vs false) is intentionally discarded — both - // forms map to the same "unsupported subset" rejection in - // normalize/schema.rs. Deserializing as a typed variant (rather than - // a generic catch-all) keeps the rejection message accurate. - Boolean(#[allow(dead_code)] bool), + Boolean(bool), } diff --git a/src/parse/policy.rs b/src/parse/policy.rs index 7bf2dbc..15ebf39 100644 --- a/src/parse/policy.rs +++ b/src/parse/policy.rs @@ -1,50 +1,41 @@ use std::collections::BTreeMap; use crate::{ - error::{Diagnostic, DiagnosticCode, Reporter}, + error::{Diagnostic, DiagnosticCode, Reporter, bail, bail_policy}, parse::{ - input::{max_operations, max_schemas}, + limits::{MAX_OPERATIONS, MAX_SCHEMAS}, openapi_model::OpenApiDocument, }, }; pub(crate) fn validate_openapi_version( document: &OpenApiDocument, - reporter: &Reporter<'_>, + reporter: &Reporter, ) -> Result<(), Diagnostic> { if !document.openapi.starts_with("3.") { - return Err(reporter.error( + bail!( + reporter, DiagnosticCode::UnsupportedSemantic, - format!( - "Unsupported OpenAPI document shape: only OpenAPI 3.x documents are supported, found {}.", - document.openapi - ), - )); + "Unsupported OpenAPI document shape: only OpenAPI 3.x documents are supported, found {}.", + document.openapi + ); } Ok(()) } pub(crate) fn validate_generation_policy( document: &OpenApiDocument, - reporter: &Reporter<'_>, + reporter: &Reporter, ) -> Result<(), Diagnostic> { - // Per-document caps. These are sized to forestall accidental - // pathological inputs (e.g. a fanned-out anchor expansion) before any - // O(n²)-ish normalize/emit work runs. The defaults are deliberately - // generous (10k each) — real specs are several orders of magnitude - // below — and overridable via env so downstream consumers can opt out - // without recompiling. let schema_count = document.components.schemas.len(); - let cap_schemas = max_schemas(); + let cap_schemas = MAX_SCHEMAS.get(); if schema_count > cap_schemas { - return Err(Diagnostic::policy_violation( + bail_policy!( reporter, "schema-cap-exceeded", - format!( - "Failed to plan services: OpenAPI document declares {schema_count} schemas under components.schemas; \ + "Failed to plan services: OpenAPI document declares {schema_count} schemas under components.schemas; \ the per-document cap is {cap_schemas}. Set OPENAPI_NG_MAX_SCHEMAS to override.", - ), - )); + ); } let operation_count: usize = document @@ -52,50 +43,44 @@ pub(crate) fn validate_generation_policy( .values() .map(|path_item| path_item.operations().count()) .sum(); - let cap_operations = max_operations(); + let cap_operations = MAX_OPERATIONS.get(); if operation_count > cap_operations { - return Err(Diagnostic::policy_violation( + bail_policy!( reporter, "operation-cap-exceeded", - format!( - "Failed to plan services: OpenAPI document declares {operation_count} operations across paths; \ + "Failed to plan services: OpenAPI document declares {operation_count} operations across paths; \ the per-document cap is {cap_operations}. Set OPENAPI_NG_MAX_OPERATIONS to override.", - ), - )); + ); } - // Maps operationId → (method, path) for duplicate detection. + // Each operationId, against the first operation that declared it. let mut seen_operation_ids: BTreeMap<&str, (&'static str, &str)> = BTreeMap::new(); - for (path, path_item) in &document.paths { + for (path, path_item) in document.paths.iter() { for (method, operation) in path_item.operations() { if operation.operation_id.is_none() { - return Err(Diagnostic::policy_violation( + bail_policy!( reporter, "missing-operation-id", - format!( - "Failed to plan services: operation {} {} must define operationId when service generation is enabled.", - method.to_ascii_uppercase(), - path - ), - )); + "Failed to plan services: operation {} {} must define operationId when service generation is enabled.", + method.to_ascii_uppercase(), + path + ); } if let Some(ref op_id) = operation.operation_id { if let Some(&(prev_method, prev_path)) = seen_operation_ids.get(op_id.as_str()) { - return Err(Diagnostic::policy_violation( + bail_policy!( reporter, "duplicate-operation-id", - format!( - "Failed to plan services: operationId '{}' is defined on both {} {} and {} {}. \ + "Failed to plan services: operationId '{}' is defined on both {} {} and {} {}. \ operationIds must be globally unique.", - op_id, - prev_method.to_ascii_uppercase(), - prev_path, - method.to_ascii_uppercase(), - path, - ), - )); + op_id, + prev_method.to_ascii_uppercase(), + prev_path, + method.to_ascii_uppercase(), + path, + ); } seen_operation_ids.insert(op_id.as_str(), (method, path.as_str())); } @@ -109,7 +94,7 @@ pub(crate) fn validate_generation_policy( mod tests { use std::{path::Path, rc::Rc}; - use crate::{parse::input::decode_openapi_input, test_support::test_ctx}; + use crate::{parse::input::decode_openapi_input, test_support::test_reporter}; use super::{validate_generation_policy, validate_openapi_version}; @@ -125,8 +110,8 @@ mod tests { "paths":{"/pets":{"get":{"responses":{"200":{"description":"ok"}}}}}}"#, ); - let mut ctx = test_ctx(); - validate_openapi_version(&document, &ctx.reporter()).expect("version check should pass"); + let ctx = test_reporter(); + validate_openapi_version(&document, &ctx).expect("version check should pass"); } #[test] @@ -134,8 +119,8 @@ mod tests { let document = decode(r#"{"openapi":"2.0.0","info":{"title":"Old","version":"1.0.0"},"paths":{}}"#); - let mut ctx = test_ctx(); - let Err(error) = validate_openapi_version(&document, &ctx.reporter()) else { + let ctx = test_reporter(); + let Err(error) = validate_openapi_version(&document, &ctx) else { panic!("old version should fail") }; @@ -152,9 +137,9 @@ mod tests { r#"{"openapi":"3.0.3","info":{"title":"Missing OperationId","version":"1.0.0"}, "paths":{"/pets":{"get":{"responses":{"200":{"description":"ok"}}}}}}"#, ); - let mut ctx = test_ctx(); + let ctx = test_reporter(); - let Err(error) = validate_generation_policy(&document, &ctx.reporter()) else { + let Err(error) = validate_generation_policy(&document, &ctx) else { panic!("missing operationId should fail") }; @@ -173,10 +158,9 @@ mod tests { r#"{"openapi":"3.0.3","info":{"title":"Has OperationId","version":"1.0.0"}, "paths":{"/pets":{"get":{"operationId":"listPets","responses":{"200":{"description":"ok"}}}}}}"#, ); - let mut ctx = test_ctx(); + let ctx = test_reporter(); - validate_generation_policy(&document, &ctx.reporter()) - .expect("operation with operationId should pass"); + validate_generation_policy(&document, &ctx).expect("operation with operationId should pass"); } #[test] @@ -185,9 +169,9 @@ mod tests { let display: Rc = Rc::from("fixture.yaml"); let document = decode_openapi_input(Path::new("fixture.yaml"), yaml, &display) .expect("decode should succeed"); - let mut ctx = test_ctx(); - let err = validate_generation_policy(&document, &ctx.reporter()) - .expect_err("should reject duplicate operationId"); + let ctx = test_reporter(); + let err = + validate_generation_policy(&document, &ctx).expect_err("should reject duplicate operationId"); assert_eq!(err.code, crate::error::DiagnosticCode::PolicyViolation); assert_eq!(err.subcode, Some("duplicate-operation-id")); } @@ -197,58 +181,11 @@ mod tests { mod cap_tests { use std::rc::Rc; - use crate::{ - parse::input::{ - DEFAULT_MAX_OPERATIONS, DEFAULT_MAX_SCHEMAS, max_operations_from, max_schemas_from, - }, - test_support::test_ctx, - }; + use crate::parse::limits::{MAX_OPERATIONS, MAX_SCHEMAS}; + use crate::test_support::test_reporter; use super::validate_generation_policy; - // Pure-function tests for the cap helpers — not affected by OnceLock state. - - #[test] - fn schemas_cap_helper_default() { - assert_eq!(max_schemas_from(None), DEFAULT_MAX_SCHEMAS); - assert_eq!(max_schemas_from(None), 10_000); - } - - #[test] - fn operations_cap_helper_default() { - assert_eq!(max_operations_from(None), DEFAULT_MAX_OPERATIONS); - assert_eq!(max_operations_from(None), 10_000); - } - - #[test] - fn schemas_cap_helper_respects_valid_env() { - assert_eq!(max_schemas_from(Some("1")), 1); - assert_eq!(max_schemas_from(Some("0")), 0); - } - - #[test] - fn operations_cap_helper_respects_valid_env() { - assert_eq!(max_operations_from(Some("1")), 1); - assert_eq!(max_operations_from(Some("0")), 0); - } - - #[test] - fn schemas_cap_helper_rejects_invalid_env_uses_default() { - assert_eq!(max_schemas_from(Some("not-a-number")), DEFAULT_MAX_SCHEMAS); - assert_eq!(max_schemas_from(Some("")), DEFAULT_MAX_SCHEMAS); - assert_eq!(max_schemas_from(Some("-1")), DEFAULT_MAX_SCHEMAS); - } - - #[test] - fn operations_cap_helper_rejects_invalid_env_uses_default() { - assert_eq!( - max_operations_from(Some("not-a-number")), - DEFAULT_MAX_OPERATIONS - ); - assert_eq!(max_operations_from(Some("")), DEFAULT_MAX_OPERATIONS); - assert_eq!(max_operations_from(Some("-1")), DEFAULT_MAX_OPERATIONS); - } - // Build an OpenAPI YAML document on the fly with N empty-object schemas // under components.schemas. Used to assert the schema-cap fires at the // configured boundary. @@ -294,12 +231,12 @@ mod cap_tests { #[test] fn schemas_cap_rejects_oversize() { - let yaml = build_doc_with_schemas(DEFAULT_MAX_SCHEMAS + 1); + let yaml = build_doc_with_schemas(MAX_SCHEMAS.get() + 1); let document = decode(&yaml); - let mut ctx = test_ctx(); - let err = validate_generation_policy(&document, &ctx.reporter()) - .expect_err("should reject oversize schemas"); + let ctx = test_reporter(); + let err = + validate_generation_policy(&document, &ctx).expect_err("should reject oversize schemas"); assert_eq!(err.code, crate::error::DiagnosticCode::PolicyViolation); assert_eq!(err.subcode, Some("schema-cap-exceeded")); @@ -312,12 +249,12 @@ mod cap_tests { #[test] fn operations_cap_rejects_oversize() { - let yaml = build_doc_with_operations(DEFAULT_MAX_OPERATIONS + 1); + let yaml = build_doc_with_operations(MAX_OPERATIONS.get() + 1); let document = decode(&yaml); - let mut ctx = test_ctx(); - let err = validate_generation_policy(&document, &ctx.reporter()) - .expect_err("should reject oversize operations"); + let ctx = test_reporter(); + let err = + validate_generation_policy(&document, &ctx).expect_err("should reject oversize operations"); assert_eq!(err.code, crate::error::DiagnosticCode::PolicyViolation); assert_eq!(err.subcode, Some("operation-cap-exceeded")); diff --git a/src/parse/unique_map.rs b/src/parse/unique_map.rs new file mode 100644 index 0000000..2c73041 --- /dev/null +++ b/src/parse/unique_map.rs @@ -0,0 +1,173 @@ +//! Duplicate-rejecting map deserializers. +//! +//! YAML and JSON both permit a repeated mapping key, which `BTreeMap` and +//! `IndexMap` resolve by keeping the last occurrence. + +use std::collections::BTreeMap; +use std::fmt; +use std::marker::PhantomData; +use std::ops::Deref; + +use indexmap::IndexMap; +use serde::de::{Deserialize, Deserializer, Error as _, MapAccess, Visitor}; + +/// A keyed collection that reports whether a key was already present. +pub(crate) trait InsertUnique { + type Value; + + /// Inserts `value` under `key`. Returns `false` and leaves the collection + /// unchanged when `key` was already present. + fn insert_unique(&mut self, key: String, value: Self::Value) -> bool; +} + +impl InsertUnique for BTreeMap { + type Value = V; + + fn insert_unique(&mut self, key: String, value: V) -> bool { + match self.entry(key) { + std::collections::btree_map::Entry::Occupied(_) => false, + std::collections::btree_map::Entry::Vacant(slot) => { + slot.insert(value); + true + } + } + } +} + +impl InsertUnique for IndexMap { + type Value = V; + + fn insert_unique(&mut self, key: String, value: V) -> bool { + match self.entry(key) { + indexmap::map::Entry::Occupied(_) => false, + indexmap::map::Entry::Vacant(slot) => { + slot.insert(value); + true + } + } + } +} + +/// Wraps a keyed collection so deserializing it rejects a repeated key. +/// Derefs to the wrapped collection. +#[derive(Debug, Default)] +#[cfg_attr(test, derive(Clone))] +pub(crate) struct Unique(M); + +/// `BTreeMap` that rejects a repeated key at decode time. +pub(crate) type UniqueMap = Unique>; + +/// `IndexMap` that rejects a repeated key at decode time, preserving the +/// spec author's declaration order. +pub(crate) type UniqueIndexMap = Unique>; + +/// Total: a built `BTreeMap` or `IndexMap` holds no repeated key, so only +/// deserialization can encounter one. +impl From for Unique { + fn from(map: M) -> Self { + Self(map) + } +} + +impl Deref for Unique { + type Target = M; + + fn deref(&self) -> &M { + &self.0 + } +} + +impl<'de, M> Deserialize<'de> for Unique +where + M: InsertUnique + Default, + M::Value: Deserialize<'de>, +{ + fn deserialize>(deserializer: D) -> Result { + deserializer.deserialize_map(UniqueVisitor(PhantomData)) + } +} + +struct UniqueVisitor(PhantomData); + +impl<'de, M> Visitor<'de> for UniqueVisitor +where + M: InsertUnique + Default, + M::Value: Deserialize<'de>, +{ + type Value = Unique; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a map") + } + + fn visit_map>(self, mut access: A) -> Result { + let mut map = M::default(); + while let Some(key) = access.next_key::()? { + let value = access.next_value::()?; + if !map.insert_unique(key.clone(), value) { + return Err(A::Error::custom(format!("{DUPLICATE_KEY} '{key}'"))); + } + } + Ok(Unique(map)) + } +} + +/// Leading text of the duplicate-key error, for callers routing on it. +pub(crate) const DUPLICATE_KEY: &str = "duplicate key"; + +#[cfg(test)] +mod tests { + use super::{UniqueIndexMap, UniqueMap}; + + #[test] + fn unique_keys_deserialize_into_the_wrapped_map() { + let map: UniqueMap = serde_yml::from_str("a: 1\nb: 2\n").expect("unique keys parse"); + assert_eq!(map.len(), 2); + assert_eq!(map.get("a"), Some(&1)); + } + + #[test] + fn repeated_key_fails_naming_the_key() { + let error = + serde_yml::from_str::>("a: 1\nb: 2\na: 3\n").expect_err("repeat must fail"); + assert!(error.to_string().contains("duplicate key 'a'")); + } + + /// The field path and source position come from the deserializer, so they + /// appear once the map sits under a named field — which is every use in + /// the OpenAPI model. + #[test] + fn repeated_key_under_a_field_reports_the_path_and_position() { + #[derive(Debug, serde::Deserialize)] + struct Doc { + #[allow(dead_code)] + schemas: UniqueMap, + } + + let error = + serde_yml::from_str::("schemas:\n a: 1\n a: 2\n").expect_err("repeat must fail"); + let message = error.to_string(); + assert!(message.contains("schemas: duplicate key 'a'"), "{message}"); + assert!( + message.contains("line ") && message.contains("column "), + "{message}" + ); + } + + #[test] + fn index_map_variant_preserves_declaration_order() { + let map: UniqueIndexMap = + serde_yml::from_str("z: 1\na: 2\n").expect("unique keys parse in order"); + assert_eq!( + map.keys().map(String::as_str).collect::>(), + ["z", "a"] + ); + } + + #[test] + fn index_map_variant_rejects_a_repeated_key() { + let error = + serde_yml::from_str::>("z: 1\nz: 2\n").expect_err("repeat must fail"); + assert!(error.to_string().contains("duplicate key 'z'")); + } +} diff --git a/src/pipeline.rs b/src/pipeline.rs index 85205d4..c7d0f1a 100644 --- a/src/pipeline.rs +++ b/src/pipeline.rs @@ -1,16 +1,7 @@ use std::rc::Rc; use crate::{ - bindings::EmitTarget, - emit::{ - MODEL_ARTIFACT_PATH, - angular::{ - REST_MODEL_PATH, REST_MODEL_TEMPLATE, REST_UTIL_PATH, REST_UTIL_TEMPLATE, REST_VALIDATE_PATH, - REST_VALIDATE_TEMPLATE, emit_service, - }, - model::emit_ts_models::emit_model, - render_generated_banner, - }, + emit::{emitters_for, render_generated_banner}, error::{Diagnostic, Reporter}, ir::canonical::ApiModel, options::{GenerateConfig, validate_generate_config}, @@ -18,42 +9,32 @@ use crate::{ result::{GenerateSummary, GeneratedArtifact}, }; -// ── Result types ──────────────────────────────────────────────────────────── - pub struct GenerateResult { pub summary: GenerateSummary, pub diagnostics: Vec, pub artifacts: Vec, } -/// Top-level pipeline outcome on failure: the accumulated warnings up to the -/// failure point, plus the fatal diagnostic that ended the pipeline. Warnings -/// "ride on the reporter" inside stages; this struct exists only at the -/// pipeline boundary so the NAPI layer can surface both halves to the -/// consumer. +/// The warnings recorded before a run failed, and the fatal that ended +/// it. #[derive(Debug)] pub struct GenerateFailure { pub warnings: Vec, pub fatal: Diagnostic, } -// ── Pipeline ──────────────────────────────────────────────────────────────── - -/// Decode → policy-check → normalize. `normalize_api_model` performs -/// the final semantic step (discriminator narrowing + `$ref` -/// validation) before returning. +/// Decode → policy-check → normalize. pub(crate) fn build_ir( config: &GenerateConfig, display_path: &Rc, - reporter: &mut Reporter<'_>, + reporter: &Reporter, ) -> Result { let document = match (&config.input_path, &config.input_contents) { (Some(path), None) => crate::parse::read_and_decode(path, display_path)?, (None, Some(contents)) => { crate::parse::decode_input_contents(contents, config.input_format, display_path)? } - // Validator guarantees exactly-one — these branches are unreachable - // in practice but we keep them defensive rather than panicking. + // `validate_generate_config` has already rejected both-or-neither. _ => { return Err(Diagnostic::new( crate::error::DiagnosticCode::InvalidOption, @@ -68,16 +49,14 @@ pub(crate) fn build_ir( } pub fn execute_generate(config: GenerateConfig) -> Result { - // Self-test hook for `catch_unwind` at the NAPI boundary. The magic - // input-path string is opaque enough that no real spec path can hit it; - // kept in release builds so CI exercises the panic-to-E_UNEXPECTED path. + // Sentinel path that forces a panic, so the `catch_unwind` at the NAPI + // boundary is exercised by a real call. Present in release builds. if config.input_path.as_deref() == Some("__panic_for_test__") { panic!("test sentinel: forced panic"); } - // Build display_path: honour an explicitly-supplied value (URL inputs, - // direct inputContents callers); otherwise derive from input_path with - // backslash-to-slash normalisation. + // An explicit `display_path` wins; otherwise the input path with its + // separators normalised. let display_path: Rc = config.display_path.as_deref().map_or_else( || { config.input_path.as_deref().map_or_else( @@ -94,81 +73,52 @@ pub fn execute_generate(config: GenerateConfig) -> Result = Vec::new(); + let reporter = Reporter::new(Rc::clone(&display_path)); - match run_pipeline(config, Rc::clone(&display_path), &mut warnings) { + match run_pipeline(config, display_path, &reporter) { Ok((summary, artifacts)) => Ok(GenerateResult { summary, - diagnostics: warnings, + diagnostics: reporter.into_warnings(), artifacts, }), - Err(fatal) => Err(GenerateFailure { warnings, fatal }), + Err(fatal) => Err(GenerateFailure { + warnings: reporter.into_warnings(), + fatal, + }), } } fn run_pipeline( mut config: GenerateConfig, display_path: Rc, - warnings: &mut Vec, + reporter: &Reporter, ) -> Result<(GenerateSummary, Vec), Diagnostic> { - let mut reporter = Reporter::new(Rc::clone(&display_path), warnings); - validate_generate_config(&mut config, &mut reporter)?; - let ir = build_ir(&config, &display_path, &mut reporter)?; + validate_generate_config(&mut config, reporter)?; + let ir = build_ir(&config, &display_path, reporter)?; let summary = GenerateSummary::from_ir(display_path.as_ref().to_string(), &ir); - let source_path = summary.normalized_source_path.as_str(); - - let plan = plan_generation(&config, &ir, &reporter)?; - // One banner allocation per pipeline run, threaded into every emitter - // by reference. Bench-large emits 35+ artifacts; this trims one - // `format!` per artifact (and on petstore-sized inputs the cost is - // also paid by every consumer test). - let banner = render_generated_banner(source_path); - - // Canonical emit order: models → angular-rest support → per-tag - // services. `plan.services` is already class-name-sorted by - // `resolve_service_plans`, so artifact ordering is independent of - // operation insertion order. - let mut artifacts: Vec = Vec::new(); - if config.emit.contains(&EmitTarget::Models) && !ir.schemas.is_empty() { - let body = emit_model(&ir.schemas, &plan.mapped_types); - artifacts.push(GeneratedArtifact::new( - MODEL_ARTIFACT_PATH.to_string(), - format!("{banner}{body}"), - )); - } - if config.emit.contains(&EmitTarget::Angular) { - artifacts.push(GeneratedArtifact::new( - REST_MODEL_PATH.to_string(), - format!("{banner}{REST_MODEL_TEMPLATE}"), - )); - artifacts.push(GeneratedArtifact::new( - REST_UTIL_PATH.to_string(), - format!("{banner}{REST_UTIL_TEMPLATE}"), - )); - artifacts.push(GeneratedArtifact::new( - REST_VALIDATE_PATH.to_string(), - format!("{banner}{REST_VALIDATE_TEMPLATE}"), - )); - for service in &plan.services { - let body = emit_service(service); - artifacts.push(GeneratedArtifact::new( - service.artifact_path.clone(), - format!("{banner}{body}"), - )); - } - } + + let plan = plan_generation(&config, &ir, reporter)?; + + // One banner per run, prefixed onto every artifact. + let banner = render_generated_banner(summary.normalized_source_path.as_str()); + + let artifacts: Vec = config + .emit + .iter() + .flat_map(|target| emitters_for(*target)) + .flat_map(|emitter| emitter.artifacts(&plan)) + .map(|artifact| artifact.with_banner(&banner)) + .collect(); crate::io::writer::write_generated_artifacts( config.output_path.as_deref(), &artifacts, - &reporter, + reporter, )?; Ok((summary, artifacts)) } -// ── Tests ──────────────────────────────────────────────────────────────────── - #[cfg(test)] mod tests { use std::{ @@ -184,13 +134,11 @@ mod tests { options::GenerateConfig, parse::input::decode_openapi_input, result::{GenerateSummary, GeneratedArtifact}, - test_support::test_ctx, + test_support::test_reporter, }; use super::{GenerateResult, build_ir, execute_generate}; - // ── build_ir ───────────────────────────────────────────────────────────── - fn build_ir_config_for_path(path: &str) -> GenerateConfig { GenerateConfig { input_path: Some(path.to_string()), @@ -208,10 +156,10 @@ mod tests { #[test] fn build_ir_runs_input_validation_policy_and_normalize_in_one_pass() { - let mut ctx = test_ctx(); + let ctx = test_reporter(); let display: Rc = Rc::from("test/fixtures/petstore-minimal.openapi.yaml"); let config = build_ir_config_for_path("test/fixtures/petstore-minimal.openapi.yaml"); - let ir = build_ir(&config, &display, &mut ctx.reporter()).expect("compiler stages succeed"); + let ir = build_ir(&config, &display, &ctx).expect("compiler stages succeed"); assert_eq!(ir.info.title, "Petstore Minimal"); assert_eq!(ir.info.spec_version, "3.0.3"); @@ -243,11 +191,11 @@ mod tests { ) .expect("fixture should be written"); - let mut ctx = test_ctx(); + let ctx = test_reporter(); let path_str = path.to_str().expect("utf-8 path"); let display: Rc = Rc::from(path_str); let config = build_ir_config_for_path(path_str); - let Err(failure) = build_ir(&config, &display, &mut ctx.reporter()) else { + let Err(failure) = build_ir(&config, &display, &ctx) else { panic!("invalid operation shape should fail") }; @@ -280,8 +228,6 @@ mod tests { } } - // ── GenerateResult ─────────────────────────────────────────────────────── - #[test] fn generated_artifact_new_preserves_path_and_contents() { let artifact = GeneratedArtifact::new( @@ -318,8 +264,6 @@ mod tests { assert_eq!(result.artifacts, vec![artifact]); } - // ── execute_generate ───────────────────────────────────────────────────── - #[test] fn execute_generate_emits_typescript_and_angular_artifacts_in_canonical_order() { let result = execute_generate(GenerateConfig { diff --git a/src/plan/artifact_plan.rs b/src/plan/artifact_plan.rs index 8a112cf..9a85624 100644 --- a/src/plan/artifact_plan.rs +++ b/src/plan/artifact_plan.rs @@ -2,6 +2,7 @@ use std::collections::BTreeMap; use crate::{ error::{Diagnostic, DiagnosticCode, Reporter}, + ident::{Ident, MethodName, TypeName}, ir::canonical::{ ApiModel, BodyFieldType, ErrorResponse, HttpMethod, ModelSymbol, ResponseContent, }, @@ -10,17 +11,14 @@ use crate::{ }; use super::{ - naming::{service_class_name, service_file_stem}, + naming::{error_interface_name, request_interface_name, service_class_name, service_file_stem}, services::plan_request_contract, }; -/// `MappedType` after schema-name validation. The `schema` field borrows -/// from the IR's model symbol that was matched, encoding the validated -/// lifecycle in the type system: callers receive `ResolvedMappedType` -/// only after `validate_mapped_types_against_schemas` confirmed the -/// schema exists. `import`, `ty`, and `alias` are owned `Box` -/// (cloned from the input `MappedType`) since they are short identifier -/// strings consumed by emit. +/// A [`MappedType`] whose `schema` was found in the IR, borrowed from the +/// model symbol that matched. +/// +/// Only [`validate_mapped_types_against_schemas`] constructs one. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct ResolvedMappedType<'a> { pub(crate) schema: &'a str, @@ -43,7 +41,7 @@ impl<'a> ResolvedMappedType<'a> { #[derive(Debug, PartialEq, Eq)] pub(crate) struct ServicePlan<'ir> { pub(crate) group_name: String, - pub(crate) class_name: String, + pub(crate) class_name: TypeName, pub(crate) artifact_path: String, pub(crate) operations: Vec>, } @@ -51,27 +49,28 @@ pub(crate) struct ServicePlan<'ir> { #[derive(Debug, PartialEq, Eq)] pub(crate) struct PlannedOperation<'ir> { pub(crate) operation_id: String, - pub(crate) method_name: String, + pub(crate) method_name: MethodName, pub(crate) method: HttpMethod, pub(crate) path: String, pub(crate) request: PlannedRequestContract<'ir>, pub(crate) response: Option<&'ir ResponseContent>, - /// Borrowed from the IR's `OperationDef.errors`. Empty when the - /// operation declared no 4xx/5xx response with a JSON schema. The - /// angular emit walks this to render a `{Pascal}Error` interface - /// alongside the operation's `{Pascal}Params`. + /// The operation's typed error responses, empty when it declared + /// none. pub(crate) errors: &'ir [ErrorResponse], + /// Name of the `{Pascal}Params` interface, or `None` when the operation + /// declares no path, query, header or body input and so emits none. + pub(crate) request_interface: Option, + /// Name of the `{Pascal}Error` interface, or `None` when the operation + /// declares no 4xx/5xx response with a JSON schema. + pub(crate) error_interface: Option, pub(crate) description: Option, pub(crate) deprecated: bool, } -/// Per-field discriminator for `PlannedRequestField` that tells emit code -/// which slot of the HTTP request a field maps to. Headers live on -/// `PlannedRequestContract.headers` and the request body lives on -/// `PlannedRequestContract.body`; `Body` here marks the body properties -/// hoisted into top-level fields by the smart-flatten rule (inline JSON -/// object bodies). Nested-body operations carry no `Body`-kinded entries -/// — their body sits on the dedicated slot. +/// Which slot of the HTTP request a [`PlannedRequestField`] fills. +/// +/// `Body` marks a property hoisted out of an inline JSON body; a nested +/// body has no fields of this kind. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum RequestFieldKind { Path, @@ -81,16 +80,12 @@ pub(crate) enum RequestFieldKind { #[derive(Debug, PartialEq, Eq)] pub(crate) struct PlannedRequestContract<'ir> { - /// Path and query parameters. Inline-JSON-body properties are not stored - /// here — they live inside `PlannedRequestBody::FlatJson` so emit can - /// dispatch on the body kind without filtering by `RequestFieldKind`. + /// Path and query parameters. A hoisted body property lives on + /// [`PlannedRequestBody::FlatJson`], not here. pub(crate) fields: Vec>, - /// Header parameters surfaced on the request interface as a nested - /// `headers: { ... }` field. Empty when the operation declares no - /// `in: header` parameters. + /// Header parameters, empty when the operation declares none. pub(crate) headers: Vec>, - /// The request body's planned layout. `None` when the operation - /// declares no body. + /// The body's layout, `None` when the operation declares no body. pub(crate) body: Option>, } @@ -109,124 +104,119 @@ pub(crate) struct PlannedHeader<'ir> { pub(crate) ty: &'ir SchemaType, } -/// A single form-body field for multipart/form-data or -/// application/x-www-form-urlencoded request bodies. Borrows the -/// `BodyFieldType` from the IR; the emit type-printer dispatches on that -/// enum to render the right TS type (string / Blob / number[] / Blob[] …). +/// One field of a multipart or urlencoded body. #[derive(Debug, PartialEq, Eq)] pub(crate) struct PlannedFormField<'ir> { - pub(crate) name: Box, + pub(crate) name: Ident, pub(crate) optional: bool, pub(crate) ty: &'ir BodyFieldType, } -/// The request body's planned layout. The smart-flatten rule splits JSON -/// bodies in two: a top-level `$ref` (or any non-object schema) stays -/// `Nested`, preserving the spec author's named type as `body: T` on the -/// request interface; an inline `type: object` body becomes `FlatJson`, -/// hoisting its properties to top-level fields beside path/query. Form -/// bodies always flatten — their `BodyFieldType`-typed fields can't -/// compose back under the source schema name anyway. +/// How a request body is laid out on the request contract. #[derive(Debug, PartialEq, Eq)] pub(crate) enum PlannedRequestBody<'ir> { - /// Renders as a nested `body: T` field on the request interface and - /// forwards verbatim via shorthand from the builder. Produced for JSON - /// bodies whose schema is a top-level `$ref` or any non-object shape - /// (scalar, array, union) where there is no property structure to - /// hoist. + /// A JSON body with no properties to hoist: a top-level `$ref`, or a + /// scalar, array or union. Keeps the spec author's type under one + /// `body` key. Nested { ty: &'ir SchemaType, optional: bool }, - /// Body was an inline JSON object; its properties are hoisted as - /// `RequestFieldKind::Body` entries on this variant. Each property's - /// `optional` already accounts for the body envelope's `required` - /// flag (an `required: false` body downgrades every property to - /// optional regardless of its individual schema flag). + /// An inline JSON object body, its properties hoisted to top level. + /// + /// Each `optional` already folds in the envelope's `required`: under a + /// `required: false` body every property is optional. FlatJson { properties: Vec>, required: bool, }, - /// `multipart/form-data` body. Fields render as top-level entries on - /// the request interface (typed via `BodyFieldType`); builder - /// materializes them into a `FormData` at runtime. + /// A `multipart/form-data` body, its fields hoisted to top level. Multipart { fields: Vec> }, - /// `application/x-www-form-urlencoded` body. Fields render as - /// top-level entries on the request interface; builder materializes - /// them into `URLSearchParams`. + /// An `application/x-www-form-urlencoded` body, its fields hoisted to + /// top level. UrlEncoded { fields: Vec> }, } -/// Verifies that each `mapped_types[].schema` resolves to a top-level -/// model symbol and returns a `Vec>` borrowing -/// the matched symbol names from the IR. Pre-emit gate so a typo -/// doesn't silently produce an emit that omits the placeholder for the -/// missing schema. The return type encodes the validated lifecycle: -/// `MappedType` is user input, `ResolvedMappedType` is what emit consumes. +/// Resolves each mapped type against `model_symbols`, failing on the +/// first `schema` the IR does not declare. pub(crate) fn validate_mapped_types_against_schemas<'ir>( model_symbols: &'ir [ModelSymbol], mapped_types: &[MappedType], - reporter: &Reporter<'_>, + reporter: &Reporter, ) -> Result>, Diagnostic> { let by_name = model_symbols .iter() .map(|symbol| (symbol.name.as_ref(), symbol)) .collect::>(); - let mut resolved = Vec::with_capacity(mapped_types.len()); - for mapped_type in mapped_types { - let symbol = by_name.get(mapped_type.schema.as_str()).ok_or_else(|| { - reporter.error( - DiagnosticCode::InvalidOption, - format!( - "Failed to resolve generation options: mapped schema {} does not exist in the IR.", - mapped_type.schema - ), - ) - })?; - resolved.push(ResolvedMappedType::new(symbol.name.as_ref(), mapped_type)); - } - - Ok(resolved) + mapped_types + .iter() + .map(|mapped_type| { + let symbol = by_name.get(mapped_type.schema.as_str()).ok_or_else(|| { + reporter.error( + DiagnosticCode::InvalidOption, + format!( + "Failed to resolve generation options: mapped schema {} does not exist in the IR.", + mapped_type.schema + ), + ) + })?; + Ok(ResolvedMappedType::new(symbol.name.as_ref(), mapped_type)) + }) + .collect() } pub(crate) fn resolve_service_plans<'ir>( ir: &'ir ApiModel, resolver: &crate::plan::naming::NamingResolver, - reporter: &Reporter<'_>, + reporter: &Reporter, ) -> Result>, Diagnostic> { use super::services::group_operations; - let grouped_operations = group_operations(&ir.operations, resolver, reporter)?; - let mut services = Vec::with_capacity(grouped_operations.len()); - for (group_name, group_operations) in grouped_operations { - let mut operations: Vec> = group_operations - .iter() - .map(|(operation, method_name)| { - Ok(PlannedOperation { - operation_id: operation.operation_id.clone(), - method_name: method_name.clone(), - method: operation.method, - path: operation.path.clone(), - request: plan_request_contract(operation, reporter)?, - response: operation.response.as_ref(), - errors: operation.errors.as_slice(), - description: operation.description.clone(), - deprecated: operation.deprecated, - }) + let mut services = group_operations(&ir.operations, resolver, reporter)? + .into_iter() + .map(|(group_name, group)| { + let mut operations = group + .into_iter() + .map(|(operation, method_name)| plan_operation(operation, method_name, reporter)) + .collect::, Diagnostic>>()?; + operations.sort_by(|left, right| left.method_name.cmp(&right.method_name)); + + Ok(ServicePlan { + class_name: service_class_name(&group_name), + artifact_path: format!("rest/{}.rest.generated.ts", service_file_stem(&group_name)), + group_name, + operations, }) - .collect::, Diagnostic>>()?; - operations.sort_by(|a, b| a.method_name.cmp(&b.method_name)); + }) + .collect::, Diagnostic>>()?; + services.sort_by(|left, right| left.class_name.cmp(&right.class_name)); - let artifact_path = format!("rest/{}.rest.generated.ts", service_file_stem(&group_name)); + Ok(services) +} - services.push(ServicePlan { - group_name: group_name.clone(), - class_name: service_class_name(&group_name), - artifact_path, - operations, - }); - } - services.sort_by(|a, b| a.class_name.cmp(&b.class_name)); +fn plan_operation<'ir>( + operation: &'ir crate::ir::canonical::OperationDef, + method_name: MethodName, + reporter: &Reporter, +) -> Result, Diagnostic> { + let request = plan_request_contract(operation, reporter)?; + Ok(PlannedOperation { + operation_id: operation.operation_id.clone(), + request_interface: takes_input(&request).then(|| request_interface_name(&method_name)), + error_interface: (!operation.errors.is_empty()).then(|| error_interface_name(&method_name)), + method_name, + method: operation.method, + path: operation.path.clone(), + request, + response: operation.response.as_ref(), + errors: operation.errors.as_slice(), + description: operation.description.clone(), + deprecated: operation.deprecated, + }) +} - Ok(services) +/// True when the operation declares any path, query, header or body +/// input. +const fn takes_input(request: &PlannedRequestContract<'_>) -> bool { + !request.fields.is_empty() || request.body.is_some() || !request.headers.is_empty() } #[cfg(test)] @@ -246,7 +236,7 @@ mod tests { options::MappedType, }; - use crate::test_support::test_ctx; + use crate::test_support::test_reporter; fn api_model(schemas: Vec, operations: Vec) -> ApiModel { ApiModel { @@ -431,7 +421,7 @@ mod tests { #[test] fn validate_mapped_types_accepts_schemas_that_exist_in_the_ir() { - let mut ctx = test_ctx(); + let ctx = test_reporter(); let symbols = test_model_symbols(); let resolved = validate_mapped_types_against_schemas( &symbols, @@ -441,7 +431,7 @@ mod tests { ty: "ExternalUserId".to_string(), alias: Some("UserId".to_string()), }], - &ctx.reporter(), + &ctx, ) .expect("mapped types validate against IR"); @@ -454,7 +444,7 @@ mod tests { #[test] fn validate_mapped_types_rejects_schemas_missing_from_the_ir() { - let mut ctx = test_ctx(); + let ctx = test_reporter(); let err = validate_mapped_types_against_schemas( &test_model_symbols(), &[MappedType { @@ -463,7 +453,7 @@ mod tests { ty: "Missing".to_string(), alias: None, }], - &ctx.reporter(), + &ctx, ) .expect_err("missing schema should fail validation"); @@ -474,13 +464,10 @@ mod tests { #[test] fn resolve_service_plans_groups_operations_and_builds_request_contracts() { let ir = service_test_ir(); - let mut ctx = test_ctx(); - let services = resolve_service_plans( - &ir, - &crate::plan::naming::NamingResolver::default(), - &ctx.reporter(), - ) - .expect("service plan resolves"); + let ctx = test_reporter(); + let services = + resolve_service_plans(&ir, &crate::plan::naming::NamingResolver::default(), &ctx) + .expect("service plan resolves"); assert_eq!(services.len(), 2); // Services are sorted alphabetically by class_name (AdoptionRequestRest @@ -494,7 +481,7 @@ mod tests { ); let pet_service = &services[1]; - assert_eq!(pet_service.class_name, "PetRest"); + assert_eq!(pet_service.class_name.to_string(), "PetRest"); assert_eq!(pet_service.artifact_path, "rest/pet.rest.generated.ts"); assert_eq!( pet_service @@ -589,13 +576,10 @@ mod tests { }], ); - let mut ctx = test_ctx(); - let services = resolve_service_plans( - &ir, - &crate::plan::naming::NamingResolver::default(), - &ctx.reporter(), - ) - .expect("ref body stays nested even when it resolves to an inline object"); + let ctx = test_reporter(); + let services = + resolve_service_plans(&ir, &crate::plan::naming::NamingResolver::default(), &ctx) + .expect("ref body stays nested even when it resolves to an inline object"); let create_pet = &services[0].operations[0]; assert!(matches!( create_pet.request.body, @@ -653,18 +637,15 @@ mod tests { ]; let ir = api_model(Vec::new(), operations); - let mut ctx = test_ctx(); - let services = resolve_service_plans( - &ir, - &crate::plan::naming::NamingResolver::default(), - &ctx.reporter(), - ) - .expect("plans resolve"); + let ctx = test_reporter(); + let services = + resolve_service_plans(&ir, &crate::plan::naming::NamingResolver::default(), &ctx) + .expect("plans resolve"); assert_eq!( services .iter() - .map(|service| service.class_name.as_str()) + .map(|service| service.class_name.to_string()) .collect::>(), vec!["AdoptionRest", "ZooRest"] ); @@ -702,7 +683,7 @@ mod tests { headers: vec![], body: Some(PlannedRequestBody::Multipart { fields: vec![PlannedFormField { - name: "status".into(), + name: crate::ident::Ident::parse("status").expect("identifier"), optional: false, ty: &scalar, }], @@ -712,7 +693,7 @@ mod tests { panic!("expected multipart body"); }; assert_eq!(fields.len(), 1); - assert_eq!(fields[0].name.as_ref(), "status"); + assert_eq!(fields[0].name.as_str(), "status"); } #[test] diff --git a/src/plan/mod.rs b/src/plan/mod.rs index 894e715..1b0655a 100644 --- a/src/plan/mod.rs +++ b/src/plan/mod.rs @@ -1,5 +1,5 @@ -// Planning logic that turns `ApiModel` into emitter-ready service plans -// and validates mapped-type configuration against the IR. +//! Turns an `ApiModel` into the plan an emitter reads, and validates the +//! caller's mapped types against it. pub(crate) mod artifact_plan; pub mod naming; @@ -8,7 +8,7 @@ pub(crate) mod services; use crate::{ bindings::EmitTarget, error::{Diagnostic, Reporter}, - ir::canonical::ApiModel, + ir::canonical::{ApiModel, ModelSymbol}, options::GenerateConfig, }; @@ -16,23 +16,22 @@ use artifact_plan::{ ResolvedMappedType, ServicePlan, resolve_service_plans, validate_mapped_types_against_schemas, }; -/// Pre-emit plan: the validated mapped-type list shared by the model -/// emitter, plus the per-tag Angular service plans. The pipeline -/// decides which artifacts to emit by inspecting `config.emit` directly; -/// `services` is empty when Angular is not selected. +/// Everything the emitters read: the IR's model symbols, the validated +/// mapped-type list, and the per-group Angular service plans. +/// +/// `services` is empty when Angular is not among the selected targets, and +/// `mapped_types` is empty when the caller declared none. pub(crate) struct GenerationPlan<'ir> { + pub(crate) schemas: &'ir [ModelSymbol], pub(crate) mapped_types: Vec>, pub(crate) services: Vec>, } -/// Builds the pre-emit plan from the validated config and IR. All -/// cross-target validation (e.g. `emit_models` gates mapped-type -/// resolution) lives here so the pipeline is a flat sequence of -/// guarded emit calls. +/// Builds the plan for the targets `config` selects. pub(crate) fn plan_generation<'ir>( config: &GenerateConfig, ir: &'ir ApiModel, - reporter: &Reporter<'_>, + reporter: &Reporter, ) -> Result, Diagnostic> { let emit_models = config.emit.contains(&EmitTarget::Models); let emit_angular = config.emit.contains(&EmitTarget::Angular); @@ -51,6 +50,7 @@ pub(crate) fn plan_generation<'ir>( }; Ok(GenerationPlan { + schemas: &ir.schemas, mapped_types, services, }) diff --git a/src/plan/naming/case.rs b/src/plan/naming/case.rs index 2666f43..4f8b7ec 100644 --- a/src/plan/naming/case.rs +++ b/src/plan/naming/case.rs @@ -1,107 +1,75 @@ -//! Tokenizer + case transformations. Tokens are split on any -//! non-alphanumeric character (covers `_`, `-`, whitespace, punctuation) -//! and on case transitions. A run of consecutive uppercase letters is -//! treated as a single token; downstream cases title-case that token, -//! so `getURLPath` → `getUrlPath` for camelCase. -//! -//! This is the single tokenizer used both by the user-facing `case` rule -//! engine and by the project-fixed legacy helpers (`service_class_name`, -//! `service_file_stem`, `request_interface_name`, `infer_body_field_name`), -//! so all naming-side case conversions agree on edge cases. +//! The one name tokenizer and set of case renderers, shared by the +//! caller-facing `case` rule and by [`super::fixed`]. use crate::plan::naming::config::Case; -pub(crate) fn tokenize(s: &str) -> Vec { - let mut tokens: Vec = Vec::new(); - let mut current = String::new(); - let chars: Vec = s.chars().collect(); - let mut i = 0; - while i < chars.len() { - let ch = chars[i]; - if !ch.is_alphanumeric() { - if !current.is_empty() { - tokens.push(std::mem::take(&mut current)); - } - i += 1; - continue; - } - // Case transition: lowercase/digit → uppercase starts a new token. - if let Some(prev) = current.chars().last() { - let prev_lower_or_digit = prev.is_ascii_lowercase() || prev.is_ascii_digit(); - if prev_lower_or_digit && ch.is_ascii_uppercase() { - tokens.push(std::mem::take(&mut current)); - current.push(ch); - i += 1; - continue; - } - } - // Uppercase run followed by lowercase: the last uppercase belongs to - // the next token. e.g. "URLPath" → ["URL", "Path"]: when reading - // 'P' we know 'L' was the last upper, and the next char would be - // lower — but we only see the lower one char later. So at lowercase, - // if the previous two chars were upper+upper, peel the trailing - // upper into a new token. - if ch.is_ascii_lowercase() && current.len() >= 2 { - let last_two: Vec = current.chars().rev().take(2).collect(); - if last_two[0].is_ascii_uppercase() && last_two[1].is_ascii_uppercase() { - let peeled = current.pop().unwrap(); - tokens.push(std::mem::take(&mut current)); - current.push(peeled); - } - } - current.push(ch); - i += 1; - } - if !current.is_empty() { - tokens.push(current); - } - tokens +/// Splits `name` into its casing tokens, borrowing each from `name`. +/// +/// Separators are every non-alphanumeric character. A run of +/// alphanumerics splits at two case transitions: after a lowercase or +/// digit that precedes an uppercase (`listPets`), and at the last +/// uppercase of a run that is followed by a lowercase (`URLPath`). +pub(crate) const fn tokenize(name: &str) -> Tokens<'_> { + Tokens { rest: name } +} + +pub(crate) struct Tokens<'a> { + rest: &'a str, } -pub(crate) fn apply(s: &str, case: Case) -> String { - let tokens = tokenize(s); - if tokens.is_empty() { - return String::new(); +impl<'a> Iterator for Tokens<'a> { + type Item = &'a str; + + fn next(&mut self) -> Option<&'a str> { + let start = self.rest.find(char::is_alphanumeric)?; + let token = &self.rest[start..]; + let end = token_len(token); + self.rest = &token[end..]; + Some(&token[..end]) } - match case { - Case::Camel => { - let mut out = String::new(); - for (i, t) in tokens.iter().enumerate() { - if i == 0 { - out.push_str(&t.to_ascii_lowercase()); - } else { - out.push_str(&title_case(t)); - } - } - out +} + +/// Byte length of the token starting at `token`, whose first character +/// is alphanumeric. +fn token_len(token: &str) -> usize { + let mut chars = token.char_indices(); + let Some((_, first)) = chars.next() else { + return 0; + }; + let mut previous = first; + for (offset, current) in chars.clone() { + let following = chars.clone().nth(1).map(|(_, ch)| ch); + if !current.is_alphanumeric() || splits_before(previous, current, following) { + return offset; } - Case::Pascal => tokens.iter().map(|t| title_case(t)).collect(), - Case::Snake => tokens - .iter() - .map(|t| t.to_ascii_lowercase()) - .collect::>() - .join("_"), - Case::Kebab => tokens - .iter() - .map(|t| t.to_ascii_lowercase()) - .collect::>() - .join("-"), - Case::Constant => tokens - .iter() - .map(|t| t.to_ascii_uppercase()) - .collect::>() - .join("_"), + previous = current; + chars.next(); } + token.len() } -fn title_case(t: &str) -> String { - let mut chars = t.chars(); - chars.next().map_or_else(String::new, |first| { - let mut out = String::new(); - out.extend(first.to_uppercase()); - out.push_str(&chars.as_str().to_ascii_lowercase()); - out - }) +/// True when a token boundary falls immediately before `current`. +/// `following` is the character after `current`, if any. +fn splits_before(previous: char, current: char, following: Option) -> bool { + let starts_after_lower = previous.is_ascii_lowercase() || previous.is_ascii_digit(); + let ends_upper_run = previous.is_ascii_uppercase() + && current.is_ascii_uppercase() + && following.is_some_and(|ch| ch.is_ascii_lowercase()); + starts_after_lower && current.is_ascii_uppercase() || ends_upper_run +} + +/// Renders `name`'s tokens joined in the given case. +pub(crate) fn apply(name: &str, case: Case) -> String { + tokenize(name).enumerate().fold( + String::with_capacity(name.len()), + |mut out, (index, token)| { + if index > 0 { + out.push_str(case.separator()); + } + case.write_token(&mut out, token, index); + out + }, + ) } #[cfg(test)] @@ -110,37 +78,64 @@ mod tests { #[test] fn tokenize_splits_on_underscore_hyphen_space() { - assert_eq!(tokenize("get_some_thing"), vec!["get", "some", "thing"]); - assert_eq!(tokenize("get-some-thing"), vec!["get", "some", "thing"]); - assert_eq!(tokenize("get some thing"), vec!["get", "some", "thing"]); + assert_eq!( + tokenize("get_some_thing").collect::>(), + vec!["get", "some", "thing"] + ); + assert_eq!( + tokenize("get-some-thing").collect::>(), + vec!["get", "some", "thing"] + ); + assert_eq!( + tokenize("get some thing").collect::>(), + vec!["get", "some", "thing"] + ); } #[test] fn tokenize_splits_on_any_non_alphanumeric_punctuation() { - assert_eq!(tokenize("get.some/thing"), vec!["get", "some", "thing"]); - assert_eq!(tokenize("get!some@thing"), vec!["get", "some", "thing"]); - assert_eq!(tokenize("a__b---c"), vec!["a", "b", "c"]); + assert_eq!( + tokenize("get.some/thing").collect::>(), + vec!["get", "some", "thing"] + ); + assert_eq!( + tokenize("get!some@thing").collect::>(), + vec!["get", "some", "thing"] + ); + assert_eq!( + tokenize("a__b---c").collect::>(), + vec!["a", "b", "c"] + ); } #[test] fn tokenize_splits_on_camel_case_transition() { - assert_eq!(tokenize("getSomeThing"), vec!["get", "Some", "Thing"]); + assert_eq!( + tokenize("getSomeThing").collect::>(), + vec!["get", "Some", "Thing"] + ); } #[test] fn tokenize_treats_consecutive_uppercase_as_single_token() { // From the spec example. - assert_eq!(tokenize("getURLPath"), vec!["get", "URL", "Path"]); + assert_eq!( + tokenize("getURLPath").collect::>(), + vec!["get", "URL", "Path"] + ); } #[test] fn tokenize_handles_trailing_uppercase_run() { - assert_eq!(tokenize("parseURL"), vec!["parse", "URL"]); + assert_eq!( + tokenize("parseURL").collect::>(), + vec!["parse", "URL"] + ); } #[test] fn tokenize_handles_leading_uppercase_run() { - assert_eq!(tokenize("URLPath"), vec!["URL", "Path"]); + assert_eq!(tokenize("URLPath").collect::>(), vec!["URL", "Path"]); } #[test] diff --git a/src/plan/naming/config.rs b/src/plan/naming/config.rs index 4fd6d48..fd09150 100644 --- a/src/plan/naming/config.rs +++ b/src/plan/naming/config.rs @@ -1,7 +1,4 @@ -//! Internal representation of the user-facing `NamingConfig`. The NAPI -//! boundary projects `bindings::NamingOptions` into this shape after -//! flag-validating each parse spec and unwrapping the JS RegExp into -//! `{ source, flags }`. +//! The validated, regex-compiled form of the caller's naming config. use crate::plan::naming::parse_spec::CompiledParseSpec; @@ -17,10 +14,10 @@ pub(crate) enum Naming { Chain(Vec), } -/// A single entry in a chain — either a bare format-string shorthand or -/// a full `Rule`. The shorthand is equivalent to `Rule { format: -/// Some(s), case: None, .. }`; we keep them distinct so config-time -/// error messages can name the source form precisely. +/// One entry of a fallback chain. +/// +/// `Shorthand(s)` behaves as `Rule { format: Some(s), .. }`; the two stay +/// distinct so a config error can name the form the caller wrote. #[derive(Debug, Clone)] pub(crate) enum RuleEntry { Shorthand(String), @@ -57,6 +54,43 @@ impl Case { } } +impl Case { + /// Text inserted between adjacent tokens. + pub(crate) const fn separator(self) -> &'static str { + match self { + Self::Camel | Self::Pascal => "", + Self::Snake | Self::Constant => "_", + Self::Kebab => "-", + } + } + + /// Appends `token` to `out` in the casing this style uses at `index`. + pub(crate) fn write_token(self, out: &mut String, token: &str, index: usize) { + match self { + Self::Camel if index == 0 => push_lower(out, token), + Self::Camel | Self::Pascal => push_title(out, token), + Self::Snake | Self::Kebab => push_lower(out, token), + Self::Constant => push_upper(out, token), + } + } +} + +fn push_lower(out: &mut String, token: &str) { + out.extend(token.chars().map(|ch| ch.to_ascii_lowercase())); +} + +fn push_upper(out: &mut String, token: &str) { + out.extend(token.chars().map(|ch| ch.to_ascii_uppercase())); +} + +fn push_title(out: &mut String, token: &str) { + let mut chars = token.chars(); + if let Some(first) = chars.next() { + out.extend(first.to_uppercase()); + out.extend(chars.map(|ch| ch.to_ascii_lowercase())); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/plan/naming/context.rs b/src/plan/naming/context.rs index d30280d..481974b 100644 --- a/src/plan/naming/context.rs +++ b/src/plan/naming/context.rs @@ -1,6 +1,5 @@ -//! `OperationContext` — the read-only bag of values a `Rule.from` / -//! `Rule.format` template can reference for one operation. Fields map -//! 1:1 to the spec's "Context fields" table. +//! The read-only values a naming rule's template can reference for one +//! operation. use std::collections::BTreeMap; @@ -8,88 +7,92 @@ use crate::ir::canonical::OperationDef; #[derive(Debug)] pub(crate) struct OperationContext<'a> { - pub(crate) operation_id: Option<&'a str>, - pub(crate) method: String, // lowercased - pub(crate) path: &'a str, - pub(crate) path_segments: Vec, - pub(crate) tags: &'a [String], - pub(crate) extensions: BTreeMap, // x- → string - // contentType / statusCode are unbound here - // until those carriers exist on OperationDef. + operation_id: Option<&'a str>, + /// Lower-case method name, as the spec writes it. + method: &'static str, + path: &'a str, + /// Path split on `/`, empty segments dropped, `{name}` unwrapped to + /// `name`. + path_segments: Vec<&'a str>, + tags: &'a [String], + /// `x-` vendor extensions. Empty, since `OperationDef` does not + /// carry them: an `{x-foo}` reference stays unbound. + extensions: BTreeMap, } impl<'a> OperationContext<'a> { pub(crate) fn from_operation(operation: &'a OperationDef) -> Self { Self { - operation_id: if operation.operation_id.is_empty() { - None - } else { - Some(operation.operation_id.as_str()) - }, - method: operation.method.as_str().to_ascii_lowercase(), + operation_id: Some(operation.operation_id.as_str()).filter(|id| !id.is_empty()), + method: operation.method.as_lowercase(), path: operation.path.as_str(), path_segments: clean_path_segments(operation.path.as_str()), tags: operation.tags.as_slice(), - // `vendor_extensions` does not yet exist on `OperationDef`; an - // empty map keeps `{x-foo}` references unbound (triggering - // fallback) and the carrier can be plumbed through normalize - // later without touching the engine. extensions: BTreeMap::new(), } } - /// Lookup by template name. Returns `None` for unbound names — the - /// caller turns that into a rule failure. - pub(crate) fn lookup(&self, name: &str) -> Option { + /// Looks up a bare field name. `None` means unbound, which the caller + /// turns into a rule failure. + pub(crate) fn lookup(&self, name: &str) -> Option<&str> { match name { - "operationId" => self.operation_id.map(str::to_string), - "method" => Some(self.method.clone()), - "path" => Some(self.path.to_string()), - _ if name.starts_with("x-") => self.extensions.get(name).cloned(), + "operationId" => self.operation_id, + "method" => Some(self.method), + "path" => Some(self.path), + _ if name.starts_with("x-") => self.extensions.get(name).map(String::as_str), _ => None, } } - /// Lookup with array indexing: `pathSegments[0]`, `tags[-1]`, etc. - /// Negative indexes count from the tail. Out-of-bounds is unbound. - pub(crate) fn lookup_indexed(&self, array_name: &str, index: i32) -> Option { - let slice: Vec<&str> = match array_name { - "pathSegments" => self.path_segments.iter().map(String::as_str).collect(), - "tags" => self.tags.iter().map(String::as_str).collect(), - _ => return None, - }; - resolve_index(slice.len(), index).map(|i| slice[i].to_string()) + /// Looks up an array element: `pathSegments[0]`, `tags[-1]`. A negative + /// index counts from the tail; out of bounds is unbound. + pub(crate) fn lookup_indexed(&self, array: &str, index: i32) -> Option<&str> { + match array { + "pathSegments" => element(&self.path_segments, index).copied(), + "tags" => element(self.tags, index).map(String::as_str), + _ => None, + } + } + + /// Path segments joined with `_`, for the default method name. + pub(crate) fn path_segments_joined(&self) -> String { + self.path_segments.join("_") + } + + pub(crate) const fn tags(&self) -> &'a [String] { + self.tags + } + + pub(crate) const fn method(&self) -> &'static str { + self.method + } + + pub(crate) const fn operation_id(&self) -> Option<&'a str> { + self.operation_id } } -const fn resolve_index(len: usize, index: i32) -> Option { +/// Resolves a possibly-negative index against `items`. +fn element(items: &[T], index: i32) -> Option<&T> { if index >= 0 { - let i = index as usize; - if i < len { Some(i) } else { None } - } else { - let from_tail = (-index) as usize; - if from_tail == 0 || from_tail > len { - None - } else { - Some(len - from_tail) - } + return items.get(usize::try_from(index).ok()?); } + let from_tail = usize::try_from(-index).ok()?; + items + .len() + .checked_sub(from_tail) + .and_then(|i| items.get(i)) } -/// Clean a path per spec: -/// * drop leading empty segment (leading `/`) -/// * drop trailing empty segment (trailing `/`) -/// * unwrap `{name}` → `name` (literal content between braces) -fn clean_path_segments(path: &str) -> Vec { +fn clean_path_segments(path: &str) -> Vec<&str> { path .split('/') - .filter(|s| !s.is_empty()) - .map(|s| { - if s.starts_with('{') && s.ends_with('}') && s.len() >= 2 { - s[1..s.len() - 1].to_string() - } else { - s.to_string() - } + .filter(|segment| !segment.is_empty()) + .map(|segment| { + segment + .strip_prefix('{') + .and_then(|inner| inner.strip_suffix('}')) + .unwrap_or(segment) }) .collect() } @@ -105,7 +108,7 @@ mod tests { fn op(operation_id: &str, method: HttpMethod, path: &str, tags: &[&str]) -> OperationDef { OperationDef { operation_id: operation_id.to_string(), - tags: tags.iter().map(|s| s.to_string()).collect(), + tags: tags.iter().map(ToString::to_string).collect(), method, path: path.to_string(), request: RequestDef::default(), @@ -138,9 +141,9 @@ mod tests { fn lookup_returns_operation_id_method_and_path() { let operation = op("listPets", HttpMethod::Get, "/pets", &["Pet"]); let ctx = OperationContext::from_operation(&operation); - assert_eq!(ctx.lookup("operationId").as_deref(), Some("listPets")); - assert_eq!(ctx.lookup("method").as_deref(), Some("get")); - assert_eq!(ctx.lookup("path").as_deref(), Some("/pets")); + assert_eq!(ctx.lookup("operationId"), Some("listPets")); + assert_eq!(ctx.lookup("method"), Some("get")); + assert_eq!(ctx.lookup("path"), Some("/pets")); } #[test] @@ -154,14 +157,8 @@ mod tests { fn lookup_indexed_supports_positive_and_negative_path_indexes() { let operation = op("x", HttpMethod::Get, "/users/{id}/posts", &[]); let ctx = OperationContext::from_operation(&operation); - assert_eq!( - ctx.lookup_indexed("pathSegments", 0).as_deref(), - Some("users") - ); - assert_eq!( - ctx.lookup_indexed("pathSegments", -1).as_deref(), - Some("posts") - ); + assert_eq!(ctx.lookup_indexed("pathSegments", 0), Some("users")); + assert_eq!(ctx.lookup_indexed("pathSegments", -1), Some("posts")); assert_eq!(ctx.lookup_indexed("pathSegments", 5), None); } diff --git a/src/plan/naming/defaults.rs b/src/plan/naming/defaults.rs index ea9ef73..75c604d 100644 --- a/src/plan/naming/defaults.rs +++ b/src/plan/naming/defaults.rs @@ -1,50 +1,43 @@ -//! Hardcoded defaults — applied when the user did not configure a -//! `Naming` for a given key. The spec says these are NOT expressed as -//! `Rule` chains, so they live as plain Rust here. -//! -//! Defaults: -//! * methodName: camelCase(operationId), else camelCase(method + '_' + path segments joined by `_`). -//! Errors if both fail. -//! * group: pascalCase(tags[0]), else pascalCase(pathSegments[0]), else "Default". +//! The naming applied when the caller configured no rule for a key. use crate::plan::naming::{case::apply as apply_case, config::Case, context::OperationContext}; +/// The method name has no source: neither an `operationId` nor a usable +/// path segment. #[derive(Debug)] -pub(crate) enum DefaultMethodNameFailure { - /// Neither operationId nor a usable path-segment fallback was available. - NoSource, -} +pub(crate) struct NoMethodNameSource; +/// camelCase of `operationId`; failing that, camelCase of the method joined +/// with the path segments. pub(crate) fn default_method_name( ctx: &OperationContext<'_>, -) -> Result { - if let Some(id) = ctx.operation_id - && !id.is_empty() - { +) -> Result { + if let Some(id) = ctx.operation_id() { return Ok(apply_case(id, Case::Camel)); } - if !ctx.path_segments.is_empty() { - let suffix = ctx.path_segments.join("_"); - return Ok(apply_case( - &format!("{}_{}", ctx.method, suffix), - Case::Camel, - )); + let segments = ctx.path_segments_joined(); + if segments.is_empty() { + return Err(NoMethodNameSource); } - Err(DefaultMethodNameFailure::NoSource) + Ok(apply_case( + &format!("{}_{segments}", ctx.method()), + Case::Camel, + )) } +/// PascalCase of the first tag; failing that, of the first path segment; +/// failing that, `Default`. pub(crate) fn default_group(ctx: &OperationContext<'_>) -> String { - if let Some(tag) = ctx.tags.first() - && !tag.is_empty() - { - return apply_case(tag, Case::Pascal); - } - if let Some(segment) = ctx.path_segments.first() - && !segment.is_empty() - { - return apply_case(segment, Case::Pascal); - } - "Default".to_string() + ctx + .tags() + .first() + .map(String::as_str) + .or_else(|| ctx.lookup_indexed("pathSegments", 0)) + .filter(|source| !source.is_empty()) + .map_or_else( + || "Default".to_string(), + |source| apply_case(source, Case::Pascal), + ) } #[cfg(test)] @@ -58,7 +51,7 @@ mod tests { fn op(id: &str, method: HttpMethod, path: &str, tags: &[&str]) -> OperationDef { OperationDef { operation_id: id.to_string(), - tags: tags.iter().map(|s| s.to_string()).collect(), + tags: tags.iter().map(ToString::to_string).collect(), method, path: path.to_string(), request: RequestDef::default(), @@ -89,10 +82,7 @@ mod tests { fn default_method_name_errors_when_no_operation_id_and_path_is_empty() { let operation = op("", HttpMethod::Get, "/", &[]); let ctx = OperationContext::from_operation(&operation); - assert!(matches!( - default_method_name(&ctx), - Err(DefaultMethodNameFailure::NoSource) - )); + assert!(matches!(default_method_name(&ctx), Err(NoMethodNameSource))); } #[test] diff --git a/src/plan/naming/engine.rs b/src/plan/naming/engine.rs index 422c2ca..b2cea18 100644 --- a/src/plan/naming/engine.rs +++ b/src/plan/naming/engine.rs @@ -1,6 +1,5 @@ -//! Single-rule evaluator and fallback-chain runner. Failure modes per -//! spec §"Failure modes": empty `from` + present `parse`, regex -//! mismatch, or any unbound name reference in `from`/`format`. +//! Evaluates one rule, and runs a fallback chain until an entry +//! succeeds. [`RuleFailure`] enumerates every way an entry can fail. use std::collections::HashMap; @@ -19,9 +18,7 @@ pub(crate) enum RuleFailure { ParseMismatch, /// A template referenced an unbound name (field, indexed slot, or capture). Unbound(String), - /// Template was malformed at parse time. This is technically a - /// config-time error caught by validation, but evaluation still has - /// to handle it defensively. + /// A template was malformed. Malformed(String), } @@ -54,13 +51,11 @@ fn evaluate_entry(entry: &RuleEntry, ctx: &OperationContext<'_>) -> Result) -> Result { - // Step 1: expand `from` (default "" if omitted). let from_expanded = match &rule.from { Some(template) => expand(template, ctx, &HashMap::new()).map_err(map_template_error)?, None => String::new(), }; - // Step 2: parse — only runs when present. let captures: HashMap = match &rule.parse { Some(spec) => { if from_expanded.is_empty() { @@ -84,14 +79,13 @@ fn evaluate_rule(rule: &Rule, ctx: &OperationContext<'_>) -> Result HashMap::new(), }; - // Step 3: format — defaults to the expanded `from` when omitted (only - // legal when `parse` is also absent; config-time validation enforces). + // Without a `format` the result is the expanded `from`, which + // `plan::naming::lower` allows only when `parse` is absent too. let raw = match &rule.format { Some(template) => expand(template, ctx, &captures).map_err(map_template_error)?, None => from_expanded, }; - // Step 4: case transformation. let final_value = match rule.case { Some(case) => apply_case(&raw, case), None => raw, @@ -124,7 +118,7 @@ mod tests { fn op(id: &str, method: HttpMethod, path: &str, tags: &[&str]) -> OperationDef { OperationDef { operation_id: id.to_string(), - tags: tags.iter().map(|s| s.to_string()).collect(), + tags: tags.iter().map(ToString::to_string).collect(), method, path: path.to_string(), request: RequestDef::default(), diff --git a/src/plan/naming/legacy.rs b/src/plan/naming/fixed.rs similarity index 60% rename from src/plan/naming/legacy.rs rename to src/plan/naming/fixed.rs index e7d66f5..b1dabe4 100644 --- a/src/plan/naming/legacy.rs +++ b/src/plan/naming/fixed.rs @@ -1,61 +1,62 @@ -use crate::plan::naming::{case::apply as apply_case, config::Case}; - -/// Returns the PascalCase class name for a service tag, e.g. "pet" → "PetRest". -pub(crate) fn service_class_name(tag: &str) -> String { - format!("{}Rest", apply_case(tag, Case::Pascal)) +use crate::{ + ident::{MethodName, TypeName}, + plan::naming::{case::apply as apply_case, config::Case}, +}; + +/// PascalCase service class name for a group, e.g. `"pet"` → `PetRest`. +pub(crate) fn service_class_name(group: &str) -> TypeName { + TypeName::new(format!("{}Rest", apply_case(group, Case::Pascal))) } -/// Returns the kebab-case file stem for a service tag, e.g. "PetOrder" → "pet-order". -pub(crate) fn service_file_stem(tag: &str) -> String { - apply_case(tag, Case::Kebab) +/// Kebab-case file stem for a group, e.g. `"PetOrder"` → `pet-order`. +pub(crate) fn service_file_stem(group: &str) -> String { + apply_case(group, Case::Kebab) } -/// Returns the PascalCase synthesized envelope name for an operation's -/// path/query/header/body fields, e.g. "listPets" → "ListPetsParams". -/// -/// Suffixed with `Params` (not `Request`) to avoid colliding with body -/// schemas named `Request` declared in the spec. +/// PascalCase name of the interface carrying an operation's path, query, +/// header and body fields, e.g. `listPets` → `ListPetsParams`. /// -/// Input is the resolved `method_name` (post user naming-rules), not the -/// raw spec `operationId`. Naming rules can rewrite e.g. -/// `Pet_listPets` → `listPets`, and the emitted `*Params` interface -/// must follow that rewrite so the per-operation surfaces stay -/// aligned with the property name on the service class. -pub(crate) fn request_interface_name(method_name: &str) -> String { - format!("{}Params", apply_case(method_name, Case::Pascal)) +/// Suffixed `Params`: a spec may already declare a schema named +/// `Request`. +pub(crate) fn request_interface_name(method_name: &MethodName) -> TypeName { + TypeName::new(format!( + "{}Params", + apply_case(method_name.as_str(), Case::Pascal) + )) } -/// Returns the PascalCase error-body interface name for an operation, -/// e.g. "updatePet" → "UpdatePetError". Suffixed with `Error` (not -/// `ErrorBody`) for ergonomics — the user-facing access pattern is -/// `UpdatePetError[400]`, so the shorter suffix reads better at the -/// call site. Risk of colliding with a spec schema named -/// `Error` is real but uncommon; if it bites consumers we -/// can switch to `ErrorBody` later. +/// PascalCase name of the interface mapping an operation's 4xx/5xx statuses +/// to their body types, e.g. `updatePet` → `UpdatePetError`. /// -/// Input is the resolved `method_name` (post user naming-rules), same -/// as `request_interface_name`. -pub(crate) fn error_interface_name(method_name: &str) -> String { - format!("{}Error", apply_case(method_name, Case::Pascal)) +/// Read at the call site as `UpdatePetError[400]`. +pub(crate) fn error_interface_name(method_name: &MethodName) -> TypeName { + TypeName::new(format!( + "{}Error", + apply_case(method_name.as_str(), Case::Pascal) + )) } #[cfg(test)] mod tests { use super::*; + fn method(name: &str) -> MethodName { + MethodName::new(name.to_string()) + } + #[test] fn service_class_name_converts_lowercase_tag_to_pascal_case_rest_suffix() { - assert_eq!(service_class_name("pet"), "PetRest"); + assert_eq!(service_class_name("pet").to_string(), "PetRest"); } #[test] fn service_class_name_converts_camel_case_tag_to_pascal_case_rest_suffix() { - assert_eq!(service_class_name("petOrder"), "PetOrderRest"); + assert_eq!(service_class_name("petOrder").to_string(), "PetOrderRest"); } #[test] fn service_class_name_converts_kebab_tag_to_pascal_case_rest_suffix() { - assert_eq!(service_class_name("pet-order"), "PetOrderRest"); + assert_eq!(service_class_name("pet-order").to_string(), "PetOrderRest"); } #[test] @@ -70,15 +71,20 @@ mod tests { #[test] fn request_interface_name_converts_camel_case_method_name_to_pascal_params() { - assert_eq!(request_interface_name("listPets"), "ListPetsParams"); + assert_eq!( + request_interface_name(&method("listPets")).to_string(), + "ListPetsParams" + ); } #[test] fn request_interface_name_converts_lower_method_name_to_pascal_params() { - assert_eq!(request_interface_name("updatePet"), "UpdatePetParams"); + assert_eq!( + request_interface_name(&method("updatePet")).to_string(), + "UpdatePetParams" + ); } - // ── Property-based: naming helpers ────────────────────────────────────── // // service_class_name and service_file_stem are pure case-conversions // over arbitrary tag strings sourced from the spec. The example tests @@ -90,7 +96,7 @@ mod tests { /// First char must satisfy TS IdentifierStart (we restrict to ASCII /// alphabetic + `_` + `$`); subsequent chars must be IdentifierPart. - /// Matches `is_valid_identifier` in `emit::typescript`. + /// Matches `is_ident` in `emit::typescript`. fn is_ts_identifier(value: &str) -> bool { let mut chars = value.chars(); let Some(first) = chars.next() else { @@ -139,10 +145,11 @@ mod tests { fn service_class_name_emits_valid_ts_identifier_with_rest_suffix( tag in "[a-zA-Z][a-zA-Z0-9_-]{0,31}" ) { - let class_name = service_class_name(&tag); + let class_name = service_class_name(&tag).to_string(); + let class_name = class_name.as_str(); prop_assert!(class_name.ends_with("Rest")); prop_assert!( - is_ts_identifier(&class_name), + is_ts_identifier(class_name), "service_class_name produced non-identifier {class_name:?} for tag {tag:?}", ); } diff --git a/src/plan/naming/lower.rs b/src/plan/naming/lower.rs new file mode 100644 index 0000000..8bb4615 --- /dev/null +++ b/src/plan/naming/lower.rs @@ -0,0 +1,145 @@ +//! Lowering of the NAPI-boundary `NamingOptions` into the internal +//! [`NamingConfig`]. +//! +//! The boundary has no sum type, so a `NamingValue` arrives as three +//! exclusive optional fields. Every failure here is `E_INVALID_OPTION`. + +use crate::{ + bindings::{NamingOptions, NamingRuleEntry, NamingValue}, + error::{Diagnostic, DiagnosticCode, Reporter, bail}, + plan::naming::{ + config::{Case, Naming, NamingConfig, Rule, RuleEntry}, + parse_spec::compile as compile_parse_spec, + }, +}; + +/// Lowers the caller's naming options. Absent options yield the default +/// config, where each key falls back to its hardcoded default. +pub(crate) fn lower( + options: Option, + reporter: &Reporter, +) -> Result { + let Some(options) = options else { + return Ok(NamingConfig::default()); + }; + Ok(NamingConfig { + method_name: lower_value(options.method_name, "methodName", reporter)?, + group: lower_value(options.group, "group", reporter)?, + }) +} + +/// Lowers one key's value: a bare format string, a single rule, or a +/// fallback chain of either. +fn lower_value( + value: Option, + key: &str, + reporter: &Reporter, +) -> Result, Diagnostic> { + let Some(value) = value else { + return Ok(None); + }; + + let declared = u8::from(value.string.is_some()) + + u8::from(value.rule.is_some()) + + u8::from(value.chain.is_some()); + if declared != 1 { + bail!( + reporter, + DiagnosticCode::InvalidOption, + "naming.{key}: must set exactly one of `string`, `rule`, or `chain` (got {declared})." + ); + } + + if let Some(format) = value.string { + return Ok(Some(Naming::Single(RuleEntry::Shorthand(format)))); + } + if let Some(rule) = value.rule { + return Ok(Some(Naming::Single(lower_rule(rule, key, reporter)?))); + } + + let items = value.chain.unwrap_or_default(); + let entries = items + .into_iter() + .enumerate() + .map(|(index, item)| lower_entry(item.string, item.rule, &format!("{key}[{index}]"), reporter)) + .collect::, Diagnostic>>()?; + Ok(Some(Naming::Chain(entries))) +} + +/// Lowers one chain item, which must set exactly one of `string` or `rule`. +fn lower_entry( + shorthand: Option, + rule: Option, + path: &str, + reporter: &Reporter, +) -> Result { + match (shorthand, rule) { + (Some(format), None) => Ok(RuleEntry::Shorthand(format)), + (None, Some(rule)) => lower_rule(rule, path, reporter), + (Some(_), Some(_)) => bail!( + reporter, + DiagnosticCode::InvalidOption, + "naming.{path}: a chain item cannot set both `string` and `rule`." + ), + (None, None) => bail!( + reporter, + DiagnosticCode::InvalidOption, + "naming.{path}: a chain item must set exactly one of `string` or `rule`." + ), + } +} + +fn lower_rule( + rule: NamingRuleEntry, + path: &str, + reporter: &Reporter, +) -> Result { + let case = rule + .case_ + .as_deref() + .map(|name| lower_case(name, path, reporter)) + .transpose()?; + + let parse = rule + .parse + .map(|spec| { + compile_parse_spec(&spec.source, &spec.flags).map_err(|error| { + reporter.error( + DiagnosticCode::InvalidOption, + format!( + "naming.{path}.parse: failed to compile regex `{}` (flags=`{}`): {error:?}", + spec.source, spec.flags, + ), + ) + }) + }) + .transpose()?; + + // Without a `format`, the rule's output is the expanded `from` — which + // would discard every capture the regex just produced. + if parse.is_some() && rule.format.is_none() { + bail!( + reporter, + DiagnosticCode::InvalidOption, + "naming.{path}: when `parse` is present, `format` is required." + ); + } + + Ok(RuleEntry::Rule(Rule { + from: rule.from, + parse, + format: rule.format, + case, + })) +} + +fn lower_case(name: &str, path: &str, reporter: &Reporter) -> Result { + Case::parse(name).ok_or_else(|| { + reporter.error( + DiagnosticCode::InvalidOption, + format!( + "naming.{path}.case: '{name}' is not one of 'camel', 'pascal', 'snake', 'kebab', 'constant'." + ), + ) + }) +} diff --git a/src/plan/naming/mod.rs b/src/plan/naming/mod.rs index 8be80ab..4460de7 100644 --- a/src/plan/naming/mod.rs +++ b/src/plan/naming/mod.rs @@ -1,44 +1,37 @@ -//! Naming module — pre-emit derivation of `methodName` and `group` for -//! each operation, plus the formatting helpers (class name, file stem, -//! request-interface name, body-field inference) that consume those -//! resolved names. +//! Derives each operation's `methodName` and `group`, and formats the +//! type names built from them. //! -//! Submodules: -//! * `legacy` — formatting helpers fixed by the project (not user-configurable). -//! * `config`, `context`, `template`, `case`, `parse_spec`, `engine`, -//! `defaults` — the rule engine described in `docs/naming-spec.md`. -//! -//! Public surface (re-exported here): `NamingResolver`, the `NamingConfig` -//! / `Naming` / `Rule` / `RuleEntry` / `Case` types, the `compile_parse_spec` -//! helper, and the four legacy formatting helpers. +//! [`fixed`] holds the formatting the project fixes; every other submodule +//! belongs to the caller-configurable rule engine, whose entry point is +//! [`NamingResolver`]. mod case; mod config; mod context; mod defaults; mod engine; -mod legacy; +mod fixed; +mod lower; mod parse_spec; mod template; pub use config::NamingConfig; -pub(crate) use config::{Case, Naming, Rule, RuleEntry}; -pub(crate) use legacy::{ +pub(crate) use fixed::{ error_interface_name, request_interface_name, service_class_name, service_file_stem, }; -pub(crate) use parse_spec::compile as compile_parse_spec; +pub(crate) use lower::lower; use crate::{ error::{Diagnostic, Reporter}, + ident::MethodName, ir::canonical::OperationDef, }; use context::OperationContext; use defaults::{default_group, default_method_name}; use engine::{RuleFailure, evaluate_chain}; -/// Resolved-naming entry point used by the planner. Holds the -/// user-supplied (validated, regex-compiled) config and exposes -/// per-operation lookups. +/// Resolves a name per operation, from the caller's config or from the +/// hardcoded default when a key is unconfigured. #[derive(Debug, Clone, Default)] pub(crate) struct NamingResolver { pub(crate) config: NamingConfig, @@ -52,10 +45,10 @@ impl NamingResolver { pub(crate) fn method_name( &self, operation: &OperationDef, - reporter: &Reporter<'_>, - ) -> Result { + reporter: &Reporter, + ) -> Result { let ctx = OperationContext::from_operation(operation); - self.config.method_name.as_ref().map_or_else( + let name = self.config.method_name.as_ref().map_or_else( || { default_method_name(&ctx).map_err(|_| { Diagnostic::policy_violation( @@ -73,13 +66,14 @@ impl NamingResolver { naming_resolution_error(reporter, "methodName", operation, &failures) }) }, - ) + )?; + Ok(MethodName::new(name)) } pub(crate) fn group( &self, operation: &OperationDef, - reporter: &Reporter<'_>, + reporter: &Reporter, ) -> Result { let ctx = OperationContext::from_operation(operation); self.config.group.as_ref().map_or_else( @@ -93,7 +87,7 @@ impl NamingResolver { } fn naming_resolution_error( - reporter: &Reporter<'_>, + reporter: &Reporter, key: &str, operation: &OperationDef, failures: &[RuleFailure], @@ -127,6 +121,8 @@ fn format_failure(failure: &RuleFailure) -> String { #[cfg(test)] mod tests { + use super::config::{Case, Naming, Rule, RuleEntry}; + use super::parse_spec::compile as compile_parse_spec; use super::*; use crate::{ error::DiagnosticCode, @@ -134,13 +130,13 @@ mod tests { canonical::{HttpMethod, OperationDef, RequestDef, ResponseContent}, schema::{SchemaScalar, SchemaType}, }, - test_support::test_ctx, + test_support::test_reporter, }; fn op(id: &str, tags: &[&str], path: &str) -> OperationDef { OperationDef { operation_id: id.to_string(), - tags: tags.iter().map(|s| s.to_string()).collect(), + tags: tags.iter().map(ToString::to_string).collect(), method: HttpMethod::Get, path: path.to_string(), request: RequestDef::default(), @@ -156,10 +152,10 @@ mod tests { #[test] fn naming_resolver_returns_default_method_name_when_unconfigured() { let resolver = NamingResolver::default(); - let mut ctx = test_ctx(); + let ctx = test_reporter(); let operation = op("list_pets", &["Pet"], "/pets"); assert_eq!( - resolver.method_name(&operation, &ctx.reporter()).unwrap(), + resolver.method_name(&operation, &ctx).unwrap().as_str(), "listPets" ); } @@ -167,12 +163,9 @@ mod tests { #[test] fn naming_resolver_returns_default_group_when_unconfigured() { let resolver = NamingResolver::default(); - let mut ctx = test_ctx(); + let ctx = test_reporter(); let operation = op("x", &["pet-orders"], "/pets"); - assert_eq!( - resolver.group(&operation, &ctx.reporter()).unwrap(), - "PetOrders" - ); + assert_eq!(resolver.group(&operation, &ctx).unwrap(), "PetOrders"); } #[test] @@ -187,10 +180,10 @@ mod tests { method_name: Some(chain), group: None, }); - let mut ctx = test_ctx(); + let ctx = test_reporter(); let operation = op("posts_listAll", &["Posts"], "/posts"); assert_eq!( - resolver.method_name(&operation, &ctx.reporter()).unwrap(), + resolver.method_name(&operation, &ctx).unwrap().as_str(), "listAll" ); } @@ -202,11 +195,9 @@ mod tests { method_name: Some(chain), group: None, }); - let mut ctx = test_ctx(); + let ctx = test_reporter(); let operation = op("x", &["Pet"], "/pets"); - let err = resolver - .method_name(&operation, &ctx.reporter()) - .unwrap_err(); + let err = resolver.method_name(&operation, &ctx).unwrap_err(); assert_eq!(err.code, DiagnosticCode::PolicyViolation); assert_eq!(err.subcode, Some("naming-resolution")); assert!(err.message.contains("methodName")); diff --git a/src/plan/naming/parse_spec.rs b/src/plan/naming/parse_spec.rs index a892dac..188ce98 100644 --- a/src/plan/naming/parse_spec.rs +++ b/src/plan/naming/parse_spec.rs @@ -1,7 +1,5 @@ -//! Compiled user-supplied `parse` regex. The NAPI boundary delivers the -//! source pattern and flags string (split out from the JS RegExp on the -//! wrapper side); we compile here once at config time so per-operation -//! evaluation is a cheap `regex.captures()` call. +//! A caller's `parse` regex, compiled once so evaluating a rule is a +//! `captures` call. use regex::{Regex, RegexBuilder}; @@ -10,20 +8,17 @@ pub(crate) struct CompiledParseSpec { pub(crate) regex: Regex, } -/// Why a `parse` spec could not be compiled. Surfaced at config-validation -/// time as an `E_INVALID_OPTION` diagnostic so the user sees the error -/// before any generation work runs. +/// Why a `parse` spec could not be compiled. #[derive(Debug, PartialEq, Eq)] pub(crate) enum CompileError { UnsupportedFlag(char), InvalidPattern(String), } -/// Compile a user `parse` regex. Supported flags: `i`, `m`, `s` (subset -/// of JS RegExp that maps cleanly to Rust's `regex` crate). Any other -/// flag — including `g`/`y`/`u` — is rejected loudly rather than -/// silently ignored, so JS authors don't get surprised when their JS -/// pattern relies on a flag the Rust engine cannot honour. +/// Compiles a `parse` regex, accepting the flags `i`, `m` and `s`. +/// +/// Every other flag fails, `g`, `y` and `u` included: Rust's engine has +/// no equivalent, and ignoring one would silently change the match. pub(crate) fn compile(source: &str, flags: &str) -> Result { let mut builder = RegexBuilder::new(source); for ch in flags.chars() { diff --git a/src/plan/naming/template.rs b/src/plan/naming/template.rs index 2e0fd55..99db696 100644 --- a/src/plan/naming/template.rs +++ b/src/plan/naming/template.rs @@ -1,11 +1,10 @@ -//! Template expander. Supports exactly three productions: -//! * `{fieldName}` — context field by name -//! * `{arrayField[N]}` — array index (negative allowed) -//! * `{capture.name}` — regex named capture (explicit namespace) +//! Template expander for `Rule.from` and `Rule.format`. //! -//! Unbound references and malformed templates surface as -//! `TemplateError`; the rule evaluator converts these into rule failures -//! per spec §"Failure modes". +//! Three productions, and nothing else: +//! * `{fieldName}` — a context field +//! * `{arrayField[N]}` — an array element, negative indexes counting from +//! the tail +//! * `{capture.name}` — a named capture from the rule's `parse` use std::collections::HashMap; @@ -13,68 +12,71 @@ use crate::plan::naming::context::OperationContext; #[derive(Debug, PartialEq, Eq)] pub(crate) enum TemplateError { - /// `{...}` referenced a name not in the context. + /// A `{...}` named something the context does not bind. Unbound(String), - /// Malformed template: unclosed `{`, malformed index, etc. + /// Unclosed `{`, or an index that is not an integer. Malformed(String), } +/// Expands every `{...}` in `template`. pub(crate) fn expand( template: &str, ctx: &OperationContext<'_>, captures: &HashMap, ) -> Result { let mut out = String::with_capacity(template.len()); - let chars: Vec = template.chars().collect(); - let mut i = 0; - while i < chars.len() { - let ch = chars[i]; - if ch == '{' { - let end = chars[i..] - .iter() - .position(|c| *c == '}') - .ok_or_else(|| TemplateError::Malformed(format!("unclosed `{{` at offset {i}")))?; - let token: String = chars[i + 1..i + end].iter().collect(); - out.push_str(&resolve_token(&token, ctx, captures)?); - i += end + 1; - } else { - out.push(ch); - i += 1; - } + let mut rest = template; + + while let Some(open) = rest.find('{') { + out.push_str(&rest[..open]); + let after = &rest[open + 1..]; + let Some(close) = after.find('}') else { + return Err(TemplateError::Malformed(format!( + "unclosed `{{` in `{template}`" + ))); + }; + out.push_str(&resolve(&after[..close], ctx, captures)?); + rest = &after[close + 1..]; } + + out.push_str(rest); Ok(out) } -fn resolve_token( +fn resolve( token: &str, ctx: &OperationContext<'_>, captures: &HashMap, ) -> Result { - if let Some(rest) = token.strip_prefix("capture.") { + if let Some(name) = token.strip_prefix("capture.") { return captures - .get(rest) + .get(name) .cloned() .ok_or_else(|| TemplateError::Unbound(token.to_string())); } - if let Some((array_name, idx_str)) = parse_indexed(token) { - let idx: i32 = idx_str + + if let Some((array, index)) = split_index(token) { + let index: i32 = index .parse() .map_err(|_| TemplateError::Malformed(format!("invalid index in `{token}`")))?; return ctx - .lookup_indexed(array_name, idx) + .lookup_indexed(array, index) + .map(str::to_string) .ok_or_else(|| TemplateError::Unbound(token.to_string())); } + ctx .lookup(token) + .map(str::to_string) .ok_or_else(|| TemplateError::Unbound(token.to_string())) } -fn parse_indexed(token: &str) -> Option<(&str, &str)> { +/// Splits `tags[-1]` into `("tags", "-1")`, or `None` when the token is not +/// an indexed reference. +fn split_index(token: &str) -> Option<(&str, &str)> { let open = token.find('[')?; - if !token.ends_with(']') { - return None; - } - Some((&token[..open], &token[open + 1..token.len() - 1])) + let index = token[open + 1..].strip_suffix(']')?; + Some((&token[..open], index)) } #[cfg(test)] diff --git a/src/plan/services.rs b/src/plan/services.rs deleted file mode 100644 index e00b635..0000000 --- a/src/plan/services.rs +++ /dev/null @@ -1,897 +0,0 @@ -//! Planning services: operation grouping, body-layout resolution, and -//! per-operation request-contract construction. - -use std::collections::{BTreeSet, HashMap}; - -use crate::{ - error::{Diagnostic, Reporter}, - ir::{ - canonical::{BodyContent, BodyField, OperationDef, RequestBodyDef}, - schema::SchemaType, - }, - plan::artifact_plan::{ - PlannedFormField, PlannedHeader, PlannedRequestBody, PlannedRequestContract, - PlannedRequestField, RequestFieldKind, - }, -}; - -// --------------------------------------------------------------------------- -// Section 1: Operation grouper -// --------------------------------------------------------------------------- - -pub(crate) type GroupedOperations<'a> = Vec<(String, Vec<(&'a OperationDef, String)>)>; - -/// Groups operations by their resolved `group` name. Returns -/// `(group_name, Vec<(operation, method_name)>)` pairs in -/// operation-discovery order; downstream sorting is the planner's job -/// (`resolve_service_plans`). Both `group` and `method_name` are -/// resolved up-front via the `NamingResolver` so each operation is -/// touched exactly once. -pub(crate) fn group_operations<'a>( - operations: &'a [OperationDef], - resolver: &crate::plan::naming::NamingResolver, - reporter: &Reporter<'_>, -) -> Result, Diagnostic> { - let mut groups: GroupedOperations<'a> = Vec::new(); - let mut group_indexes = HashMap::::new(); - - for operation in operations { - let group_name = resolver.group(operation, reporter)?; - let method_name = resolver.method_name(operation, reporter)?; - - let group_index = group_indexes.get(&group_name).copied().unwrap_or_else(|| { - let index = groups.len(); - let key = group_name.clone(); - groups.push((group_name, Vec::new())); - group_indexes.insert(key, index); - index - }); - - groups[group_index].1.push((operation, method_name)); - } - - Ok(groups) -} - -// --------------------------------------------------------------------------- -// Section 2: Body resolution -// --------------------------------------------------------------------------- - -/// Translate an IR `RequestBodyDef` into the planner's `PlannedRequestBody`, -/// applying the smart-flatten rule: -/// -/// - Inline JSON `type: object` → `FlatJson` with the body's properties -/// hoisted to top-level request fields. The body envelope's `required` -/// flag is propagated to each hoisted property's `optional` marker so an -/// `required: false` body never produces required fields on the params -/// interface. -/// - JSON `$ref` (named schema) or any non-object JSON shape → `Nested`, -/// preserving the spec author's type as a single `body: T` field. -/// - Form bodies (multipart / urlencoded) always flatten their fields to -/// top-level, since `BodyFieldType` (`Blob | File`, …) can't compose -/// back under the source schema name. Form fields are sorted -/// alphabetically by name so the emitted interface stays stable across -/// spec re-orderings. -fn plan_request_body<'ir>(body: Option<&'ir RequestBodyDef>) -> Option> { - let body = body?; - match &body.content { - BodyContent::Json(SchemaType::InlineObject { properties }) => { - let envelope_required = body.required; - let hoisted = properties - .iter() - .map(|property| PlannedRequestField { - name: property.name.clone(), - optional: !envelope_required || !property.required, - ty: &property.ty, - kind: RequestFieldKind::Body, - }) - .collect(); - Some(PlannedRequestBody::FlatJson { - properties: hoisted, - required: envelope_required, - }) - } - BodyContent::Json(ty) => Some(PlannedRequestBody::Nested { - ty, - optional: !body.required, - }), - BodyContent::Multipart { fields, .. } => Some(PlannedRequestBody::Multipart { - fields: plan_form_fields(fields), - }), - BodyContent::UrlEncoded { fields, .. } => Some(PlannedRequestBody::UrlEncoded { - fields: plan_form_fields(fields), - }), - } -} - -fn plan_form_fields<'ir>(fields: &'ir [BodyField]) -> Vec> { - let mut out: Vec> = fields - .iter() - .map(|f| PlannedFormField { - name: f.name.clone(), - optional: !f.required, - ty: &f.ty, - }) - .collect(); - out.sort_by(|a, b| a.name.cmp(&b.name)); - out -} - -/// Reject a contract whose top-level body field names (from `FlatJson` -/// properties, `Multipart` fields, or `UrlEncoded` fields) clash with the -/// path/query parameter names already on `fields`. Nested-body operations -/// have nothing to check here — their body sits on the dedicated `body` -/// slot under the literal key `body`. -/// -/// The diagnostic mirrors the original (pre-smart-flatten) message and -/// nudges the spec author toward the natural escape hatch: hoist the -/// inline body to a top-level `$ref` so it lands on the `body` slot -/// instead of flattening. -fn check_body_field_collisions( - fields: &[PlannedRequestField], - body: Option<&PlannedRequestBody>, - operation_id: &str, - reporter: &Reporter<'_>, -) -> Result<(), Diagnostic> { - let path_query_names: std::collections::BTreeSet<&str> = - fields.iter().map(|f| f.name.as_ref()).collect(); - if path_query_names.is_empty() { - return Ok(()); - } - let body_names: Vec<&str> = match body { - Some(PlannedRequestBody::FlatJson { properties, .. }) => { - properties.iter().map(|p| p.name.as_ref()).collect() - } - Some(PlannedRequestBody::Multipart { fields } | PlannedRequestBody::UrlEncoded { fields }) => { - fields.iter().map(|f| f.name.as_ref()).collect() - } - _ => return Ok(()), - }; - let colliding: Vec<&str> = body_names - .into_iter() - .filter(|n| path_query_names.contains(n)) - .collect(); - if colliding.is_empty() { - return Ok(()); - } - let names = colliding.join(", "); - Err(Diagnostic::policy_violation( - reporter, - "field-collision", - format!( - "operationId '{operation_id}': body fields [{names}] duplicate path/query parameter names. \ - Rename the colliding fields in the OpenAPI spec, or hoist the body schema to a named `$ref` so it nests under `body`." - ), - )) -} - -// --------------------------------------------------------------------------- -// Section 3: Request-contract planning -// --------------------------------------------------------------------------- - -fn check_path_query_collisions( - fields: &[PlannedRequestField], - operation_id: &str, - reporter: &Reporter<'_>, -) -> Result<(), Diagnostic> { - let path_set: BTreeSet<&str> = fields - .iter() - .filter(|f| f.kind == RequestFieldKind::Path) - .map(|f| f.name.as_ref()) - .collect(); - let colliding: Vec<&str> = fields - .iter() - .filter(|f| f.kind == RequestFieldKind::Query && path_set.contains(f.name.as_ref())) - .map(|f| f.name.as_ref()) - .collect(); - if !colliding.is_empty() { - let names = colliding.join(", "); - return Err(Diagnostic::policy_violation( - reporter, - "field-collision", - format!( - "operationId '{operation_id}': path and query parameters share names [{names}], \ - which would produce duplicate fields in the generated request contract. \ - Rename the colliding parameters in the OpenAPI spec." - ), - )); - } - Ok(()) -} - -pub(crate) fn plan_request_contract<'ir>( - operation: &'ir OperationDef, - reporter: &Reporter<'_>, -) -> Result, Diagnostic> { - let mut fields: Vec> = Vec::new(); - - for input in &operation.request.inputs { - let kind = match input.source { - crate::ir::canonical::RequestInputSource::Path => RequestFieldKind::Path, - crate::ir::canonical::RequestInputSource::Query => RequestFieldKind::Query, - }; - fields.push(PlannedRequestField { - name: input.name.clone(), - optional: !input.required, - ty: &input.ty, - kind, - }); - } - - let headers: Vec> = operation - .request - .headers - .iter() - .map(|header| PlannedHeader { - name: header.name.clone(), - optional: !header.required, - ty: &header.ty, - }) - .collect(); - - check_path_query_collisions(&fields, &operation.operation_id, reporter)?; - - let body = plan_request_body(operation.request.body.as_ref()); - - check_body_field_collisions(&fields, body.as_ref(), &operation.operation_id, reporter)?; - - Ok(PlannedRequestContract { - fields, - headers, - body, - }) -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -#[cfg(test)] -mod tests { - mod grouper { - use crate::{ - ir::{ - canonical::{HttpMethod, OperationDef, RequestDef, ResponseContent}, - schema::{SchemaScalar, SchemaType}, - }, - plan::{naming::NamingResolver, services::group_operations}, - test_support::test_ctx, - }; - - fn operation(id: &str, tags: Vec<&str>) -> OperationDef { - OperationDef { - operation_id: id.to_string(), - tags: tags.into_iter().map(str::to_string).collect(), - method: HttpMethod::Get, - path: format!("/{id}"), - request: RequestDef::default(), - response: Some(ResponseContent::Json(Some(SchemaType::Scalar( - SchemaScalar::Boolean, - )))), - errors: Vec::new(), - description: None, - deprecated: false, - } - } - - #[test] - fn tag_first_operation_grouper_preserves_group_and_operation_discovery_order() { - let operations = [ - operation("listPets", vec!["Pet"]), - operation("listAdoptions", vec!["Adoption"]), - operation("getPet", vec!["Pet"]), - ]; - let mut ctx = test_ctx(); - let resolver = NamingResolver::default(); - let groups = - group_operations(&operations, &resolver, &ctx.reporter()).expect("grouping succeeds"); - - assert_eq!( - groups - .iter() - .map(|(name, _)| name.as_str()) - .collect::>(), - vec!["Pet", "Adoption"] - ); - assert_eq!( - groups[0] - .1 - .iter() - .map(|(operation, _method_name)| operation.operation_id.as_str()) - .collect::>(), - vec!["listPets", "getPet"] - ); - } - - #[test] - fn tagless_operations_fall_back_to_path_derived_group_with_default_resolver() { - // The previous `tag_first_operation_grouper_rejects_tagless_operations` - // test asserted a policy violation; with the configurable naming - // engine, the default `group` rule falls back to - // `pascalCase(pathSegments[0])` when tags are missing. - let mut ctx = test_ctx(); - let resolver = NamingResolver::default(); - let ops = [operation("listPets", Vec::new())]; - let groups = group_operations(&ops, &resolver, &ctx.reporter()) - .expect("default resolver groups by path segment when tags are absent"); - assert_eq!(groups.len(), 1); - assert_eq!(groups[0].0, "ListPets"); - } - } - - mod body { - use super::super::plan_request_body; - use crate::{ - ir::{ - canonical::{BodyContent, RequestBodyDef}, - schema::{SchemaProperty, SchemaScalar, SchemaType}, - }, - plan::artifact_plan::PlannedRequestBody, - }; - - #[test] - fn returns_none_when_body_is_absent() { - assert!(plan_request_body(None).is_none()); - } - - #[test] - fn ref_body_stays_nested_with_named_schema_preserved() { - let body = RequestBodyDef { - required: true, - content: BodyContent::Json(SchemaType::Ref("CreatePetRequest".into())), - }; - match plan_request_body(Some(&body)).expect("body present") { - PlannedRequestBody::Nested { ty, optional } => { - assert!(!optional); - assert!(matches!(ty, SchemaType::Ref(name) if name.as_ref() == "CreatePetRequest")); - } - other => panic!("expected nested ref body, got {other:?}"), - } - } - - #[test] - fn inline_object_body_hoists_properties_with_required_flag_propagated() { - let body = RequestBodyDef { - required: false, - content: BodyContent::Json(SchemaType::InlineObject { - properties: vec![SchemaProperty { - name: "status".into(), - required: true, - ty: SchemaType::Scalar(SchemaScalar::String), - description: None, - deprecated: false, - }], - }), - }; - match plan_request_body(Some(&body)).expect("body present") { - PlannedRequestBody::FlatJson { - properties, - required, - } => { - assert!(!required, "envelope marked optional in fixture"); - assert_eq!(properties.len(), 1); - assert_eq!(properties[0].name.as_ref(), "status"); - // Required property under an optional envelope ⇒ field is optional. - assert!(properties[0].optional); - } - other => panic!("expected FlatJson, got {other:?}"), - } - } - - #[test] - fn non_object_json_body_stays_nested() { - let body = RequestBodyDef { - required: true, - content: BodyContent::Json(SchemaType::Scalar(SchemaScalar::String)), - }; - assert!(matches!( - plan_request_body(Some(&body)), - Some(PlannedRequestBody::Nested { .. }) - )); - } - } - - mod contract { - use crate::{ - ir::{ - canonical::{ - BodyContent, HeaderDef, HttpMethod, OperationDef, RequestBodyDef, RequestDef, - RequestInputDef, RequestInputSource, - }, - schema::{SchemaProperty, SchemaScalar, SchemaType}, - }, - plan::artifact_plan::{PlannedRequestBody, RequestFieldKind}, - plan::services::plan_request_contract, - test_support::test_ctx, - }; - - #[test] - fn request_contract_planner_nests_ref_body_under_dedicated_slot() { - let mut ctx = test_ctx(); - let operation = OperationDef { - operation_id: "updatePet".to_string(), - tags: vec!["Pet".to_string()], - method: HttpMethod::Post, - path: "/pets/{petId}".to_string(), - request: RequestDef { - inputs: vec![RequestInputDef { - name: "petId".into(), - source: RequestInputSource::Path, - required: true, - ty: SchemaType::Ref("PetId".into()), - }], - headers: Vec::new(), - body: Some(RequestBodyDef { - required: true, - content: BodyContent::Json(SchemaType::Ref("UpdatePetPayload".into())), - }), - }, - response: None, - errors: Vec::new(), - description: None, - deprecated: false, - }; - let request = - plan_request_contract(&operation, &ctx.reporter()).expect("request contract resolves"); - - let path_fields: Vec<&str> = request - .fields - .iter() - .filter(|f| f.kind == RequestFieldKind::Path) - .map(|f| f.name.as_ref()) - .collect(); - assert_eq!(path_fields, vec!["petId"]); - match &request.body { - Some(PlannedRequestBody::Nested { ty, optional }) => { - assert!(!optional); - assert!(matches!(ty, SchemaType::Ref(name) if name.as_ref() == "UpdatePetPayload")); - } - other => panic!("expected nested ref body, got {other:?}"), - } - assert!(request.headers.is_empty()); - } - - #[test] - fn request_contract_planner_flattens_inline_object_body_to_top_level() { - // Smart-flatten: an inline `type: object` body is hoisted onto the - // request interface alongside path/query — call sites match the - // spec's authorial intent (loose parameter bag, not a named DTO). - let mut ctx = test_ctx(); - let operation = OperationDef { - operation_id: "decide".to_string(), - tags: vec!["AssetCsvImport".to_string()], - method: HttpMethod::Post, - path: "/decide".to_string(), - request: RequestDef { - inputs: Vec::new(), - headers: Vec::new(), - body: Some(RequestBodyDef { - required: true, - content: BodyContent::Json(SchemaType::InlineObject { - properties: vec![ - SchemaProperty { - name: "csvImportId".into(), - required: true, - ty: SchemaType::Ref("CsvImportId".into()), - description: None, - deprecated: false, - }, - SchemaProperty { - name: "doImport".into(), - required: true, - ty: SchemaType::Scalar(SchemaScalar::Boolean), - description: None, - deprecated: false, - }, - ], - }), - }), - }, - response: None, - errors: Vec::new(), - description: None, - deprecated: false, - }; - let request = - plan_request_contract(&operation, &ctx.reporter()).expect("request contract resolves"); - - // Path/query field list stays empty — flattened properties live on - // the FlatJson variant, not in `fields`. - assert!(request.fields.is_empty()); - let Some(PlannedRequestBody::FlatJson { - properties, - required, - }) = &request.body - else { - panic!("expected FlatJson body, got {:?}", request.body); - }; - assert!(required, "envelope required in fixture"); - assert_eq!( - properties - .iter() - .map(|p| p.name.as_ref()) - .collect::>(), - vec!["csvImportId", "doImport"] - ); - assert!(properties.iter().all(|p| !p.optional)); - } - - #[test] - fn request_contract_planner_lifts_headers_into_dedicated_list() { - let mut ctx = test_ctx(); - let operation = OperationDef { - operation_id: "tracedGet".to_string(), - tags: vec!["Pet".to_string()], - method: HttpMethod::Get, - path: "/pets".to_string(), - request: RequestDef { - inputs: Vec::new(), - headers: vec![HeaderDef { - name: "x-trace".into(), - required: false, - ty: SchemaType::Scalar(SchemaScalar::String), - }], - body: None, - }, - response: None, - errors: Vec::new(), - description: None, - deprecated: false, - }; - let request = - plan_request_contract(&operation, &ctx.reporter()).expect("request contract resolves"); - - assert!(request.fields.is_empty()); - assert_eq!(request.headers.len(), 1); - assert_eq!(request.headers[0].name.as_ref(), "x-trace"); - assert!(request.headers[0].optional); - } - - #[test] - fn request_contract_planner_rejects_flattened_body_property_colliding_with_path_param() { - // Smart-flatten hoists inline-object body properties to top-level, - // so a body property named the same as a path parameter would - // produce a duplicate field on the request interface. The planner - // rejects the spec; the author can recover by either renaming or - // by hoisting the body schema to a top-level `$ref` (which nests - // it under the `body` slot instead). - let operation = OperationDef { - operation_id: "createPet".to_string(), - tags: vec!["Pet".to_string()], - method: HttpMethod::Post, - path: "/pets/{petId}".to_string(), - request: RequestDef { - inputs: vec![RequestInputDef { - name: "petId".into(), - source: RequestInputSource::Path, - required: true, - ty: SchemaType::Ref("PetId".into()), - }], - headers: Vec::new(), - body: Some(RequestBodyDef { - required: true, - content: BodyContent::Json(SchemaType::InlineObject { - properties: vec![SchemaProperty { - name: "petId".into(), - required: true, - ty: SchemaType::Ref("PetId".into()), - description: None, - deprecated: false, - }], - }), - }), - }, - response: None, - errors: Vec::new(), - description: None, - deprecated: false, - }; - let mut ctx = test_ctx(); - let err = plan_request_contract(&operation, &ctx.reporter()) - .expect_err("should fail on flattened body property colliding with path"); - - use crate::error::DiagnosticCode; - assert_eq!(err.code, DiagnosticCode::PolicyViolation); - assert_eq!(err.subcode, Some("field-collision")); - assert!(err.message.contains("petId")); - } - - #[test] - fn request_contract_planner_accepts_ref_body_property_sharing_path_param_name() { - // The same property collision is fine when the body is a top-level - // `$ref` — it nests under `body` rather than flattening, so the - // property name doesn't appear at the top of the request interface. - let operation = OperationDef { - operation_id: "createPet".to_string(), - tags: vec!["Pet".to_string()], - method: HttpMethod::Post, - path: "/pets/{petId}".to_string(), - request: RequestDef { - inputs: vec![RequestInputDef { - name: "petId".into(), - source: RequestInputSource::Path, - required: true, - ty: SchemaType::Ref("PetId".into()), - }], - headers: Vec::new(), - body: Some(RequestBodyDef { - required: true, - content: BodyContent::Json(SchemaType::Ref("CreatePetRequest".into())), - }), - }, - response: None, - errors: Vec::new(), - description: None, - deprecated: false, - }; - let mut ctx = test_ctx(); - let request = plan_request_contract(&operation, &ctx.reporter()) - .expect("ref body nests under `body`, no top-level collision"); - assert!(matches!( - request.body, - Some(PlannedRequestBody::Nested { .. }) - )); - } - - #[test] - fn request_contract_planner_errors_when_path_and_query_param_share_a_name() { - let mut ctx = test_ctx(); - let err = plan_request_contract( - &OperationDef { - operation_id: "searchUsers".to_string(), - tags: vec!["User".to_string()], - method: HttpMethod::Get, - path: "/users/{id}".to_string(), - request: RequestDef { - inputs: vec![ - RequestInputDef { - name: "id".into(), - source: RequestInputSource::Path, - required: true, - ty: SchemaType::Scalar(SchemaScalar::String), - }, - RequestInputDef { - name: "id".into(), - source: RequestInputSource::Query, - required: false, - ty: SchemaType::Scalar(SchemaScalar::String), - }, - ], - headers: Vec::new(), - body: None, - }, - response: None, - errors: Vec::new(), - description: None, - deprecated: false, - }, - &ctx.reporter(), - ) - .expect_err("should fail on path/query collision"); - - use crate::error::DiagnosticCode; - assert_eq!(err.code, DiagnosticCode::PolicyViolation); - assert!(err.message.contains("id")); - } - } - - mod form_body { - use crate::{ - ir::{ - canonical::{ - ApiInfo, ApiModel, BodyContent, BodyField, BodyFieldType, HttpMethod, ModelSymbol, - OperationDef, RequestBodyDef, RequestDef, RequestInputDef, RequestInputSource, - }, - schema::{SchemaScalar, SchemaType}, - }, - plan::{ - artifact_plan::{PlannedRequestBody, resolve_service_plans}, - naming::NamingResolver, - }, - test_support::test_ctx, - }; - - fn api_model(schemas: Vec, operations: Vec) -> ApiModel { - ApiModel { - info: ApiInfo { - spec_version: "3.0.3".to_string(), - title: "Test".to_string(), - }, - schemas, - operations, - } - } - - fn multipart_operation( - operation_id: &str, - path: &str, - inputs: Vec, - body_ref: Option<&str>, - fields: Vec, - ) -> OperationDef { - OperationDef { - operation_id: operation_id.to_string(), - tags: vec!["Upload".to_string()], - method: HttpMethod::Post, - path: path.to_string(), - request: RequestDef { - inputs, - headers: Vec::new(), - body: Some(RequestBodyDef { - required: true, - content: BodyContent::Multipart { - body_ref: body_ref.map(Box::from), - fields, - }, - }), - }, - response: None, - errors: Vec::new(), - description: None, - deprecated: false, - } - } - - fn api_model_with_multipart_op() -> ApiModel { - api_model( - Vec::new(), - vec![multipart_operation( - "uploadAvatar", - "/avatar", - Vec::new(), - None, - vec![ - BodyField { - name: "avatar".into(), - required: true, - ty: BodyFieldType::Binary, - }, - BodyField { - name: "caption".into(), - required: false, - ty: BodyFieldType::Scalar(SchemaScalar::String), - }, - ], - )], - ) - } - - fn api_model_with_multipart_unsorted_fields() -> ApiModel { - api_model( - Vec::new(), - vec![multipart_operation( - "uploadAssets", - "/assets", - Vec::new(), - None, - vec![ - BodyField { - name: "zeta".into(), - required: true, - ty: BodyFieldType::Scalar(SchemaScalar::String), - }, - BodyField { - name: "alpha".into(), - required: true, - ty: BodyFieldType::Scalar(SchemaScalar::String), - }, - BodyField { - name: "mu".into(), - required: true, - ty: BodyFieldType::Scalar(SchemaScalar::String), - }, - ], - )], - ) - } - - fn api_model_with_form_collision() -> ApiModel { - api_model( - Vec::new(), - vec![multipart_operation( - "uploadByFileName", - "/files/{fileName}", - vec![RequestInputDef { - name: "fileName".into(), - source: RequestInputSource::Path, - required: true, - ty: SchemaType::Scalar(SchemaScalar::String), - }], - None, - vec![ - BodyField { - name: "fileName".into(), - required: true, - ty: BodyFieldType::Scalar(SchemaScalar::String), - }, - BodyField { - name: "blob".into(), - required: true, - ty: BodyFieldType::Binary, - }, - ], - )], - ) - } - - fn api_model_with_multipart_ref_body(body_ref: &str) -> ApiModel { - api_model( - Vec::new(), - vec![multipart_operation( - "uploadForm", - "/form", - Vec::new(), - Some(body_ref), - vec![BodyField { - name: "file".into(), - required: true, - ty: BodyFieldType::Binary, - }], - )], - ) - } - - #[test] - fn plans_multipart_body_with_fields_hoisted_to_form_collection() { - let ir = api_model_with_multipart_op(); - let mut ctx = test_ctx(); - let services = - resolve_service_plans(&ir, &NamingResolver::default(), &ctx.reporter()).expect("ok"); - let op = &services[0].operations[0]; - match &op.request.body { - Some(PlannedRequestBody::Multipart { fields }) => { - assert!(fields.iter().any(|f| f.name.as_ref() == "avatar")); - } - other => panic!("expected multipart body, got {other:?}"), - } - // Path/query field list stays empty in this fixture; form fields - // hoist to top-level via the body slot, not via `fields`. - assert!(op.request.fields.is_empty()); - } - - #[test] - fn plans_form_fields_sorted_alphabetically() { - let ir = api_model_with_multipart_unsorted_fields(); - let mut ctx = test_ctx(); - let services = - resolve_service_plans(&ir, &NamingResolver::default(), &ctx.reporter()).expect("ok"); - let Some(PlannedRequestBody::Multipart { fields }) = &services[0].operations[0].request.body - else { - panic!("expected multipart body"); - }; - let names: Vec<&str> = fields.iter().map(|f| f.name.as_ref()).collect(); - let mut sorted = names.clone(); - sorted.sort_unstable(); - assert_eq!(names, sorted); - } - - #[test] - fn form_field_name_collision_with_path_param_emits_field_collision() { - // Path has {fileName} and the multipart body has a `fileName` field; - // smart-flatten hoists form fields to top-level so the duplicate - // surfaces on the request interface — reject at planning time. - let ir = api_model_with_form_collision(); - let mut ctx = test_ctx(); - let err = resolve_service_plans(&ir, &NamingResolver::default(), &ctx.reporter()) - .expect_err("hoisted form fields collide with path param"); - assert_eq!(err.subcode, Some("field-collision")); - assert!(err.message.contains("fileName")); - } - - #[test] - fn multipart_ref_body_still_flattens_fields_under_smart_rule() { - // Even when the multipart body carries a named source schema, we - // can't render the schema's name as a TS type — `BodyFieldType` - // (Blob | File, …) does not compose into the source `SchemaType`. - // So multipart bodies always flatten regardless of `body_ref`. - let ir = api_model_with_multipart_ref_body("UploadForm"); - let mut ctx = test_ctx(); - let services = - resolve_service_plans(&ir, &NamingResolver::default(), &ctx.reporter()).expect("ok"); - assert!(matches!( - services[0].operations[0].request.body, - Some(PlannedRequestBody::Multipart { .. }) - )); - } - } -} diff --git a/src/plan/services/body.rs b/src/plan/services/body.rs new file mode 100644 index 0000000..852b8da --- /dev/null +++ b/src/plan/services/body.rs @@ -0,0 +1,400 @@ +//! Request-body layout. + +use crate::{ + error::{Diagnostic, Reporter}, + ir::{ + canonical::{BodyContent, BodyField, RequestBodyDef}, + schema::SchemaType, + }, + plan::artifact_plan::{ + PlannedFormField, PlannedRequestBody, PlannedRequestField, RequestFieldKind, + }, +}; + +/// Chooses a body's layout: +/// +/// - an inline JSON object → `FlatJson`, its properties hoisted and each +/// `optional` folding in the envelope's `required`; +/// - any other JSON shape → `Nested`, under one `body` key; +/// - a form body → `Multipart` / `UrlEncoded`, its fields hoisted and +/// sorted by name. +pub(super) fn plan_request_body<'ir>( + body: Option<&'ir RequestBodyDef>, +) -> Option> { + let body = body?; + match &body.content { + BodyContent::Json(SchemaType::InlineObject { properties }) => { + let envelope_required = body.required; + let hoisted = properties + .iter() + .map(|property| PlannedRequestField { + name: property.name.clone(), + optional: !envelope_required || !property.required, + ty: &property.ty, + kind: RequestFieldKind::Body, + }) + .collect(); + Some(PlannedRequestBody::FlatJson { + properties: hoisted, + required: envelope_required, + }) + } + BodyContent::Json(ty) => Some(PlannedRequestBody::Nested { + ty, + optional: !body.required, + }), + BodyContent::Multipart { fields, .. } => Some(PlannedRequestBody::Multipart { + fields: plan_form_fields(fields), + }), + BodyContent::UrlEncoded { fields, .. } => Some(PlannedRequestBody::UrlEncoded { + fields: plan_form_fields(fields), + }), + } +} + +fn plan_form_fields<'ir>(fields: &'ir [BodyField]) -> Vec> { + let mut out: Vec> = fields + .iter() + .map(|field| PlannedFormField { + name: field.name.clone(), + optional: !field.required, + ty: &field.ty, + }) + .collect(); + out.sort_by(|a, b| a.name.cmp(&b.name)); + out +} + +/// Fails when a hoisted body field name clashes with a path or query +/// parameter already on `fields`. +/// +/// A nested body has nothing to clash: it occupies the single `body` key. +pub(super) fn check_body_field_collisions( + fields: &[PlannedRequestField], + body: Option<&PlannedRequestBody>, + operation_id: &str, + reporter: &Reporter, +) -> Result<(), Diagnostic> { + let path_query_names: std::collections::BTreeSet<&str> = + fields.iter().map(|field| field.name.as_ref()).collect(); + if path_query_names.is_empty() { + return Ok(()); + } + let body_names: Vec<&str> = match body { + Some(PlannedRequestBody::FlatJson { properties, .. }) => { + properties.iter().map(|p| p.name.as_ref()).collect() + } + Some(PlannedRequestBody::Multipart { fields } | PlannedRequestBody::UrlEncoded { fields }) => { + fields.iter().map(|field| field.name.as_str()).collect() + } + _ => return Ok(()), + }; + let colliding: Vec<&str> = body_names + .into_iter() + .filter(|n| path_query_names.contains(n)) + .collect(); + if colliding.is_empty() { + return Ok(()); + } + let names = colliding.join(", "); + Err(Diagnostic::policy_violation( + reporter, + "field-collision", + format!( + "operationId '{operation_id}': body fields [{names}] duplicate path/query parameter names. \ + Rename the colliding fields in the OpenAPI spec, or hoist the body schema to a named `$ref` so it nests under `body`." + ), + )) +} + +#[cfg(test)] +mod tests { + mod body { + use super::super::plan_request_body; + use crate::{ + ir::{ + canonical::{BodyContent, RequestBodyDef}, + schema::{SchemaProperty, SchemaScalar, SchemaType}, + }, + plan::artifact_plan::PlannedRequestBody, + }; + + #[test] + fn returns_none_when_body_is_absent() { + assert!(plan_request_body(None).is_none()); + } + + #[test] + fn ref_body_stays_nested_with_named_schema_preserved() { + let body = RequestBodyDef { + required: true, + content: BodyContent::Json(SchemaType::Ref("CreatePetRequest".into())), + }; + match plan_request_body(Some(&body)).expect("body present") { + PlannedRequestBody::Nested { ty, optional } => { + assert!(!optional); + assert!(matches!(ty, SchemaType::Ref(name) if name.as_ref() == "CreatePetRequest")); + } + other => panic!("expected nested ref body, got {other:?}"), + } + } + + #[test] + fn inline_object_body_hoists_properties_with_required_flag_propagated() { + let body = RequestBodyDef { + required: false, + content: BodyContent::Json(SchemaType::InlineObject { + properties: vec![SchemaProperty { + name: "status".into(), + required: true, + ty: SchemaType::Scalar(SchemaScalar::String), + description: None, + deprecated: false, + }], + }), + }; + match plan_request_body(Some(&body)).expect("body present") { + PlannedRequestBody::FlatJson { + properties, + required, + } => { + assert!(!required, "envelope marked optional in fixture"); + assert_eq!(properties.len(), 1); + assert_eq!(properties[0].name.as_ref(), "status"); + // Required property under an optional envelope ⇒ field is optional. + assert!(properties[0].optional); + } + other => panic!("expected FlatJson, got {other:?}"), + } + } + + #[test] + fn non_object_json_body_stays_nested() { + let body = RequestBodyDef { + required: true, + content: BodyContent::Json(SchemaType::Scalar(SchemaScalar::String)), + }; + assert!(matches!( + plan_request_body(Some(&body)), + Some(PlannedRequestBody::Nested { .. }) + )); + } + } + + mod form_body { + use crate::{ + ir::{ + canonical::{ + ApiInfo, ApiModel, BodyContent, BodyField, BodyFieldType, HttpMethod, ModelSymbol, + OperationDef, RequestBodyDef, RequestDef, RequestInputDef, RequestInputSource, + }, + schema::{SchemaScalar, SchemaType}, + }, + plan::{ + artifact_plan::{PlannedRequestBody, resolve_service_plans}, + naming::NamingResolver, + }, + test_support::test_reporter, + }; + + fn api_model(schemas: Vec, operations: Vec) -> ApiModel { + ApiModel { + info: ApiInfo { + spec_version: "3.0.3".to_string(), + title: "Test".to_string(), + }, + schemas, + operations, + } + } + + fn multipart_operation( + operation_id: &str, + path: &str, + inputs: Vec, + body_ref: Option<&str>, + fields: Vec, + ) -> OperationDef { + OperationDef { + operation_id: operation_id.to_string(), + tags: vec!["Upload".to_string()], + method: HttpMethod::Post, + path: path.to_string(), + request: RequestDef { + inputs, + headers: Vec::new(), + body: Some(RequestBodyDef { + required: true, + content: BodyContent::Multipart { + body_ref: body_ref.map(Box::from), + fields, + }, + }), + }, + response: None, + errors: Vec::new(), + description: None, + deprecated: false, + } + } + + fn api_model_with_multipart_op() -> ApiModel { + api_model( + Vec::new(), + vec![multipart_operation( + "uploadAvatar", + "/avatar", + Vec::new(), + None, + vec![ + BodyField { + name: crate::ident::Ident::parse("avatar").expect("identifier"), + required: true, + ty: BodyFieldType::Binary, + }, + BodyField { + name: crate::ident::Ident::parse("caption").expect("identifier"), + required: false, + ty: BodyFieldType::Scalar(SchemaScalar::String), + }, + ], + )], + ) + } + + fn api_model_with_multipart_unsorted_fields() -> ApiModel { + api_model( + Vec::new(), + vec![multipart_operation( + "uploadAssets", + "/assets", + Vec::new(), + None, + vec![ + BodyField { + name: crate::ident::Ident::parse("zeta").expect("identifier"), + required: true, + ty: BodyFieldType::Scalar(SchemaScalar::String), + }, + BodyField { + name: crate::ident::Ident::parse("alpha").expect("identifier"), + required: true, + ty: BodyFieldType::Scalar(SchemaScalar::String), + }, + BodyField { + name: crate::ident::Ident::parse("mu").expect("identifier"), + required: true, + ty: BodyFieldType::Scalar(SchemaScalar::String), + }, + ], + )], + ) + } + + fn api_model_with_form_collision() -> ApiModel { + api_model( + Vec::new(), + vec![multipart_operation( + "uploadByFileName", + "/files/{fileName}", + vec![RequestInputDef { + name: "fileName".into(), + source: RequestInputSource::Path, + required: true, + ty: SchemaType::Scalar(SchemaScalar::String), + }], + None, + vec![ + BodyField { + name: crate::ident::Ident::parse("fileName").expect("identifier"), + required: true, + ty: BodyFieldType::Scalar(SchemaScalar::String), + }, + BodyField { + name: crate::ident::Ident::parse("blob").expect("identifier"), + required: true, + ty: BodyFieldType::Binary, + }, + ], + )], + ) + } + + fn api_model_with_multipart_ref_body(body_ref: &str) -> ApiModel { + api_model( + Vec::new(), + vec![multipart_operation( + "uploadForm", + "/form", + Vec::new(), + Some(body_ref), + vec![BodyField { + name: crate::ident::Ident::parse("file").expect("identifier"), + required: true, + ty: BodyFieldType::Binary, + }], + )], + ) + } + + #[test] + fn plans_multipart_body_with_fields_hoisted_to_form_collection() { + let ir = api_model_with_multipart_op(); + let ctx = test_reporter(); + let services = resolve_service_plans(&ir, &NamingResolver::default(), &ctx).expect("ok"); + let op = &services[0].operations[0]; + match &op.request.body { + Some(PlannedRequestBody::Multipart { fields }) => { + assert!(fields.iter().any(|f| f.name.as_str() == "avatar")); + } + other => panic!("expected multipart body, got {other:?}"), + } + // Path/query field list stays empty in this fixture; form fields + // hoist to top-level via the body slot, not via `fields`. + assert!(op.request.fields.is_empty()); + } + + #[test] + fn plans_form_fields_sorted_alphabetically() { + let ir = api_model_with_multipart_unsorted_fields(); + let ctx = test_reporter(); + let services = resolve_service_plans(&ir, &NamingResolver::default(), &ctx).expect("ok"); + let Some(PlannedRequestBody::Multipart { fields }) = &services[0].operations[0].request.body + else { + panic!("expected multipart body"); + }; + let names: Vec<&str> = fields.iter().map(|f| f.name.as_str()).collect(); + let mut sorted = names.clone(); + sorted.sort_unstable(); + assert_eq!(names, sorted); + } + + #[test] + fn form_field_name_collision_with_path_param_emits_field_collision() { + // Path has {fileName} and the multipart body has a `fileName` field; + // smart-flatten hoists form fields to top-level so the duplicate + // surfaces on the request interface — reject at planning time. + let ir = api_model_with_form_collision(); + let ctx = test_reporter(); + let err = resolve_service_plans(&ir, &NamingResolver::default(), &ctx) + .expect_err("hoisted form fields collide with path param"); + assert_eq!(err.subcode, Some("field-collision")); + assert!(err.message.contains("fileName")); + } + + #[test] + fn multipart_ref_body_still_flattens_fields_under_smart_rule() { + // Even when the multipart body carries a named source schema, we + // can't render the schema's name as a TS type — `BodyFieldType` + // (Blob | File, …) does not compose into the source `SchemaType`. + // So multipart bodies always flatten regardless of `body_ref`. + let ir = api_model_with_multipart_ref_body("UploadForm"); + let ctx = test_reporter(); + let services = resolve_service_plans(&ir, &NamingResolver::default(), &ctx).expect("ok"); + assert!(matches!( + services[0].operations[0].request.body, + Some(PlannedRequestBody::Multipart { .. }) + )); + } + } +} diff --git a/src/plan/services/grouping.rs b/src/plan/services/grouping.rs new file mode 100644 index 0000000..a441dcc --- /dev/null +++ b/src/plan/services/grouping.rs @@ -0,0 +1,115 @@ +//! Grouping operations into services. + +use std::collections::HashMap; + +use crate::{ + error::{Diagnostic, Reporter}, + ident::MethodName, + ir::canonical::OperationDef, +}; + +pub(crate) type GroupedOperations<'a> = Vec<(String, Vec<(&'a OperationDef, MethodName)>)>; + +/// Groups operations by their resolved group name, resolving each +/// operation's method name in the same pass. +/// +/// Groups and their members come back in the order the operations were +/// discovered, unsorted. +pub(crate) fn group_operations<'a>( + operations: &'a [OperationDef], + resolver: &crate::plan::naming::NamingResolver, + reporter: &Reporter, +) -> Result, Diagnostic> { + let mut groups: GroupedOperations<'a> = Vec::new(); + let mut group_indexes = HashMap::::new(); + + for operation in operations { + let group_name = resolver.group(operation, reporter)?; + let method_name = resolver.method_name(operation, reporter)?; + + let group_index = group_indexes.get(&group_name).copied().unwrap_or_else(|| { + let index = groups.len(); + let key = group_name.clone(); + groups.push((group_name, Vec::new())); + group_indexes.insert(key, index); + index + }); + + groups[group_index].1.push((operation, method_name)); + } + + Ok(groups) +} + +#[cfg(test)] +mod tests { + mod grouper { + use crate::{ + ir::{ + canonical::{HttpMethod, OperationDef, RequestDef, ResponseContent}, + schema::{SchemaScalar, SchemaType}, + }, + plan::{naming::NamingResolver, services::group_operations}, + test_support::test_reporter, + }; + + fn operation(id: &str, tags: Vec<&str>) -> OperationDef { + OperationDef { + operation_id: id.to_string(), + tags: tags.into_iter().map(str::to_string).collect(), + method: HttpMethod::Get, + path: format!("/{id}"), + request: RequestDef::default(), + response: Some(ResponseContent::Json(Some(SchemaType::Scalar( + SchemaScalar::Boolean, + )))), + errors: Vec::new(), + description: None, + deprecated: false, + } + } + + #[test] + fn tag_first_operation_grouper_preserves_group_and_operation_discovery_order() { + let operations = [ + operation("listPets", vec!["Pet"]), + operation("listAdoptions", vec!["Adoption"]), + operation("getPet", vec!["Pet"]), + ]; + let ctx = test_reporter(); + let resolver = NamingResolver::default(); + let groups = group_operations(&operations, &resolver, &ctx).expect("grouping succeeds"); + + assert_eq!( + groups + .iter() + .map(|(name, _)| name.as_str()) + .collect::>(), + vec!["Pet", "Adoption"] + ); + assert_eq!( + groups[0] + .1 + .iter() + .map(|(operation, _method_name)| operation.operation_id.as_str()) + .collect::>(), + vec!["listPets", "getPet"] + ); + } + + #[test] + fn tagless_operations_fall_back_to_path_derived_group_with_default_resolver() { + // The previous `tag_first_operation_grouper_rejects_tagless_operations` + // test asserted a policy violation; with the configurable naming + // engine, the default `group` rule falls back to + // `pascalCase(pathSegments[0])` when tags are missing. + let ctx = test_reporter(); + let resolver = NamingResolver::default(); + let ops = [operation("listPets", Vec::new())]; + let groups = group_operations(&ops, &resolver, &ctx) + .expect("default resolver groups by path segment when tags are absent"); + assert_eq!(groups.len(), 1); + assert_eq!(groups[0].0, "ListPets"); + } + } +} diff --git a/src/plan/services/mod.rs b/src/plan/services/mod.rs new file mode 100644 index 0000000..202bf81 --- /dev/null +++ b/src/plan/services/mod.rs @@ -0,0 +1,367 @@ +//! Per-operation request-contract planning. +//! +//! `plan_request_contract` is the entry point; the submodules own the +//! grouping and the body layout it composes. + +mod body; +mod grouping; + +use std::collections::BTreeSet; + +use crate::{ + error::{Diagnostic, Reporter, bail_policy}, + ir::canonical::{OperationDef, RequestInputSource}, + plan::artifact_plan::{ + PlannedHeader, PlannedRequestContract, PlannedRequestField, RequestFieldKind, + }, +}; + +pub(crate) use grouping::group_operations; + +use body::{check_body_field_collisions, plan_request_body}; + +fn check_path_query_collisions( + fields: &[PlannedRequestField], + operation_id: &str, + reporter: &Reporter, +) -> Result<(), Diagnostic> { + let path_set: BTreeSet<&str> = fields + .iter() + .filter(|f| f.kind == RequestFieldKind::Path) + .map(|f| f.name.as_ref()) + .collect(); + let colliding: Vec<&str> = fields + .iter() + .filter(|f| f.kind == RequestFieldKind::Query && path_set.contains(f.name.as_ref())) + .map(|f| f.name.as_ref()) + .collect(); + if !colliding.is_empty() { + let names = colliding.join(", "); + bail_policy!( + reporter, + "field-collision", + "operationId '{operation_id}': path and query parameters share names [{names}], \ + which would produce duplicate fields in the generated request contract. \ + Rename the colliding parameters in the OpenAPI spec." + ); + } + Ok(()) +} + +pub(crate) fn plan_request_contract<'ir>( + operation: &'ir OperationDef, + reporter: &Reporter, +) -> Result, Diagnostic> { + let fields: Vec> = operation + .request + .inputs + .iter() + .map(|input| PlannedRequestField { + name: input.name.clone(), + optional: !input.required, + ty: &input.ty, + kind: match input.source { + RequestInputSource::Path => RequestFieldKind::Path, + RequestInputSource::Query => RequestFieldKind::Query, + }, + }) + .collect(); + + let headers: Vec> = operation + .request + .headers + .iter() + .map(|header| PlannedHeader { + name: header.name.clone(), + optional: !header.required, + ty: &header.ty, + }) + .collect(); + + check_path_query_collisions(&fields, &operation.operation_id, reporter)?; + + let body = plan_request_body(operation.request.body.as_ref()); + + check_body_field_collisions(&fields, body.as_ref(), &operation.operation_id, reporter)?; + + Ok(PlannedRequestContract { + fields, + headers, + body, + }) +} + +#[cfg(test)] +mod tests { + mod contract { + use crate::{ + ir::{ + canonical::{ + BodyContent, HeaderDef, HttpMethod, OperationDef, RequestBodyDef, RequestDef, + RequestInputDef, RequestInputSource, + }, + schema::{SchemaProperty, SchemaScalar, SchemaType}, + }, + plan::artifact_plan::{PlannedRequestBody, RequestFieldKind}, + plan::services::plan_request_contract, + test_support::test_reporter, + }; + + #[test] + fn request_contract_planner_nests_ref_body_under_dedicated_slot() { + let ctx = test_reporter(); + let operation = OperationDef { + operation_id: "updatePet".to_string(), + tags: vec!["Pet".to_string()], + method: HttpMethod::Post, + path: "/pets/{petId}".to_string(), + request: RequestDef { + inputs: vec![RequestInputDef { + name: "petId".into(), + source: RequestInputSource::Path, + required: true, + ty: SchemaType::Ref("PetId".into()), + }], + headers: Vec::new(), + body: Some(RequestBodyDef { + required: true, + content: BodyContent::Json(SchemaType::Ref("UpdatePetPayload".into())), + }), + }, + response: None, + errors: Vec::new(), + description: None, + deprecated: false, + }; + let request = plan_request_contract(&operation, &ctx).expect("request contract resolves"); + + let path_fields: Vec<&str> = request + .fields + .iter() + .filter(|f| f.kind == RequestFieldKind::Path) + .map(|f| f.name.as_ref()) + .collect(); + assert_eq!(path_fields, vec!["petId"]); + match &request.body { + Some(PlannedRequestBody::Nested { ty, optional }) => { + assert!(!optional); + assert!(matches!(ty, SchemaType::Ref(name) if name.as_ref() == "UpdatePetPayload")); + } + other => panic!("expected nested ref body, got {other:?}"), + } + assert!(request.headers.is_empty()); + } + + #[test] + fn request_contract_planner_flattens_inline_object_body_to_top_level() { + // Smart-flatten: an inline `type: object` body is hoisted onto the + // request interface alongside path/query — call sites match the + // spec's authorial intent (loose parameter bag, not a named DTO). + let ctx = test_reporter(); + let operation = OperationDef { + operation_id: "decide".to_string(), + tags: vec!["AssetCsvImport".to_string()], + method: HttpMethod::Post, + path: "/decide".to_string(), + request: RequestDef { + inputs: Vec::new(), + headers: Vec::new(), + body: Some(RequestBodyDef { + required: true, + content: BodyContent::Json(SchemaType::InlineObject { + properties: vec![ + SchemaProperty { + name: "csvImportId".into(), + required: true, + ty: SchemaType::Ref("CsvImportId".into()), + description: None, + deprecated: false, + }, + SchemaProperty { + name: "doImport".into(), + required: true, + ty: SchemaType::Scalar(SchemaScalar::Boolean), + description: None, + deprecated: false, + }, + ], + }), + }), + }, + response: None, + errors: Vec::new(), + description: None, + deprecated: false, + }; + let request = plan_request_contract(&operation, &ctx).expect("request contract resolves"); + + // Path/query field list stays empty — flattened properties live on + // the FlatJson variant, not in `fields`. + assert!(request.fields.is_empty()); + let Some(PlannedRequestBody::FlatJson { + properties, + required, + }) = &request.body + else { + panic!("expected FlatJson body, got {:?}", request.body); + }; + assert!(required, "envelope required in fixture"); + assert_eq!( + properties + .iter() + .map(|p| p.name.as_ref()) + .collect::>(), + vec!["csvImportId", "doImport"] + ); + assert!(properties.iter().all(|p| !p.optional)); + } + + #[test] + fn request_contract_planner_lifts_headers_into_dedicated_list() { + let ctx = test_reporter(); + let operation = OperationDef { + operation_id: "tracedGet".to_string(), + tags: vec!["Pet".to_string()], + method: HttpMethod::Get, + path: "/pets".to_string(), + request: RequestDef { + inputs: Vec::new(), + headers: vec![HeaderDef { + name: "x-trace".into(), + required: false, + ty: SchemaType::Scalar(SchemaScalar::String), + }], + body: None, + }, + response: None, + errors: Vec::new(), + description: None, + deprecated: false, + }; + let request = plan_request_contract(&operation, &ctx).expect("request contract resolves"); + + assert!(request.fields.is_empty()); + assert_eq!(request.headers.len(), 1); + assert_eq!(request.headers[0].name.as_ref(), "x-trace"); + assert!(request.headers[0].optional); + } + + #[test] + fn request_contract_planner_rejects_flattened_body_property_colliding_with_path_param() { + let operation = OperationDef { + operation_id: "createPet".to_string(), + tags: vec!["Pet".to_string()], + method: HttpMethod::Post, + path: "/pets/{petId}".to_string(), + request: RequestDef { + inputs: vec![RequestInputDef { + name: "petId".into(), + source: RequestInputSource::Path, + required: true, + ty: SchemaType::Ref("PetId".into()), + }], + headers: Vec::new(), + body: Some(RequestBodyDef { + required: true, + content: BodyContent::Json(SchemaType::InlineObject { + properties: vec![SchemaProperty { + name: "petId".into(), + required: true, + ty: SchemaType::Ref("PetId".into()), + description: None, + deprecated: false, + }], + }), + }), + }, + response: None, + errors: Vec::new(), + description: None, + deprecated: false, + }; + let ctx = test_reporter(); + let err = plan_request_contract(&operation, &ctx) + .expect_err("should fail on flattened body property colliding with path"); + + use crate::error::DiagnosticCode; + assert_eq!(err.code, DiagnosticCode::PolicyViolation); + assert_eq!(err.subcode, Some("field-collision")); + assert!(err.message.contains("petId")); + } + + #[test] + fn request_contract_planner_accepts_ref_body_property_sharing_path_param_name() { + let operation = OperationDef { + operation_id: "createPet".to_string(), + tags: vec!["Pet".to_string()], + method: HttpMethod::Post, + path: "/pets/{petId}".to_string(), + request: RequestDef { + inputs: vec![RequestInputDef { + name: "petId".into(), + source: RequestInputSource::Path, + required: true, + ty: SchemaType::Ref("PetId".into()), + }], + headers: Vec::new(), + body: Some(RequestBodyDef { + required: true, + content: BodyContent::Json(SchemaType::Ref("CreatePetRequest".into())), + }), + }, + response: None, + errors: Vec::new(), + description: None, + deprecated: false, + }; + let ctx = test_reporter(); + let request = plan_request_contract(&operation, &ctx) + .expect("ref body nests under `body`, no top-level collision"); + assert!(matches!( + request.body, + Some(PlannedRequestBody::Nested { .. }) + )); + } + + #[test] + fn request_contract_planner_errors_when_path_and_query_param_share_a_name() { + let ctx = test_reporter(); + let err = plan_request_contract( + &OperationDef { + operation_id: "searchUsers".to_string(), + tags: vec!["User".to_string()], + method: HttpMethod::Get, + path: "/users/{id}".to_string(), + request: RequestDef { + inputs: vec![ + RequestInputDef { + name: "id".into(), + source: RequestInputSource::Path, + required: true, + ty: SchemaType::Scalar(SchemaScalar::String), + }, + RequestInputDef { + name: "id".into(), + source: RequestInputSource::Query, + required: false, + ty: SchemaType::Scalar(SchemaScalar::String), + }, + ], + headers: Vec::new(), + body: None, + }, + response: None, + errors: Vec::new(), + description: None, + deprecated: false, + }, + &ctx, + ) + .expect_err("should fail on path/query collision"); + + use crate::error::DiagnosticCode; + assert_eq!(err.code, DiagnosticCode::PolicyViolation); + assert!(err.message.contains("id")); + } + } +} diff --git a/src/result.rs b/src/result.rs index 84ae7b3..17bd9ae 100644 --- a/src/result.rs +++ b/src/result.rs @@ -5,35 +5,28 @@ use crate::ir::canonical::ApiModel; #[napi(object)] #[derive(Clone, Debug, Eq, PartialEq)] pub struct GenerateSummary { - /// Display-normalized path of the source spec, as it appears in the - /// generated-artifact banner and in diagnostics' `path` field. Lets - /// consumers correlate a result with the input they passed; the value - /// is the supplied path with separators normalized, never resolved. + /// The source spec's path as supplied, with separators normalised and + /// nothing resolved. The same string appears in every diagnostic's + /// `path`. pub normalized_source_path: String, pub spec_version: String, pub title: String, - // u32 in the canonical IR; surfaces as a plain JS `number` (no BigInt - // gymnastics) — lossless for any plausible spec size. + // `u32` reaches JS as a plain `number`. pub path_count: u32, pub operation_count: u32, pub schema_count: u32, } impl GenerateSummary { - /// Builds a summary from the canonical `ApiModel`. Counts are derived - /// from the IR — what the generator will actually emit — rather than - /// from the pre-normalize document, so a normalization that drops or - /// fails on a schema is reflected in the user-facing summary. + /// Builds a summary whose counts come from the IR, and so describe what + /// the generator emits rather than what the document declared. pub(crate) fn from_ir(normalized_source_path: String, ir: &ApiModel) -> Self { - // Operation paths repeat per HTTP method (GET/POST/... on the same path - // count as one path), so dedup. Vec + sort_unstable + dedup avoids the - // per-node allocation of BTreeSet for what's only used as a count. + // One path carries an operation per method, so the list repeats. let mut paths: Vec<&str> = ir.operations.iter().map(|op| op.path.as_str()).collect(); paths.sort_unstable(); paths.dedup(); - // Per-document caps in `src/options.rs` keep these well below u32::MAX; - // the clamp is a defence-in-depth guard, and the debug_assert traps any - // future cap relaxation that would actually exceed the surface type. + // The caps in `crate::parse::limits` keep every count far below + // `u32::MAX`; the clamp is defence in depth. Self { normalized_source_path, spec_version: ir.info.spec_version.clone(), @@ -52,9 +45,8 @@ fn clamp_count(n: usize) -> u32 { u32::try_from(usize::min(n, U32_MAX_AS_USIZE)).unwrap_or(u32::MAX) } -/// A single generated artifact. `contents` always carries the emitted -/// source; callers that only need on-disk output can pass `outputPath` -/// and ignore the array. +/// One generated artifact. `contents` carries the emitted source whether +/// or not the caller also asked for it on disk. #[napi(object)] #[derive(Clone, Debug, Eq, PartialEq)] pub struct GeneratedArtifact { @@ -66,4 +58,10 @@ impl GeneratedArtifact { pub(crate) const fn new(path: String, contents: String) -> Self { Self { path, contents } } + + /// Prefixes the do-not-edit banner onto the contents. + pub(crate) fn with_banner(mut self, banner: &str) -> Self { + self.contents.insert_str(0, banner); + self + } } diff --git a/src/test_support.rs b/src/test_support.rs index c0edf0e..4f88e0f 100644 --- a/src/test_support.rs +++ b/src/test_support.rs @@ -1,42 +1,29 @@ use std::rc::Rc; use crate::{ - error::{Diagnostic, Reporter}, + error::Reporter, + ident::{Ident, MethodName}, ir::{ canonical::{BodyFieldType, HttpMethod, ResponseContent}, schema::{SchemaProperty, SchemaScalar, SchemaType}, }, - plan::artifact_plan::{ - PlannedFormField, PlannedHeader, PlannedOperation, PlannedRequestBody, PlannedRequestContract, - PlannedRequestField, RequestFieldKind, + plan::{ + artifact_plan::{ + PlannedFormField, PlannedHeader, PlannedOperation, PlannedRequestBody, + PlannedRequestContract, PlannedRequestField, RequestFieldKind, + }, + naming::{error_interface_name, request_interface_name}, }, }; -/// Self-contained reporter scaffolding for tests. Owns the warnings vec so -/// individual tests don't have to manage the borrow themselves; expose -/// the warnings borrow through `.reporter()` so tests can either ignore -/// warnings (treat as `&Reporter<'_>`) or push warnings via -/// `&mut Reporter<'_>`. -pub(crate) struct TestReporter { - pub(crate) path: Rc, - pub(crate) warnings: Vec, +/// Reporter over a placeholder display path. +pub(crate) fn test_reporter() -> Reporter { + reporter_for("test") } -impl TestReporter { - pub(crate) fn new(path: impl Into>) -> Self { - Self { - path: path.into(), - warnings: Vec::new(), - } - } - - pub(crate) fn reporter(&mut self) -> Reporter<'_> { - Reporter::new(Rc::clone(&self.path), &mut self.warnings) - } -} - -pub(crate) fn test_ctx() -> TestReporter { - TestReporter::new("test") +/// Reporter whose diagnostics carry `path`. +pub(crate) fn reporter_for(path: &str) -> Reporter { + Reporter::new(Rc::from(path)) } pub(crate) fn property(name: &str, required: bool, ty: SchemaType) -> SchemaProperty { @@ -59,9 +46,6 @@ pub(crate) fn nullable_property(name: &str, required: bool, ty: SchemaType) -> S } } -// ── Request-field / operation fixture builders ──────────────────────────────── - -/// A plain `string` scalar — the most common field type used in test fixtures. pub(crate) fn string_ty() -> SchemaType { SchemaType::Scalar(SchemaScalar::String) } @@ -88,9 +72,7 @@ pub(crate) fn query_field<'a>( } } -/// Build a `PlannedRequestField` of kind `Body` for tests that exercise the -/// FlatJson body layout (inline JSON object body whose properties hoisted to -/// top-level). +/// A field hoisted out of an inline JSON body. pub(crate) fn body_field<'a>( name: &str, optional: bool, @@ -104,14 +86,11 @@ pub(crate) fn body_field<'a>( } } -/// A `PlannedRequestBody::Nested` carrier with the given `ty` and optionality. pub(crate) fn nested_body(ty: &SchemaType, optional: bool) -> PlannedRequestBody<'_> { PlannedRequestBody::Nested { ty, optional } } -/// A `PlannedRequestBody::FlatJson` carrier whose properties are the given -/// `Body`-kinded fields. `required` records whether the envelope was -/// `requestBody.required: true`. +/// A hoisted JSON body, `required` being the envelope's own flag. pub(crate) fn flat_json_body<'a>( properties: Vec>, required: bool, @@ -122,8 +101,7 @@ pub(crate) fn flat_json_body<'a>( } } -/// Returns a `PlannedRequestContract` with no fields, headers, or body. -/// Useful in tests that care about operation structure but not request shape. +/// A contract with no fields, headers or body. pub(crate) fn empty_request() -> PlannedRequestContract<'static> { PlannedRequestContract { fields: vec![], @@ -132,8 +110,7 @@ pub(crate) fn empty_request() -> PlannedRequestContract<'static> { } } -/// Constructs a minimal `PlannedOperation` with the given parameters. -/// `response` is `None` for operations without a typed response. +/// An operation whose interface names follow from `operation_id`. pub(crate) fn op_with<'a>( operation_id: &str, method: HttpMethod, @@ -141,9 +118,14 @@ pub(crate) fn op_with<'a>( request: PlannedRequestContract<'a>, response: Option<&'a ResponseContent>, ) -> PlannedOperation<'a> { + let method_name = MethodName::new(operation_id.to_string()); + let takes_input = + !request.fields.is_empty() || request.body.is_some() || !request.headers.is_empty(); PlannedOperation { operation_id: operation_id.to_string(), - method_name: operation_id.to_string(), + request_interface: takes_input.then(|| request_interface_name(&method_name)), + error_interface: None, + method_name, method, path: path.to_string(), request, @@ -154,16 +136,17 @@ pub(crate) fn op_with<'a>( } } -/// Variant of `op_with` that attaches an error-response slice. Borrows -/// the slice from the caller — typical use is `&[ErrorResponse{...}, -/// ...]` constructed in the test body. +/// An operation carrying `errors` and nothing else. pub(crate) fn op_with_errors<'a>( operation_id: &str, errors: &'a [crate::ir::canonical::ErrorResponse], ) -> PlannedOperation<'a> { + let method_name = MethodName::new(operation_id.to_string()); PlannedOperation { operation_id: operation_id.to_string(), - method_name: operation_id.to_string(), + request_interface: None, + error_interface: (!errors.is_empty()).then(|| error_interface_name(&method_name)), + method_name, method: HttpMethod::Post, path: "/x".to_string(), request: empty_request(), @@ -180,18 +163,15 @@ fn build_form_fields<'a>( fields .into_iter() .map(|(name, optional, ty)| PlannedFormField { - name: name.into(), + name: Ident::parse(name).expect("test form-field name is an identifier"), optional, ty, }) .collect() } -/// Constructs a `PlannedOperation` whose request body is a multipart form, -/// populated with the supplied form fields. `fields` and `headers` on the -/// contract are empty. Each tuple is `(name, optional, ty)` where `ty` is -/// borrowed from the caller (matching the IR-borrowing convention of -/// `PlannedFormField`). +/// An operation whose body is a multipart form of `(name, optional, ty)` +/// fields. pub(crate) fn op_with_multipart_fields<'a>( fields: Vec<(&str, bool, &'a BodyFieldType)>, ) -> PlannedOperation<'a> { @@ -210,11 +190,7 @@ pub(crate) fn op_with_multipart_fields<'a>( ) } -/// Constructs a `PlannedOperation` whose request body is a multipart form -/// alongside non-empty `path/query` fields and/or `headers`. Mirror of -/// `op_with_multipart_fields` but lets a caller supply path/query fields -/// (typically `path_field(...)` / `query_field(...)`) and a list of -/// `PlannedHeader`s in addition to the form fields. +/// [`op_with_multipart_fields`] with path/query fields and headers too. pub(crate) fn op_with_multipart_fields_full<'a>( path_fields: Vec>, headers: Vec>, @@ -235,9 +211,7 @@ pub(crate) fn op_with_multipart_fields_full<'a>( ) } -/// Constructs a `PlannedOperation` whose request body is a url-encoded form, -/// populated with the supplied form fields. Mirror of -/// `op_with_multipart_fields` for the urlencoded variant. +/// [`op_with_multipart_fields`] for the urlencoded flavour. pub(crate) fn op_with_urlencoded_fields<'a>( fields: Vec<(&str, bool, &'a BodyFieldType)>, ) -> PlannedOperation<'a> { From 4fe8dbc6c1adc09ed874b31d0463e9fe35ab40b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andrzej=20=C5=9Awi=C4=99tek?= Date: Tue, 8 Sep 2026 17:25:14 +0200 Subject: [PATCH 02/11] Converted the build scripts to TypeScript --- __test__/generate.snapshot.spec.ts | 20 +- benchmark/tsconfig.json | 8 +- index.d.ts | 64 ++- native.js | 108 ++--- package.json | 8 +- scripts/check-version-not-placeholder.mjs | 14 - scripts/check-version-not-placeholder.ts | 20 + scripts/lib/engine.ts | 49 +++ scripts/lib/snapshot-layout.ts | 31 ++ scripts/package.json | 3 + scripts/patch-types.mjs | 241 ----------- scripts/patch-types.ts | 267 ++++++++++++ scripts/regen-snapshots.mjs | 385 ------------------ scripts/regen-snapshots.ts | 328 +++++++++++++++ scripts/tsconfig.json | 24 ++ tsconfig.json | 13 +- website/{astro.config.mjs => astro.config.ts} | 7 +- website/package.json | 4 +- website/scripts/bundle-engine.mjs | 71 ---- website/scripts/bundle-engine.ts | 76 ++++ 20 files changed, 912 insertions(+), 829 deletions(-) delete mode 100644 scripts/check-version-not-placeholder.mjs create mode 100644 scripts/check-version-not-placeholder.ts create mode 100644 scripts/lib/engine.ts create mode 100644 scripts/lib/snapshot-layout.ts create mode 100644 scripts/package.json delete mode 100644 scripts/patch-types.mjs create mode 100644 scripts/patch-types.ts delete mode 100644 scripts/regen-snapshots.mjs create mode 100644 scripts/regen-snapshots.ts create mode 100644 scripts/tsconfig.json rename website/{astro.config.mjs => astro.config.ts} (91%) delete mode 100644 website/scripts/bundle-engine.mjs create mode 100644 website/scripts/bundle-engine.ts diff --git a/__test__/generate.snapshot.spec.ts b/__test__/generate.snapshot.spec.ts index 45f2ef2..37eab44 100644 --- a/__test__/generate.snapshot.spec.ts +++ b/__test__/generate.snapshot.spec.ts @@ -8,6 +8,9 @@ import { fileURLToPath } from 'node:url'; // (the failure snapshots pin `warnings`/`path` from the upgraded class // shape). import { generate } from '../lib/index.js'; +// The banner regex and the static-template list come from the module the +// regenerator writes with, so reader and writer cannot drift. +import { BANNER_RE, STATIC_TEMPLATE_PATHS } from '../scripts/lib/snapshot-layout.ts'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.join(__dirname, '..'); @@ -21,21 +24,8 @@ function readJsonSnapshot(name: string) { return JSON.parse(fs.readFileSync(snapshot(name), 'utf8')); } -// Strip the per-version banner from artifact contents so snapshots -// survive version bumps. Keep in sync with BANNER_RE in -// scripts/regen-snapshots.mjs. -const BANNER_RE = - /^\/\/ Generated by openapi-ng v[^\n]*\n\/\/ Source: [^\n]*\n\/\/ DO NOT EDIT[^\n]*\n\n/u; - -// Paths whose contents are byte-identical across every success fixture. -// Listed by path in each per-fixture snapshot but the file bodies live -// once under __test__/snapshots/generate-native/static-template/ — see -// scripts/regen-snapshots.mjs for the storage layout. -const STATIC_TEMPLATE_PATHS = new Set([ - 'rest.model.ts', - 'rest.util.ts', - 'rest.validate.ts', -]); +// The banner regex and the static-template list come from the module the +// regenerator writes with, so reader and writer cannot drift. const STATIC_TEMPLATE_DIR = path.join( repoRoot, '__test__', diff --git a/benchmark/tsconfig.json b/benchmark/tsconfig.json index 659905b..83ee4b0 100644 --- a/benchmark/tsconfig.json +++ b/benchmark/tsconfig.json @@ -5,6 +5,10 @@ "moduleResolution": "NodeNext", "outDir": "lib" }, - "include": ["."], - "exclude": ["lib"] + "include": [ + "." + ], + "exclude": [ + "lib" + ] } diff --git a/index.d.ts b/index.d.ts index 2936e30..3e84296 100644 --- a/index.d.ts +++ b/index.d.ts @@ -11,9 +11,8 @@ export declare const EmitTarget: { }; /** - * A single generated artifact. `contents` always carries the emitted - * source; callers that only need on-disk output can pass `outputPath` - * and ignore the array. + * One generated artifact. `contents` carries the emitted source whether + * or not the caller also asked for it on disk. */ export interface GeneratedArtifact { path: string @@ -85,10 +84,9 @@ export interface GenerateResult { export interface GenerateSummary { /** - * Display-normalized path of the source spec, as it appears in the - * generated-artifact banner and in diagnostics' `path` field. Lets - * consumers correlate a result with the input they passed; the value - * is the supplied path with separators normalized, never resolved. + * The source spec's path as supplied, with separators normalised and + * nothing resolved. The same string appears in every diagnostic's + * `path`. */ normalizedSourcePath: string specVersion: string @@ -99,12 +97,11 @@ export interface GenerateSummary { } /** - * Boundary projection of `Diagnostic` for the NAPI surface — string-typed - * `code` is what JS consumers see and compare against. `severity` is - * either `"warning"` or `"error"`; the TS surface narrows it to the - * `'warning' | 'error'` union via `scripts/patch-types.mjs`. `subcode` - * is populated only for `PolicyViolation` today; consumers route on it - * when they need finer-grained remediation than `code` alone. + * Boundary projection of [`Diagnostic`] for the NAPI surface, where + * `code` and `severity` are strings a JS consumer compares against. + * + * `severity` is `"warning"` or `"error"`. `subcode` is set only for + * `PolicyViolation`. */ export interface GeneratorDiagnostic { code: DiagnosticCode @@ -124,12 +121,11 @@ export declare const enum InputFormat { } /** - * Canonical mapped-type record. Used as user input (from CLI/JS options) - * and as the planning record (after schema-name validation). + * One caller-declared mapped type: replace the generated declaration for + * `schema` with `ty` imported from `import`. * - * Field names match the CLI YAML config vocabulary (schema/import/type/ - * alias). `ty` is the Rust-side name; the NAPI surface renames it to - * `type` so the JS API stays idiomatic. + * Field names match the config vocabulary. `ty` crosses the NAPI + * boundary as `type`. */ export interface MappedType { schema: string @@ -144,9 +140,8 @@ export interface NamingChainItem { } /** - * User-facing naming config crossing the NAPI boundary. The JS wrapper - * in `lib/index.js` unpacks each JS `RegExp` into the `{ source, flags - * }` shape carried here, so Rust sees pure data on this side. + * The naming config as it crosses the NAPI boundary, where a JS `RegExp` + * arrives already unpacked into `{ source, flags }`. */ export interface NamingOptions { methodName?: NamingValue @@ -167,11 +162,10 @@ export interface NamingRuleEntry { } /** - * Discriminated union: a string shorthand, a single rule, or a chain - * of rules-or-shorthands. NAPI cannot express true sum types, so we - * use exclusive fields: exactly one of `string`, `rule`, or `chain` - * must be set. The JS wrapper enforces this; the Rust validator - * double-checks at config resolution. + * A string shorthand, a single rule, or a chain of either. + * + * NAPI has no sum type, so the variants are exclusive fields: exactly + * one must be set, which `plan::naming::lower` enforces. */ export interface NamingValue { /** `{ string: '...' }` — bare format-string shorthand. */ @@ -185,13 +179,7 @@ export interface NamingValue { chain?: Array } -/** - * JS-facing response-kind values. Mirrors the names Angular's - * `HttpClient.request({ responseType })` and `httpResource.()` - * expose, so the config vocabulary stays in JS conventions. The emit - * boundary translates `ArrayBuffer` to the lowercase `'arraybuffer'` - * string `HttpClient.request` requires. - */ +/** How a response body is decoded, named as the JS runtime names it. */ export declare const enum ResponseType { Json = 'json', Blob = 'blob', @@ -200,12 +188,10 @@ export declare const enum ResponseType { } /** - * User mapping: override the response-kind decoded for a specific - * response content-type. Pure data — Phase-3 normalize-side reads - * this when picking the `responseKind` for an operation's response - * content. Keys are matched case-insensitively against the lowercased - * media-type from the spec; the `responseType` is one of the JS-facing - * HttpClient response kinds (`'json' | 'blob' | 'text' | 'arrayBuffer'`). + * Overrides the response kind decoded for one content type. + * + * `content_type` is matched case-insensitively against the media type + * the spec declares. */ export interface ResponseTypeMapping { contentType: string diff --git a/native.js b/native.js index 4371127..f0d3b6e 100644 --- a/native.js +++ b/native.js @@ -77,8 +77,8 @@ function requireNative() { try { const binding = require('@avsystem/openapi-ng-android-arm64') const bindingPackageVersion = require('@avsystem/openapi-ng-android-arm64/package.json').version - if (bindingPackageVersion !== '0.4.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.4.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.5.2' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.5.2 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -93,8 +93,8 @@ function requireNative() { try { const binding = require('@avsystem/openapi-ng-android-arm-eabi') const bindingPackageVersion = require('@avsystem/openapi-ng-android-arm-eabi/package.json').version - if (bindingPackageVersion !== '0.4.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.4.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.5.2' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.5.2 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -114,8 +114,8 @@ function requireNative() { try { const binding = require('@avsystem/openapi-ng-win32-x64-gnu') const bindingPackageVersion = require('@avsystem/openapi-ng-win32-x64-gnu/package.json').version - if (bindingPackageVersion !== '0.4.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.4.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.5.2' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.5.2 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -130,8 +130,8 @@ function requireNative() { try { const binding = require('@avsystem/openapi-ng-win32-x64-msvc') const bindingPackageVersion = require('@avsystem/openapi-ng-win32-x64-msvc/package.json').version - if (bindingPackageVersion !== '0.4.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.4.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.5.2' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.5.2 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -147,8 +147,8 @@ function requireNative() { try { const binding = require('@avsystem/openapi-ng-win32-ia32-msvc') const bindingPackageVersion = require('@avsystem/openapi-ng-win32-ia32-msvc/package.json').version - if (bindingPackageVersion !== '0.4.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.4.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.5.2' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.5.2 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -163,8 +163,8 @@ function requireNative() { try { const binding = require('@avsystem/openapi-ng-win32-arm64-msvc') const bindingPackageVersion = require('@avsystem/openapi-ng-win32-arm64-msvc/package.json').version - if (bindingPackageVersion !== '0.4.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.4.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.5.2' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.5.2 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -182,8 +182,8 @@ function requireNative() { try { const binding = require('@avsystem/openapi-ng-darwin-universal') const bindingPackageVersion = require('@avsystem/openapi-ng-darwin-universal/package.json').version - if (bindingPackageVersion !== '0.4.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.4.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.5.2' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.5.2 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -198,8 +198,8 @@ function requireNative() { try { const binding = require('@avsystem/openapi-ng-darwin-x64') const bindingPackageVersion = require('@avsystem/openapi-ng-darwin-x64/package.json').version - if (bindingPackageVersion !== '0.4.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.4.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.5.2' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.5.2 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -214,8 +214,8 @@ function requireNative() { try { const binding = require('@avsystem/openapi-ng-darwin-arm64') const bindingPackageVersion = require('@avsystem/openapi-ng-darwin-arm64/package.json').version - if (bindingPackageVersion !== '0.4.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.4.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.5.2' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.5.2 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -234,8 +234,8 @@ function requireNative() { try { const binding = require('@avsystem/openapi-ng-freebsd-x64') const bindingPackageVersion = require('@avsystem/openapi-ng-freebsd-x64/package.json').version - if (bindingPackageVersion !== '0.4.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.4.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.5.2' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.5.2 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -250,8 +250,8 @@ function requireNative() { try { const binding = require('@avsystem/openapi-ng-freebsd-arm64') const bindingPackageVersion = require('@avsystem/openapi-ng-freebsd-arm64/package.json').version - if (bindingPackageVersion !== '0.4.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.4.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.5.2' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.5.2 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -271,8 +271,8 @@ function requireNative() { try { const binding = require('@avsystem/openapi-ng-linux-x64-musl') const bindingPackageVersion = require('@avsystem/openapi-ng-linux-x64-musl/package.json').version - if (bindingPackageVersion !== '0.4.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.4.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.5.2' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.5.2 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -287,8 +287,8 @@ function requireNative() { try { const binding = require('@avsystem/openapi-ng-linux-x64-gnu') const bindingPackageVersion = require('@avsystem/openapi-ng-linux-x64-gnu/package.json').version - if (bindingPackageVersion !== '0.4.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.4.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.5.2' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.5.2 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -305,8 +305,8 @@ function requireNative() { try { const binding = require('@avsystem/openapi-ng-linux-arm64-musl') const bindingPackageVersion = require('@avsystem/openapi-ng-linux-arm64-musl/package.json').version - if (bindingPackageVersion !== '0.4.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.4.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.5.2' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.5.2 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -321,8 +321,8 @@ function requireNative() { try { const binding = require('@avsystem/openapi-ng-linux-arm64-gnu') const bindingPackageVersion = require('@avsystem/openapi-ng-linux-arm64-gnu/package.json').version - if (bindingPackageVersion !== '0.4.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.4.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.5.2' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.5.2 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -339,8 +339,8 @@ function requireNative() { try { const binding = require('@avsystem/openapi-ng-linux-arm-musleabihf') const bindingPackageVersion = require('@avsystem/openapi-ng-linux-arm-musleabihf/package.json').version - if (bindingPackageVersion !== '0.4.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.4.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.5.2' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.5.2 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -355,8 +355,8 @@ function requireNative() { try { const binding = require('@avsystem/openapi-ng-linux-arm-gnueabihf') const bindingPackageVersion = require('@avsystem/openapi-ng-linux-arm-gnueabihf/package.json').version - if (bindingPackageVersion !== '0.4.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.4.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.5.2' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.5.2 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -373,8 +373,8 @@ function requireNative() { try { const binding = require('@avsystem/openapi-ng-linux-loong64-musl') const bindingPackageVersion = require('@avsystem/openapi-ng-linux-loong64-musl/package.json').version - if (bindingPackageVersion !== '0.4.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.4.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.5.2' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.5.2 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -389,8 +389,8 @@ function requireNative() { try { const binding = require('@avsystem/openapi-ng-linux-loong64-gnu') const bindingPackageVersion = require('@avsystem/openapi-ng-linux-loong64-gnu/package.json').version - if (bindingPackageVersion !== '0.4.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.4.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.5.2' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.5.2 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -407,8 +407,8 @@ function requireNative() { try { const binding = require('@avsystem/openapi-ng-linux-riscv64-musl') const bindingPackageVersion = require('@avsystem/openapi-ng-linux-riscv64-musl/package.json').version - if (bindingPackageVersion !== '0.4.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.4.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.5.2' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.5.2 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -423,8 +423,8 @@ function requireNative() { try { const binding = require('@avsystem/openapi-ng-linux-riscv64-gnu') const bindingPackageVersion = require('@avsystem/openapi-ng-linux-riscv64-gnu/package.json').version - if (bindingPackageVersion !== '0.4.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.4.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.5.2' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.5.2 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -440,8 +440,8 @@ function requireNative() { try { const binding = require('@avsystem/openapi-ng-linux-ppc64-gnu') const bindingPackageVersion = require('@avsystem/openapi-ng-linux-ppc64-gnu/package.json').version - if (bindingPackageVersion !== '0.4.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.4.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.5.2' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.5.2 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -456,8 +456,8 @@ function requireNative() { try { const binding = require('@avsystem/openapi-ng-linux-s390x-gnu') const bindingPackageVersion = require('@avsystem/openapi-ng-linux-s390x-gnu/package.json').version - if (bindingPackageVersion !== '0.4.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.4.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.5.2' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.5.2 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -476,8 +476,8 @@ function requireNative() { try { const binding = require('@avsystem/openapi-ng-openharmony-arm64') const bindingPackageVersion = require('@avsystem/openapi-ng-openharmony-arm64/package.json').version - if (bindingPackageVersion !== '0.4.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.4.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.5.2' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.5.2 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -492,8 +492,8 @@ function requireNative() { try { const binding = require('@avsystem/openapi-ng-openharmony-x64') const bindingPackageVersion = require('@avsystem/openapi-ng-openharmony-x64/package.json').version - if (bindingPackageVersion !== '0.4.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.4.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.5.2' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.5.2 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -508,8 +508,8 @@ function requireNative() { try { const binding = require('@avsystem/openapi-ng-openharmony-arm') const bindingPackageVersion = require('@avsystem/openapi-ng-openharmony-arm/package.json').version - if (bindingPackageVersion !== '0.4.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.4.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.5.2' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.5.2 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -648,8 +648,8 @@ if (!nativeBinding || forceWasi) { if (!candidateFailed) { if (process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { const bindingPackageVersion = require('@avsystem/openapi-ng-wasm32-wasi/package.json').version - if (bindingPackageVersion !== '0.4.0') { - throw new Error(`WASI binding package version mismatch, expected 0.4.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.5.2') { + throw new Error(`WASI binding package version mismatch, expected 0.5.2 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } } wasiBinding = require('@avsystem/openapi-ng-wasm32-wasi') diff --git a/package.json b/package.json index 8c6a90b..53a1388 100644 --- a/package.json +++ b/package.json @@ -67,15 +67,17 @@ "artifacts": "napi artifacts", "bench": "bun benchmark/bench.ts", "build": "napi build --platform --release --js native.js", - "postbuild": "bun scripts/patch-types.mjs", + "postbuild": "bun scripts/patch-types.ts", "build:debug": "napi build --platform --js native.js", + "postbuild:debug": "bun scripts/patch-types.ts", "format": "run-p format:oxfmt format:rs format:toml", "format:oxfmt": "oxfmt . --write", "format:toml": "taplo format", "format:rs": "cargo fmt", "lint": "oxlint", - "prepublishOnly": "bun scripts/check-version-not-placeholder.mjs && napi prepublish -t npm", - "regen-snapshots": "bun scripts/regen-snapshots.mjs", + "typecheck": "tsc -p scripts/tsconfig.json", + "prepublishOnly": "bun scripts/check-version-not-placeholder.ts && napi prepublish -t npm", + "regen-snapshots": "bun scripts/regen-snapshots.ts", "test": "ava", "version": "napi version" }, diff --git a/scripts/check-version-not-placeholder.mjs b/scripts/check-version-not-placeholder.mjs deleted file mode 100644 index a88ede4..0000000 --- a/scripts/check-version-not-placeholder.mjs +++ /dev/null @@ -1,14 +0,0 @@ -import { readFileSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; -import { resolve, dirname } from 'node:path'; - -const here = dirname(fileURLToPath(import.meta.url)); -const pkg = JSON.parse(readFileSync(resolve(here, '..', 'package.json'), 'utf8')); - -if (pkg.version === '0.0.0') { - console.error(`Refusing to publish: package.json version is "0.0.0" (placeholder).`); - console.error( - `Set a real version (e.g. via \`bun pm version \` then \`napi version\`).`, - ); - process.exit(1); -} diff --git a/scripts/check-version-not-placeholder.ts b/scripts/check-version-not-placeholder.ts new file mode 100644 index 0000000..795af2f --- /dev/null +++ b/scripts/check-version-not-placeholder.ts @@ -0,0 +1,20 @@ +#!/usr/bin/env bun +// Refuses to publish while package.json still carries the placeholder +// version, which would claim 0.0.0 on the registry. + +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const PLACEHOLDER = '0.0.0'; + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..'); +const pkg = JSON.parse(readFileSync(join(repoRoot, 'package.json'), 'utf8')) as { + version?: string; +}; + +if (pkg.version === PLACEHOLDER) { + console.error(`Refusing to publish: package.json version is "${PLACEHOLDER}" (placeholder).`); + console.error('Set a real version (`bun pm version `, then `napi version`).'); + process.exit(1); +} diff --git a/scripts/lib/engine.ts b/scripts/lib/engine.ts new file mode 100644 index 0000000..6e1638d --- /dev/null +++ b/scripts/lib/engine.ts @@ -0,0 +1,49 @@ +// Loads the generator from the local build, so a script run exercises the +// tree it was invoked in. +// +// `lib/index.js` is untyped CommonJS implementing the surface the +// repository's `index.d.ts` declares. The shape check below turns a +// renamed or missing export into an error naming it, at load time. + +import { createRequire } from 'node:module'; + +import type { GenerateError, GenerateOptions, GenerateResult } from '../../index.js'; + +export type { GenerateError, GenerateOptions, GenerateResult }; + +/** The one entry point a script needs. */ +type Generate = (options: GenerateOptions) => Promise; + +/** The runtime surface of `lib/index.js` these scripts rely on. */ +interface Wrapper { + readonly generate: Generate; + readonly GenerateError: { + isGenerateError: (value: unknown) => value is GenerateError; + }; +} + +/** Fails naming the export that is missing or no longer a function. */ +function loadWrapper(): Wrapper { + const loaded: unknown = createRequire(import.meta.url)('../../lib/index.js'); + if (loaded === null || typeof loaded !== 'object') { + throw new TypeError('lib/index.js did not export an object'); + } + + const exported: Partial = loaded; + if (typeof exported.generate !== 'function') { + throw new TypeError('lib/index.js does not export generate()'); + } + if (typeof exported.GenerateError?.isGenerateError !== 'function') { + throw new TypeError('lib/index.js does not export GenerateError.isGenerateError()'); + } + return { generate: exported.generate, GenerateError: exported.GenerateError }; +} + +const wrapper = loadWrapper(); + +export const generate: Generate = wrapper.generate; + +/** Narrows a caught value to the generator's own failure type. */ +export function isGenerateError(value: unknown): value is GenerateError { + return wrapper.GenerateError.isGenerateError(value); +} diff --git a/scripts/lib/snapshot-layout.ts b/scripts/lib/snapshot-layout.ts new file mode 100644 index 0000000..435368b --- /dev/null +++ b/scripts/lib/snapshot-layout.ts @@ -0,0 +1,31 @@ +// The snapshot storage layout, shared by the regenerator +// (scripts/regen-snapshots.ts) and the reader +// (__test__/generate.snapshot.spec.ts), so the two cannot drift. + +import path from 'node:path'; + +/** + * The do-not-edit banner, stripped from every stored artifact so snapshots + * survive a version bump. + */ +export const BANNER_RE = + /^\/\/ Generated by openapi-ng v[^\n]*\n\/\/ Source: [^\n]*\n\/\/ DO NOT EDIT[^\n]*\n\n/u; + +/** + * Artifacts byte-identical across every success fixture. Each per-fixture + * directory omits them; their bodies are stored once under + * `static-template/`. + */ +export const STATIC_TEMPLATE_PATHS: ReadonlySet = new Set([ + 'rest.model.ts', + 'rest.util.ts', + 'rest.validate.ts', +]); + +/** Directory holding every snapshot, given the repository root. */ +export function snapshotDir(repoRoot: string): string { + return path.join(repoRoot, '__test__', 'snapshots', 'generate-native'); +} + +/** The emit targets every snapshot is generated with. */ +export const SNAPSHOT_EMIT = ['models', 'angular'] as const; diff --git a/scripts/package.json b/scripts/package.json new file mode 100644 index 0000000..3dbc1ca --- /dev/null +++ b/scripts/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/scripts/patch-types.mjs b/scripts/patch-types.mjs deleted file mode 100644 index 541eb3b..0000000 --- a/scripts/patch-types.mjs +++ /dev/null @@ -1,241 +0,0 @@ -#!/usr/bin/env node -// Post-processes the NAPI-RS-generated `index.d.ts`: -// 1. Narrows `code: string` to `code: DiagnosticCode` (named union) -// and `severity: string` to `severity: 'warning' | 'error'` so TS -// consumers get autocomplete and exhaustive switches. -// 2. Rewrites the `export declare const enum EmitTarget` block into a -// string-literal union plus an ambient frozen-const declaration. -// `const enum` in a published `.d.ts` is hostile to consumers -// compiling under `isolatedModules` / `verbatimModuleSyntax` (Vite, -// esbuild, Bun, TS 5.x defaults), so we replace the shape with one -// that survives single-file transpilation. -// 3. Concatenates the hand-authored tail file `index.d.ts.in`, which -// carries the `DiagnosticCode` union and the `GenerateError` class -// declaration (defined in `lib/index.js`, not by napi-rs). -// -// Run automatically by the `postbuild` npm script after `napi build`. - -import fs from 'node:fs'; -import path from 'node:path'; -import url from 'node:url'; - -const __dirname = path.dirname(url.fileURLToPath(import.meta.url)); -const repoRoot = path.join(__dirname, '..'); -const dtsPath = path.join(repoRoot, 'index.d.ts'); -const tailPath = path.join(repoRoot, 'index.d.ts.in'); - -let content = fs.readFileSync(dtsPath, 'utf8'); -const tail = fs.readFileSync(tailPath, 'utf8'); - -// Strip any prior tail concatenation so reruns stay idempotent. -const TAIL_BEGIN = '\n// Hand-authored tail'; -const beginIdx = content.indexOf(TAIL_BEGIN); -if (beginIdx !== -1) { - content = content.slice(0, beginIdx).trimEnd() + '\n'; -} - -/** - * Scope a set of literal text substitutions to the body of a named - * interface declaration. Catches drift (e.g. napi-rs changing indent, - * or another interface coincidentally containing `code: string`) early - * by failing loud when a target cannot be matched within the named - * block. - */ -function patchInterface(src, name, edits) { - const re = new RegExp(`(interface\\s+${name}\\s*\\{)([\\s\\S]*?)(^})`, 'm'); - const m = src.match(re); - if (!m) throw new Error(`interface ${name} not found`); - let body = m[2]; - for (const [from, to] of edits) { - if (!body.includes(from)) { - // Idempotent: already narrowed by a prior run. - if (body.includes(to)) continue; - throw new Error(`patch target not found in ${name}: ${from}`); - } - body = body.split(from).join(to); - } - return src.replace(re, `$1${body}$3`); -} - -// Narrow the napi-emitted opaque strings to typed unions. Same shape -// (`code: string`, `severity: string`) appears in multiple interfaces -// (GeneratorDiagnostic, GenerateErrorPayload) — we scope each set of -// substitutions to its own interface block so a stray `code: string` -// elsewhere can never silently corrupt the patch. -const diagnosticEdits = [ - [' code: string', ' code: DiagnosticCode'], - [' subcode?: string', ' subcode: DiagnosticSubcode | null'], - [' severity: string', " severity: 'warning' | 'error'"], -]; -const payloadEdits = [ - [' code: string', ' code: DiagnosticCode'], - [' subcode?: string', ' subcode: DiagnosticSubcode | null'], -]; - -content = patchInterface(content, 'GeneratorDiagnostic', diagnosticEdits); -content = patchInterface(content, 'GenerateErrorPayload', payloadEdits); - -// Replace the napi-rs-emitted `const enum EmitTarget` with a string-literal -// union plus an ambient frozen-const declaration. Consumers compiling under -// `isolatedModules` reject `const enum` imports across module boundaries -// (Vite, esbuild, Bun, TS 5.x defaults); the union+const pair preserves the -// `EmitTarget.Models` access shape while keeping the surface importable. -const ENUM_BLOCK_RE = - /export declare const enum EmitTarget \{\s*Models = 'models',\s*Angular = 'angular'\s*\}/; -const ENUM_REPLACEMENT = - "export type EmitTarget = 'models' | 'angular';\n" + - 'export declare const EmitTarget: {\n' + - " readonly Models: 'models';\n" + - " readonly Angular: 'angular';\n" + - '};'; -if (ENUM_BLOCK_RE.test(content)) { - content = content.replace(ENUM_BLOCK_RE, ENUM_REPLACEMENT); -} else if (!content.includes("export type EmitTarget = 'models' | 'angular';")) { - throw new Error( - 'EmitTarget const-enum block not found and union form not already present', - ); -} - -// Mark `GenerateOptions.emit` optional on the published surface. The JS -// wrapper in `lib/index.js` defaults the field to `['models', 'angular']` -// (mirroring the CLI's DEFAULT_EMIT in `bin/lib/parse.js`) before -// crossing the NAPI boundary, so consumers can omit it. Kept as a -// rewrite here — rather than annotating the Rust field as -// `Option>` — so the core `GenerateConfig::from` -// conversion (and its cargo-side tests) continue to receive a -// populated emit list with no extra None-handling. -if (content.includes('emit: Array')) { - content = content.replace('emit: Array', 'emit?: Array'); -} else if (!content.includes('emit?: Array')) { - throw new Error('patch-types: expected `emit: Array` in index.d.ts'); -} - -// Rewrite `GenerateOptions.naming` from the NAPI-boundary shape -// (`NamingOptions`) to the user-friendly shape (`NamingConfig`). -// Native `RegExp` does not cross the NAPI boundary as a JS object so -// the Rust side declares `NamingParseSpec { source, flags }`; the JS -// wrapper unpacks user `RegExp`s into that shape. Consumers should -// only ever see the friendly `NamingConfig` type on the published -// surface, not the wire shape. -if (content.includes('naming?: NamingOptions')) { - content = content.replace('naming?: NamingOptions', 'naming?: NamingConfig'); -} else if (!content.includes('naming?: NamingConfig')) { - throw new Error('patch-types: expected `naming?: NamingOptions` in index.d.ts'); -} - -// The native export and its union are wrapper-internal. Remove them (and -// their leading doc comment) from the public surface; the hand-authored -// tail declares `generate`. -// `[^*]|\*(?!/)` (not `[\s\S]*?`) so the lazy match can't skip past this -// declaration's own closing `*/` and swallow a later, unrelated block. -const LEADING_DOC = '(?:^/\\*\\*(?:[^*]|\\*(?!/))*\\*/\\n)?'; -const NATIVE_FN_RE = new RegExp( - `${LEADING_DOC}^export declare function generateNative\\([^\\n]*\\n`, - 'm', -); -const OUTCOME_RE = new RegExp( - `${LEADING_DOC}^export interface GenerateOutcome \\{[\\s\\S]*?^\\}\\n`, - 'm', -); -content = content.replace(NATIVE_FN_RE, '').replace(OUTCOME_RE, ''); -// Scoped to the declaration forms because GenerateErrorPayload's doc comment -// legitimately mentions `GenerateOutcome.error` in prose. -if ( - /^export (?:declare function generateNative|interface GenerateOutcome)\b/m.test(content) -) { - throw new Error( - 'patch-types: generateNative/GenerateOutcome declaration survived stripping; update the patterns', - ); -} - -// `inputPath` becomes optional on the published surface because the -// caller may instead pass `inputContents` (validated mutually -// exclusive at runtime). napi-rs may emit it as either required or -// optional depending on whether the Rust field is Option at -// build time; both cases are handled here. -if (content.includes('inputPath: string')) { - content = content.replace('inputPath: string', 'inputPath?: string'); -} else if (!content.includes('inputPath?: string')) { - throw new Error( - 'patch-types: expected `inputPath: string` or `inputPath?: string` in index.d.ts', - ); -} - -content = content.trimEnd() + '\n\n' + tail.trimEnd() + '\n'; -// Stripped declarations leave runs of blank lines behind. -content = content.replace(/\n{3,}/g, '\n\n'); - -fs.writeFileSync(dtsPath, content); -console.log('patch-types: narrowed code/severity and appended tail to index.d.ts'); - -// --------------------------------------------------------------------------- -// Patch native.js: inject a friendly error for unsupported platforms BEFORE -// the generic npm-bug-report throw that NAPI-RS emits. -// --------------------------------------------------------------------------- -const nativePath = path.join(repoRoot, 'native.js'); -let nativeContent = fs.readFileSync(nativePath, 'utf8'); - -// Idempotency guard: skip if the injection is already present. -const INJECTION_GUARD = '__OPENAPI_NG_PLATFORM_KEY__'; -if (!nativeContent.includes(INJECTION_GUARD)) { - // The exact marker string that NAPI-RS emits; fail loud if it drifts. - const MARKER = 'if (!nativeBinding) {\n if (loadErrors.length > 0) {'; - const markerIdx = nativeContent.indexOf(MARKER); - if (markerIdx === -1) { - throw new Error( - 'patch-native-loader: cannot find expected marker in native.js — ' + - 'NAPI-RS may have changed its generated output; update the patch.', - ); - } - - const injection = - "const __OPENAPI_NG_PLATFORM_KEY__ = process.platform + '/' + process.arch;\n" + - 'const __OPENAPI_NG_SUPPORTED__ = new Set([\n' + - " 'darwin/x64', 'darwin/arm64',\n" + - " 'linux/x64', 'linux/arm64',\n" + - " 'win32/x64', 'win32/arm64',\n" + - ']);\n' + - 'if (!nativeBinding && !__OPENAPI_NG_SUPPORTED__.has(__OPENAPI_NG_PLATFORM_KEY__)) {\n' + - ' throw new Error(\n' + - " 'openapi-ng does not ship a native binary for ' + __OPENAPI_NG_PLATFORM_KEY__ + '. ' +\n" + - " 'Supported platforms: ' + [...__OPENAPI_NG_SUPPORTED__].sort().join(', ') + '. ' +\n" + - " 'If you need this platform, please file an issue, or install @avsystem/openapi-ng-wasm32-wasi for a WebAssembly fallback.',\n" + - ' );\n' + - '}\n\n'; - - nativeContent = - nativeContent.slice(0, markerIdx) + injection + nativeContent.slice(markerIdx); - - fs.writeFileSync(nativePath, nativeContent); - console.log('patch-types: injected unsupported-platform error into native.js'); -} else { - console.log('patch-types: native.js already patched (idempotent skip)'); -} - -// --------------------------------------------------------------------------- -// Re-author browser.js. `napi build` writes an `export *` stub over it, so -// the post-build step restores the real entry, which lives in -// lib/browser.js where napi never touches it. -// --------------------------------------------------------------------------- -const browserPath = path.join(repoRoot, 'browser.js'); -const browserContent = `'use strict';\n\nmodule.exports = require('./lib/browser.js');\n`; - -const existingBrowser = fs.existsSync(browserPath) - ? fs.readFileSync(browserPath, 'utf8') - : null; -if (existingBrowser === browserContent) { - console.log('patch-types: browser.js already canonical (idempotent skip)'); -} else { - fs.writeFileSync(browserPath, browserContent); - console.log('patch-types: re-authored browser.js as a lib/browser.js re-export'); -} - -// `browser.d.ts` types the `./browser` subpath. napi never writes a file with -// this name, so it is hand-authored and committed; fail loud if it goes -// missing rather than publishing an untyped browser entry. -const browserDtsPath = path.join(repoRoot, 'browser.d.ts'); -if (!fs.existsSync(browserDtsPath)) { - throw new Error( - 'patch-types: browser.d.ts is missing — the ./browser subpath would publish untyped', - ); -} diff --git a/scripts/patch-types.ts b/scripts/patch-types.ts new file mode 100644 index 0000000..f9ea988 --- /dev/null +++ b/scripts/patch-types.ts @@ -0,0 +1,267 @@ +#!/usr/bin/env bun +// Post-processes what `napi build` generates, so the published surface is +// the one consumers should see. +// +// A patch that no longer matches fails the build naming itself, so a +// change in NAPI-RS output cannot publish an unpatched surface. Every +// patch is idempotent: a rerun on a patched tree is a no-op. +// +// Runs from the `postbuild` / `postbuild:debug` scripts. + +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..'); +const dtsPath = join(repoRoot, 'index.d.ts'); +const tailPath = join(repoRoot, 'index.d.ts.in'); +const nativePath = join(repoRoot, 'native.js'); +const browserPath = join(repoRoot, 'browser.js'); +const browserDtsPath = join(repoRoot, 'browser.d.ts'); + +/** One rewrite of a generated file. */ +interface Patch { + /** Named in the drift error when the patch cannot be applied. */ + readonly name: string; + /** + * Returns the rewritten source, or `null` when the source is already in + * the target shape. Throws when it is in neither. + */ + readonly apply: (source: string) => string | null; +} + +class DriftError extends Error { + constructor(patch: string, detail: string) { + super( + `patch-types: ${patch} could not be applied — ${detail}. ` + + `NAPI-RS output may have changed; update the patch.`, + ); + this.name = 'DriftError'; + } +} + +function applyAll(source: string, patches: readonly Patch[]): string { + return patches.reduce((current, patch) => patch.apply(current) ?? current, source); +} + +/** + * Replaces `find` with `replace`. Treats a source that already contains + * `replace` as already patched; anything else is drift. + */ +function rewrite(name: string, find: string, replace: string): Patch { + return { + name, + apply: source => { + if (source.includes(find)) return source.split(find).join(replace); + if (source.includes(replace)) return null; + throw new DriftError(name, `neither ${JSON.stringify(find)} nor its replacement found`); + }, + }; +} + +/** Replaces the first match of `find`, or does nothing when `settled` holds. */ +function rewritePattern( + name: string, + find: RegExp, + replace: string, + settled: (source: string) => boolean, +): Patch { + return { + name, + apply: source => { + if (find.test(source)) return source.replace(find, replace); + if (settled(source)) return null; + throw new DriftError(name, `pattern ${find} did not match`); + }, + }; +} + +/** + * Scopes literal substitutions to the body of one named interface, so a + * coincidental `code: string` elsewhere can never be caught by the patch. + */ +function withinInterface( + name: string, + interfaceName: string, + edits: readonly (readonly [string, string])[], +): Patch { + /** Capture group holding the interface body, between its braces. */ + const BODY_GROUP = 2; + const block = new RegExp(`(interface\\s+${interfaceName}\\s*\\{)([\\s\\S]*?)(^})`, 'm'); + return { + name, + apply: source => { + const found = source.match(block); + if (!found) throw new DriftError(name, `interface ${interfaceName} not found`); + let body = found[BODY_GROUP]; + if (body === undefined) { + throw new DriftError(name, 'the interface-body capture group is missing'); + } + for (const [from, to] of edits) { + if (body.includes(from)) { + body = body.split(from).join(to); + } else if (!body.includes(to)) { + throw new DriftError(name, `${JSON.stringify(from)} not found in ${interfaceName}`); + } + } + return source.replace(block, `$1${body}$3`); + }, + }; +} + +// Narrow the opaque strings NAPI emits to the named unions consumers can +// switch on exhaustively. The same field shapes appear in more than one +// interface, so each set is scoped to its own block. +const NARROWED_DIAGNOSTIC = [ + [' code: string', ' code: DiagnosticCode'], + [' subcode?: string', ' subcode: DiagnosticSubcode | null'], + [' severity: string', " severity: 'warning' | 'error'"], +] as const; + +const EMIT_TARGET_UNION = "export type EmitTarget = 'models' | 'angular';"; + +// `[^*]|\*(?!/)` rather than `[\s\S]*?` so a lazy match cannot run past this +// declaration's own `*/` and swallow the next block. +const LEADING_DOC = '(?:^/\\*\\*(?:[^*]|\\*(?!/))*\\*/\\n)?'; + +const dtsPatches: readonly Patch[] = [ + withinInterface('diagnostic narrowing', 'GeneratorDiagnostic', NARROWED_DIAGNOSTIC), + withinInterface('error-payload narrowing', 'GenerateErrorPayload', NARROWED_DIAGNOSTIC.slice(0, 2)), + + // A `const enum` in a published .d.ts breaks consumers compiling under + // isolatedModules / verbatimModuleSyntax (Vite, esbuild, Bun, TS 5+ + // defaults). The union plus an ambient const keeps `EmitTarget.Models` + // working while staying importable from a single-file transpile. + rewritePattern( + 'EmitTarget const-enum removal', + /export declare const enum EmitTarget \{\s*Models = 'models',\s*Angular = 'angular'\s*\}/, + [ + EMIT_TARGET_UNION, + 'export declare const EmitTarget: {', + " readonly Models: 'models';", + " readonly Angular: 'angular';", + '};', + ].join('\n'), + source => source.includes(EMIT_TARGET_UNION), + ), + + // The wrapper defaults `emit` before the boundary, so a consumer may + // omit it. + rewrite('optional emit', 'emit: Array', 'emit?: Array'), + + // `inputPath` is optional because a caller may pass `inputContents` + // instead; the two are validated mutually exclusive at runtime. + rewrite('optional inputPath', 'inputPath: string', 'inputPath?: string'), + + // A JS `RegExp` cannot cross the NAPI boundary, so Rust declares the + // `{ source, flags }` wire shape the wrapper unpacks into. + rewrite('friendly naming type', 'naming?: NamingOptions', 'naming?: NamingConfig'), + + // The native export and its result union are wrapper-internal; the + // hand-authored tail declares `generate` instead. + { + name: 'native-export stripping', + apply: source => { + const stripped = source + .replace(new RegExp(`${LEADING_DOC}^export declare function generateNative\\([^\\n]*\\n`, 'm'), '') + .replace(new RegExp(`${LEADING_DOC}^export interface GenerateOutcome \\{[\\s\\S]*?^\\}\\n`, 'm'), ''); + // Scoped to the declaration forms: GenerateErrorPayload's own doc + // comment legitimately mentions `GenerateOutcome.error` in prose. + if (/^export (?:declare function generateNative|interface GenerateOutcome)\b/m.test(stripped)) { + throw new DriftError('native-export stripping', 'a declaration survived'); + } + return stripped; + }, + }, +]; + +/** Marks where the hand-authored tail begins, so reruns stay idempotent. */ +const TAIL_MARKER = '\n// Hand-authored tail'; + +function patchTypes(): void { + const source = readFileSync(dtsPath, 'utf8'); + const priorTail = source.indexOf(TAIL_MARKER); + const generated = priorTail === -1 ? source : `${source.slice(0, priorTail).trimEnd()}\n`; + + const tail = readFileSync(tailPath, 'utf8').trimEnd(); + const patched = `${applyAll(generated, dtsPatches).trimEnd()}\n\n${tail}\n`; + + // Stripped declarations leave runs of blank lines behind. + writeFileSync(dtsPath, patched.replace(/\n{3,}/g, '\n\n')); + console.log('patch-types: narrowed the diagnostic surface and appended the tail to index.d.ts'); +} + +/** Present once the platform guard has been injected. */ +const PLATFORM_GUARD = '__OPENAPI_NG_PLATFORM_KEY__'; + +/** The exact text NAPI-RS emits before its generic npm-bug-report throw. */ +const NAPI_FALLBACK_MARKER = 'if (!nativeBinding) {\n if (loadErrors.length > 0) {'; + +/** Grouped by OS, matching how the emitted `native.js` reads. */ +const SUPPORTED_PLATFORMS = [ + ["'darwin/x64'", "'darwin/arm64'"], + ["'linux/x64'", "'linux/arm64'"], + ["'win32/x64'", "'win32/arm64'"], +]; + +/** + * Injects a platform-specific load error ahead of NAPI-RS's generic one, so + * a consumer on an unsupported platform is told which platforms ship a + * binary and that a WebAssembly fallback exists. + */ +function patchNativeLoader(): void { + const source = readFileSync(nativePath, 'utf8'); + if (source.includes(PLATFORM_GUARD)) { + console.log('patch-types: native.js already patched'); + return; + } + + const marker = source.indexOf(NAPI_FALLBACK_MARKER); + if (marker === -1) { + throw new DriftError('native loader guard', 'the NAPI-RS fallback marker is missing'); + } + + const guard = [ + `const ${PLATFORM_GUARD} = process.platform + '/' + process.arch;`, + 'const __OPENAPI_NG_SUPPORTED__ = new Set([', + ...SUPPORTED_PLATFORMS.map(group => ` ${group.join(', ')},`), + ']);', + `if (!nativeBinding && !__OPENAPI_NG_SUPPORTED__.has(${PLATFORM_GUARD})) {`, + ' throw new Error(', + ` 'openapi-ng does not ship a native binary for ' + ${PLATFORM_GUARD} + '. ' +`, + " 'Supported platforms: ' + [...__OPENAPI_NG_SUPPORTED__].sort().join(', ') + '. ' +", + " 'If you need this platform, please file an issue, or install @avsystem/openapi-ng-wasm32-wasi for a WebAssembly fallback.',", + ' );', + '}', + '', + '', + ].join('\n'); + + writeFileSync(nativePath, source.slice(0, marker) + guard + source.slice(marker)); + console.log('patch-types: injected the unsupported-platform error into native.js'); +} + +/** `napi build` overwrites browser.js with an `export *` stub. */ +const BROWSER_ENTRY = "'use strict';\n\nmodule.exports = require('./lib/browser.js');\n"; + +function restoreBrowserEntry(): void { + const current = existsSync(browserPath) ? readFileSync(browserPath, 'utf8') : null; + if (current === BROWSER_ENTRY) { + console.log('patch-types: browser.js already canonical'); + return; + } + writeFileSync(browserPath, BROWSER_ENTRY); + console.log('patch-types: re-authored browser.js as a lib/browser.js re-export'); +} + +/** `browser.d.ts` is hand-authored: napi does not recreate it. */ +function assertBrowserTypesPresent(): void { + if (!existsSync(browserDtsPath)) { + throw new Error('patch-types: browser.d.ts is missing — ./browser would publish untyped'); + } +} + +patchTypes(); +patchNativeLoader(); +restoreBrowserEntry(); +assertBrowserTypesPresent(); diff --git a/scripts/regen-snapshots.mjs b/scripts/regen-snapshots.mjs deleted file mode 100644 index e7e6b4d..0000000 --- a/scripts/regen-snapshots.mjs +++ /dev/null @@ -1,385 +0,0 @@ -#!/usr/bin/env node -// Regenerate __test__/snapshots/generate-native/*.{success,failure}.json by -// running each fixture through generate and writing the result. -// -// Run with: bun run regen-snapshots -// -// Storage layout: -// - .success.json — summary + diagnostics + a path-only -// artifacts list (no inline contents). -// - / — each artifact's contents lives in a -// sibling file. PR diffs read as real -// TS instead of JSON-escaped strings. -// - static-template.json — path-only list for the angular static -// templates that are byte-identical -// across every success fixture. -// - static-template/ — sibling files for those templates. -// -// Both success and failure snapshots are regenerated. Use this script -// after any legitimate output change (emit format tweak, diagnostic -// message edit, planner reorder) — it is the single source of truth for -// the deep-equal snapshot tests in __test__/generate.snapshot.spec.ts. -// -// Snapshots that would NOT change are reported as "unchanged" and not -// rewritten — safe to run on a clean tree. - -import { generate } from '../lib/index.js'; -import fs from 'node:fs'; -import path from 'node:path'; -import url from 'node:url'; - -const __dirname = path.dirname(url.fileURLToPath(import.meta.url)); -const repoRoot = path.resolve(__dirname, '..'); -const snapshotDir = path.join(repoRoot, '__test__/snapshots/generate-native'); - -// emit is required; artifacts are always returned with full contents. -const DEFAULT_OPTIONS = { - emit: ['models', 'angular'], -}; - -// Strip the per-version banner from artifact contents so snapshots -// survive version bumps. Keep this in sync with BANNER_RE in -// __test__/generate.snapshot.spec.ts. -const BANNER_RE = - /^\/\/ Generated by openapi-ng v[^\n]*\n\/\/ Source: [^\n]*\n\/\/ DO NOT EDIT[^\n]*\n\n/u; - -// Paths whose contents are byte-identical across every success fixture -// (the angular static-template artifacts). Each per-fixture sibling -// directory omits these files — they live once under -// __test__/snapshots/generate-native/static-template/. -const STATIC_TEMPLATE_PATHS = new Set([ - 'rest.model.ts', - 'rest.util.ts', - 'rest.validate.ts', -]); - -const STATIC_TEMPLATE_DIR = path.join(snapshotDir, 'static-template'); -const STATIC_TEMPLATE_INDEX = path.join(snapshotDir, 'static-template.json'); - -function failurePayload(err) { - return { - code: err.code, - message: err.message, - path: err.path ?? null, - warnings: err.warnings ?? [], - }; -} - -const successFixtures = [ - 'petstore-minimal.openapi.yaml', - 'petstore-minimal.openapi.json', - 'petstore-rich.openapi.yaml', - 'petstore-rich.openapi.json', - 'oneof-anyof-composition.openapi.yaml', - 'oneof-anyof-composition.openapi.json', - 'allof-composition.openapi.yaml', - 'additional-properties.openapi.yaml', - 'additional-properties-false.openapi.yaml', - 'recursive-model.openapi.yaml', - 'single-entry-composition.openapi.yaml', - 'empty-shapes.openapi.yaml', - 'inline-model.openapi.yaml', - 'nullable-optional.openapi.yaml', - 'header-param.openapi.yaml', - 'large-enum.openapi.yaml', - 'multi-tag-operation.openapi.yaml', - 'nullable-oneof.openapi.yaml', - 'security-schemes.openapi.yaml', - 'circular-allof.openapi.yaml', - 'discriminated-union.openapi.yaml', - 'bench-large.openapi.yaml', - 'reserved-prop-names.openapi.yaml', - 'jsdoc-descriptions.openapi.yaml', - 'multi-warning.openapi.yaml', - 'deprecated-fields.openapi.yaml', - 'recursive-oneof.openapi.yaml', - 'response-204-no-content.openapi.yaml', - 'response-octet-stream.openapi.yaml', - 'response-default-fallback.openapi.yaml', - 'string-formats.openapi.yaml', - 'discriminator-mapping.openapi.yaml', - 'discriminator-allof.openapi.yaml', - 'anchor-modest.openapi.yaml', - 'body-multipart-mixed-fields.openapi.yaml', - 'body-multipart-ref-to-named-object.openapi.yaml', - 'body-urlencoded-scalar-and-array.openapi.yaml', - 'response-blob-via-pdf.openapi.yaml', - 'response-text-via-text-plain.openapi.yaml', - 'response-problem-json.openapi.yaml', -]; - -// Failure fixtures: each entry maps a fixture file to the snapshot -// label. The label distinguishes failure scenarios from the same fixture -// (e.g. petstore-rich has both a success path and an invalid-mapped-type -// failure path). For most fixtures the label is just the fixture name. -const failureFixtures = [ - { - fixture: 'empty-parameter.openapi.yaml', - snapshot: 'empty-parameter.openapi.yaml.failure.json', - }, - { - fixture: 'inline-parameter.openapi.yaml', - snapshot: 'inline-parameter.openapi.yaml.failure.json', - }, - { - fixture: 'invalid-enum-type.openapi.yaml', - snapshot: 'invalid-enum-type.openapi.yaml.failure.json', - }, - { - fixture: 'invalid-enum-value.openapi.json', - snapshot: 'invalid-enum-value.openapi.json.failure.json', - }, - // malformed.yaml message wording depends on serde_yml line/column - // output — asserted by regex in generate.snapshot.spec.ts instead of - // pinned here. - { fixture: 'unsupported-root.yaml', snapshot: 'unsupported-root.yaml.failure.json' }, - { - fixture: 'unsupported-semantic.openapi.yaml', - snapshot: 'unsupported-semantic.openapi.yaml.failure.json', - }, - { - fixture: 'additional-properties-boolean.openapi.yaml', - snapshot: 'additional-properties-boolean.openapi.yaml.failure.json', - }, - { - fixture: 'external-ref.openapi.yaml', - snapshot: 'external-ref.openapi.yaml.failure.json', - }, - { - fixture: 'field-collision.openapi.yaml', - snapshot: 'field-collision.openapi.yaml.failure.json', - }, - // deep-nested-allof exercises the MAX_NORMALIZE_DEPTH guard at 40 - // levels of inline allOf nesting (well above the constant of 32, well - // below the serde recursion limit). Pins the unsupported-semantic - // diagnostic so a regression that removes the guard, raises the bound - // without regenerating, or shifts the error code surfaces here. - { - fixture: 'deep-nested-allof.openapi.yaml', - snapshot: 'deep-nested-allof.openapi.yaml.failure.json', - }, - // discriminator-missing-property exercises the - // narrow_discriminator_properties presence check. A oneOf member - // missing the declared discriminator property would otherwise be - // patched with a synthetic literal that never existed on the source - // schema. Pins the E_POLICY_VIOLATION + missing-discriminator-property - // subcode so the silent-miscompile regression surfaces here. - { - fixture: 'discriminator-missing-property.openapi.yaml', - snapshot: 'discriminator-missing-property.openapi.yaml.failure.json', - }, - // discriminator-mapping-external-ref exercises the - // resolve_discriminator validation: a mapping value that looks like a - // ref (contains `/`) but is not an internal `#/components/schemas/` - // ref must be rejected through the central `normalize_reference` - // path, instead of silently passing as a bare literal that never - // matches any union member. Pins the E_UNSUPPORTED_SEMANTIC - // diagnostic so a regression that bypasses the central resolver - // surfaces here. - { - fixture: 'discriminator-mapping-external-ref.openapi.yaml', - snapshot: 'discriminator-mapping-external-ref.openapi.yaml.failure.json', - }, - // unbalanced-path-template exercises the normalize-stage path string - // validation. Without the check the emit stage would silently produce - // a broken template (`url: `/pets/id`` with no `${encodeURIComponent}` - // expansion). Pins the rejection so a regression that removes the - // guard surfaces here. - { - fixture: 'unbalanced-path-template.openapi.yaml', - snapshot: 'unbalanced-path-template.openapi.yaml.failure.json', - }, - // anchor-fanout exercises the OPENAPI_NG_MAX_EXPANSION_RATIO guard: a - // ~45 KB source whose 500 schemas × 16 anchor aliases each - // re-serialise into ~15 MB of inlined node tree. Pins the - // mapping-expansion-exceeded subcode so a regression that removes the - // guard or shifts the cap surfaces here. - { - fixture: 'anchor-fanout.openapi.yaml', - snapshot: 'anchor-fanout.openapi.yaml.failure.json', - }, - // Policy-violation subcodes introduced in Phase 4 for multipart / - // urlencoded form bodies and unsupported content types. One fixture per - // subcode; each pins the diagnostic so a regression that renames a - // subcode or routes a reject path through a different arm surfaces here. - { - fixture: 'body-multi-content.openapi.yaml', - snapshot: 'body-multi-content.openapi.yaml.failure.json', - }, - { - fixture: 'body-content-type-xml.openapi.yaml', - snapshot: 'body-content-type-xml.openapi.yaml.failure.json', - }, - { - fixture: 'body-multipart-nested-object.openapi.yaml', - snapshot: 'body-multipart-nested-object.openapi.yaml.failure.json', - }, - { - fixture: 'body-multipart-composed-field.openapi.yaml', - snapshot: 'body-multipart-composed-field.openapi.yaml.failure.json', - }, - { - fixture: 'body-multipart-non-object.openapi.yaml', - snapshot: 'body-multipart-non-object.openapi.yaml.failure.json', - }, - { - fixture: 'body-multipart-open-schema.openapi.yaml', - snapshot: 'body-multipart-open-schema.openapi.yaml.failure.json', - }, - { - fixture: 'body-urlencoded-binary-field.openapi.yaml', - snapshot: 'body-urlencoded-binary-field.openapi.yaml.failure.json', - }, - { - fixture: 'body-urlencoded-nested-object.openapi.yaml', - snapshot: 'body-urlencoded-nested-object.openapi.yaml.failure.json', - }, -]; - -let updated = 0; -let unchanged = 0; - -function writeIfChanged(target, formatted) { - const prev = fs.existsSync(target) ? fs.readFileSync(target, 'utf8') : ''; - if (prev === formatted) { - unchanged += 1; - } else { - fs.mkdirSync(path.dirname(target), { recursive: true }); - fs.writeFileSync(target, formatted); - updated += 1; - console.log(`updated: ${path.relative(repoRoot, target)}`); - } -} - -/** - * Write `result` as a path-only snapshot JSON plus a sibling directory - * holding each non-static artifact's contents at `/`. - * Static-template paths are listed in the JSON but their contents are - * pinned once under static-template/ (handled separately). - */ -function writeSuccessSnapshot(fixtureName, result) { - const jsonPath = path.join(snapshotDir, `${fixtureName}.success.json`); - const siblingsDir = path.join(snapshotDir, fixtureName); - - // Track which sibling files are still live; any leftover under - // siblingsDir gets removed at the end so renames/deletes don't leave - // orphans. - const live = new Set(); - for (const a of result.artifacts) { - if (STATIC_TEMPLATE_PATHS.has(a.path)) continue; - const stripped = a.contents.replace(BANNER_RE, ''); - const target = path.join(siblingsDir, a.path); - writeIfChanged(target, stripped); - live.add(path.relative(siblingsDir, target)); - } - removeOrphans(siblingsDir, live); - - const pathOnly = { - summary: result.summary, - diagnostics: result.diagnostics, - artifacts: result.artifacts.map(a => ({ path: a.path })), - }; - const formatted = JSON.stringify(pathOnly, null, 2) + '\n'; - writeIfChanged(jsonPath, formatted); -} - -function writeStaticTemplate(result) { - const live = new Set(); - for (const a of result.artifacts) { - if (!STATIC_TEMPLATE_PATHS.has(a.path)) continue; - const stripped = a.contents.replace(BANNER_RE, ''); - const target = path.join(STATIC_TEMPLATE_DIR, a.path); - writeIfChanged(target, stripped); - live.add(path.relative(STATIC_TEMPLATE_DIR, target)); - } - removeOrphans(STATIC_TEMPLATE_DIR, live); - - // Sort for deterministic ordering across regen runs. - const sortedPaths = [...STATIC_TEMPLATE_PATHS].sort(); - const index = { artifacts: sortedPaths.map(p => ({ path: p })) }; - const formatted = JSON.stringify(index, null, 2) + '\n'; - writeIfChanged(STATIC_TEMPLATE_INDEX, formatted); -} - -function removeOrphans(dir, live) { - if (!fs.existsSync(dir)) return; - const walk = (current, prefix) => { - for (const entry of fs.readdirSync(current, { withFileTypes: true })) { - const child = path.join(current, entry.name); - const rel = prefix ? path.join(prefix, entry.name) : entry.name; - if (entry.isDirectory()) { - walk(child, rel); - // Remove empty intermediate directories left behind. - if (fs.readdirSync(child).length === 0) { - fs.rmdirSync(child); - } - } else if (!live.has(rel)) { - fs.rmSync(child); - console.log(`removed: ${path.relative(repoRoot, child)}`); - } - } - }; - walk(dir, ''); -} - -let staticTemplateWritten = false; -for (const fixture of successFixtures) { - const result = await generate({ - inputPath: path.join('test/fixtures', fixture), - ...DEFAULT_OPTIONS, - }); - if (!staticTemplateWritten) { - writeStaticTemplate(result); - staticTemplateWritten = true; - } - writeSuccessSnapshot(fixture, result); -} - -for (const { fixture, snapshot } of failureFixtures) { - let payload; - try { - await generate({ - inputPath: path.join('test/fixtures', fixture), - ...DEFAULT_OPTIONS, - }); - console.warn(`SKIP: ${fixture} succeeded — failure snapshot not regenerated`); - continue; - } catch (err) { - payload = failurePayload(err); - } - const next = JSON.stringify(payload, null, 2) + '\n'; - writeIfChanged(path.join(snapshotDir, snapshot), next); -} - -// Parameterised failure cases: same fixture, different option overrides -// each produce a distinct failure snapshot. -const parameterisedFailures = [ - { - fixture: 'petstore-rich.openapi.yaml', - options: { - mappedTypes: [{ schema: 'MissingSchema', import: '@demo/x', type: 'Missing' }], - }, - snapshot: 'petstore-rich.openapi.yaml.invalid-mapped-type.failure.json', - }, -]; - -for (const { fixture, options, snapshot } of parameterisedFailures) { - let payload; - try { - await generate({ - inputPath: path.join('test/fixtures', fixture), - ...DEFAULT_OPTIONS, - ...options, - }); - console.warn( - `SKIP: ${fixture} (${snapshot}) succeeded — failure snapshot not regenerated`, - ); - continue; - } catch (err) { - payload = failurePayload(err); - } - const next = JSON.stringify(payload, null, 2) + '\n'; - writeIfChanged(path.join(snapshotDir, snapshot), next); -} - -console.log(`\n${updated} snapshot(s) updated, ${unchanged} unchanged.`); diff --git a/scripts/regen-snapshots.ts b/scripts/regen-snapshots.ts new file mode 100644 index 0000000..4533152 --- /dev/null +++ b/scripts/regen-snapshots.ts @@ -0,0 +1,328 @@ +#!/usr/bin/env bun +// Regenerates __test__/snapshots/generate-native/ by running each fixture +// through `generate` and storing the result. +// +// Storage layout: +// .success.json summary, diagnostics, and a path-only artifact +// list — no inline contents +// / each artifact's body as a sibling file, so a +// PR diff reads as TypeScript rather than as +// JSON-escaped strings +// static-template.json the path-only list for the Angular support +// static-template/ files, whose bodies are identical across +// every success fixture and so stored once +// +// Every fixture in test/fixtures/ must appear in exactly one of the three +// sets below; the script fails on one that appears in none. +// +// Run with: bun run regen-snapshots + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { generate, isGenerateError } from './lib/engine.ts'; +import type { GenerateError, GenerateOptions, GenerateResult } from './lib/engine.ts'; +import { + BANNER_RE, + SNAPSHOT_EMIT, + STATIC_TEMPLATE_PATHS, + snapshotDir, +} from './lib/snapshot-layout.ts'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const fixturesDir = path.join(repoRoot, 'test', 'fixtures'); +const snapshots = snapshotDir(repoRoot); +const staticTemplateDir = path.join(snapshots, 'static-template'); +const staticTemplateIndex = path.join(snapshots, 'static-template.json'); + +/** One failure snapshot: a fixture, the options it needs, and its label. */ +interface FailureSnapshot { + readonly fixture: string; + readonly snapshot: string; + readonly options?: Partial; +} + +/** + * Fixtures that generate successfully and whose output is pinned. + * + * Ordered as the snapshots directory reads. + */ +const SUCCESS_FIXTURES: readonly string[] = [ + 'additional-properties-false.openapi.yaml', + 'additional-properties.openapi.yaml', + 'allof-composition.openapi.yaml', + 'anchor-modest.openapi.yaml', + 'bench-large.openapi.yaml', + 'body-multipart-mixed-fields.openapi.yaml', + 'body-multipart-ref-to-named-object.openapi.yaml', + 'body-urlencoded-scalar-and-array.openapi.yaml', + 'circular-allof.openapi.yaml', + 'deprecated-fields.openapi.yaml', + 'discriminated-union.openapi.yaml', + 'discriminator-allof.openapi.yaml', + 'discriminator-mapping.openapi.yaml', + 'empty-shapes.openapi.yaml', + 'header-param.openapi.yaml', + 'inline-model.openapi.yaml', + 'jsdoc-descriptions.openapi.yaml', + 'large-enum.openapi.yaml', + 'multi-tag-operation.openapi.yaml', + 'multi-warning.openapi.yaml', + 'nullable-oneof.openapi.yaml', + 'nullable-optional.openapi.yaml', + 'oneof-anyof-composition.openapi.json', + 'oneof-anyof-composition.openapi.yaml', + 'petstore-minimal.openapi.json', + 'petstore-minimal.openapi.yaml', + 'petstore-rich.openapi.json', + 'petstore-rich.openapi.yaml', + 'recursive-model.openapi.yaml', + 'recursive-oneof.openapi.yaml', + 'reserved-prop-names.openapi.yaml', + 'response-204-no-content.openapi.yaml', + 'response-blob-via-pdf.openapi.yaml', + 'response-default-fallback.openapi.yaml', + 'response-octet-stream.openapi.yaml', + 'response-problem-json.openapi.yaml', + 'response-text-via-text-plain.openapi.yaml', + 'security-schemes.openapi.yaml', + 'single-entry-composition.openapi.yaml', + 'string-formats.openapi.yaml', +]; + +/** + * Fixtures with no snapshot. Every entry but `malformed.yaml` is a gap to + * close; that one's wording follows the YAML parser's own line and column + * output, which the spec asserts by regex. + */ +const UNSNAPSHOTTED: readonly string[] = [ + 'malformed.yaml', + // TODO: these generate or fail deterministically and should be pinned. + 'bench-multi-tag.openapi.yaml', + 'consumer-forms-and-non-json.openapi.yaml', + 'cookie-param.openapi.yaml', + 'duplicate-operation-id.openapi.yaml', + 'duplicate-schema-name.openapi.yaml', + 'errors-typed.openapi.yaml', + 'missing-tag.openapi.yaml', + 'unsupported-trace.openapi.yaml', + 'verb-prefix.openapi.yaml', + 'warning-then-fatal.openapi.yaml', +]; + +const PINNED_FAILURE_FIXTURES: readonly string[] = [ + // One entry per diagnostic the pipeline can end on, so a renamed + // subcode or a reject path rerouted through a different arm shows up + // here rather than in a consumer's generated output. + 'empty-parameter.openapi.yaml', + 'inline-parameter.openapi.yaml', + 'invalid-enum-type.openapi.yaml', + 'invalid-enum-value.openapi.json', + 'unsupported-root.yaml', + 'unsupported-semantic.openapi.yaml', + 'additional-properties-boolean.openapi.yaml', + 'external-ref.openapi.yaml', + 'field-collision.openapi.yaml', + 'deep-nested-allof.openapi.yaml', + 'discriminator-missing-property.openapi.yaml', + 'discriminator-mapping-external-ref.openapi.yaml', + 'unbalanced-path-template.openapi.yaml', + 'anchor-fanout.openapi.yaml', + 'body-multi-content.openapi.yaml', + 'body-content-type-xml.openapi.yaml', + 'body-multipart-nested-object.openapi.yaml', + 'body-multipart-composed-field.openapi.yaml', + 'body-multipart-non-object.openapi.yaml', + 'body-multipart-open-schema.openapi.yaml', + 'body-urlencoded-binary-field.openapi.yaml', + 'body-urlencoded-nested-object.openapi.yaml', +]; + +/** Failure snapshots that need options the default run does not pass. */ +const PARAMETERISED_FAILURES: readonly FailureSnapshot[] = [ + // The mapped-type validator refuses a schema the spec does not declare. + { + fixture: 'petstore-rich.openapi.yaml', + snapshot: 'petstore-rich.openapi.yaml.invalid-mapped-type.failure.json', + options: { + mappedTypes: [{ schema: 'MissingSchema', import: '@demo/x', type: 'Missing' }], + }, + }, +]; + +const FAILURE_SNAPSHOTS: readonly FailureSnapshot[] = [ + ...PINNED_FAILURE_FIXTURES.map(fixture => ({ + fixture, + snapshot: `${fixture}.failure.json`, + })), + ...PARAMETERISED_FAILURES, +]; + +/** + * Fails when a fixture on disk is in none of the three sets, so a new + * fixture must be classified rather than silently ignored. + */ +function assertEveryFixtureIsClassified(): void { + const classified = new Set([ + ...SUCCESS_FIXTURES, + ...UNSNAPSHOTTED, + ...FAILURE_SNAPSHOTS.map(entry => entry.fixture), + ]); + const unclassified = fs + .readdirSync(fixturesDir) + .filter(name => /\.(ya?ml|json)$/u.test(name)) + .filter(name => !classified.has(name)); + + if (unclassified.length > 0) { + console.error( + `regen-snapshots: ${unclassified.length} fixture(s) are in none of ` + + 'SUCCESS_FIXTURES, FAILURE_SNAPSHOTS or UNSNAPSHOTTED:\n' + + unclassified.map(name => ` ${name}`).join('\n'), + ); + process.exit(1); + } +} + +let updated = 0; +let unchanged = 0; + +function writeIfChanged(target: string, contents: string): void { + const previous = fs.existsSync(target) ? fs.readFileSync(target, 'utf8') : null; + if (previous === contents) { + unchanged += 1; + return; + } + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, contents); + updated += 1; + console.log(`updated: ${path.relative(repoRoot, target)}`); +} + +/** Removes files under `dir` that the current run did not write. */ +function removeOrphans(dir: string, live: ReadonlySet): void { + if (!fs.existsSync(dir)) return; + + const walk = (current: string, prefix: string): void => { + for (const entry of fs.readdirSync(current, { withFileTypes: true })) { + const child = path.join(current, entry.name); + const relative = prefix ? path.join(prefix, entry.name) : entry.name; + if (entry.isDirectory()) { + walk(child, relative); + if (fs.readdirSync(child).length === 0) fs.rmdirSync(child); + } else if (!live.has(relative)) { + fs.rmSync(child); + console.log(`removed: ${path.relative(repoRoot, child)}`); + } + } + }; + walk(dir, ''); +} + +function storeBodies( + dir: string, + artifacts: GenerateResult['artifacts'], + keep: (artifactPath: string) => boolean, +): void { + const live = new Set(); + for (const artifact of artifacts) { + if (!keep(artifact.path)) continue; + const target = path.join(dir, artifact.path); + writeIfChanged(target, artifact.contents.replace(BANNER_RE, '')); + live.add(path.relative(dir, target)); + } + removeOrphans(dir, live); +} + +function asJson(value: unknown): string { + return `${JSON.stringify(value, null, 2)}\n`; +} + +function writeSuccessSnapshot(fixture: string, result: GenerateResult): void { + storeBodies(path.join(snapshots, fixture), result.artifacts, artifactPath => + !STATIC_TEMPLATE_PATHS.has(artifactPath), + ); + writeIfChanged( + path.join(snapshots, `${fixture}.success.json`), + asJson({ + summary: result.summary, + diagnostics: result.diagnostics, + artifacts: result.artifacts.map(artifact => ({ path: artifact.path })), + }), + ); +} + +function writeStaticTemplates(result: GenerateResult): void { + storeBodies(staticTemplateDir, result.artifacts, artifactPath => + STATIC_TEMPLATE_PATHS.has(artifactPath), + ); + writeIfChanged( + staticTemplateIndex, + asJson({ + artifacts: [...STATIC_TEMPLATE_PATHS].sort().map(templatePath => ({ path: templatePath })), + }), + ); +} + +/** The failure fields a snapshot pins, in a stable key order. */ +function failurePayload(error: GenerateError) { + return { + code: error.code, + message: error.message, + path: error.path, + warnings: error.warnings, + }; +} + +/** Rethrows anything that is not one of the generator's own failures. */ +function asGenerateError(error: unknown, fixture: string): GenerateError { + if (isGenerateError(error)) return error; + throw new Error(`${fixture} failed with a non-generator error`, { cause: error }); +} + +function run( + fixture: string, + options: Partial = {}, +): Promise { + return generate({ + inputPath: path.join('test', 'fixtures', fixture), + emit: [...SNAPSHOT_EMIT], + ...options, + }); +} + +assertEveryFixtureIsClassified(); + +let staticTemplatesWritten = false; +for (const fixture of SUCCESS_FIXTURES) { + let result; + try { + result = await run(fixture); + } catch (error) { + console.error( + `FAIL: ${fixture} was expected to generate but failed with ` + + `${asGenerateError(error, fixture).code}. Add it to FAILURE_SNAPSHOTS, or fix the fixture.`, + ); + process.exitCode = 1; + continue; + } + if (!staticTemplatesWritten) { + writeStaticTemplates(result); + staticTemplatesWritten = true; + } + writeSuccessSnapshot(fixture, result); +} + +for (const { fixture, snapshot, options } of FAILURE_SNAPSHOTS) { + try { + await run(fixture, options); + console.warn(`SKIP: ${fixture} (${snapshot}) succeeded — failure snapshot not regenerated`); + } catch (error) { + writeIfChanged( + path.join(snapshots, snapshot), + asJson(failurePayload(asGenerateError(error, fixture))), + ); + } +} + +console.log(`\n${updated} snapshot(s) updated, ${unchanged} unchanged.`); diff --git a/scripts/tsconfig.json b/scripts/tsconfig.json new file mode 100644 index 0000000..2a2d59f --- /dev/null +++ b/scripts/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "bundler", + "noEmit": true, + "types": [ + "node" + ], + "allowImportingTsExtensions": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "noPropertyAccessFromIndexSignature": true, + "forceConsistentCasingInFileNames": true + }, + "include": [ + "." + ], + "exclude": [ + "node_modules" + ] +} diff --git a/tsconfig.json b/tsconfig.json index 550951b..b3c56ef 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,6 +9,15 @@ "esModuleInterop": true, "allowSyntheticDefaultImports": true }, - "include": ["."], - "exclude": ["node_modules", "benchmark", "__test__"] + "include": [ + "." + ], + "exclude": [ + "node_modules", + "benchmark", + "__test__", + "scripts", + "website", + "stackblitz" + ] } diff --git a/website/astro.config.mjs b/website/astro.config.ts similarity index 91% rename from website/astro.config.mjs rename to website/astro.config.ts index 89cf18a..ff379cc 100644 --- a/website/astro.config.mjs +++ b/website/astro.config.ts @@ -1,9 +1,14 @@ +import type { ViteUserConfig } from 'astro'; import { defineConfig } from 'astro/config'; import starlight from '@astrojs/starlight'; // `astro dev` ignores public/_headers; mirror its COOP/COEP scope so the // playground page is cross-origin isolated and its wasm worker inherits COEP. -const playgroundHeaders = { +// Astro re-exports Vite's config type, so the plugin shape is available +// without declaring a direct dependency on vite. +type VitePlugin = NonNullable[number]; + +const playgroundHeaders: VitePlugin = { name: 'playground-headers', configureServer(server) { server.middlewares.use((req, res, next) => { diff --git a/website/package.json b/website/package.json index 5ee5f34..f768505 100644 --- a/website/package.json +++ b/website/package.json @@ -4,10 +4,10 @@ "private": true, "type": "module", "scripts": { - "predev": "bun scripts/bundle-engine.mjs", + "predev": "bun scripts/bundle-engine.ts", "dev": "astro dev", "start": "astro dev", - "prebuild": "bun scripts/bundle-engine.mjs", + "prebuild": "bun scripts/bundle-engine.ts", "build": "astro build", "preview": "astro preview", "astro": "astro", diff --git a/website/scripts/bundle-engine.mjs b/website/scripts/bundle-engine.mjs deleted file mode 100644 index 5de147a..0000000 --- a/website/scripts/bundle-engine.mjs +++ /dev/null @@ -1,71 +0,0 @@ -#!/usr/bin/env node -// Pre-bundles the napi-rs WASI browser loader so the page can import it -// from /playground-engine/ without Vite processing node_modules internals. -import { build } from 'esbuild'; -import fs from 'node:fs'; -import { createRequire } from 'node:module'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const require = createRequire(import.meta.url); -const websiteRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); -const outDir = path.join(websiteRoot, 'public', 'playground-engine'); -const pkgDir = path.dirname( - require.resolve('@avsystem/openapi-ng-wasm32-wasi/package.json'), -); - -const PACKAGE_WORKER_URL = - "new URL('@avsystem/openapi-ng-wasm32-wasi/wasi-worker-browser.mjs', import.meta.url)"; -const LOCAL_WORKER_URL = "new URL('./wasi-worker-browser.mjs', import.meta.url)"; - -fs.rmSync(outDir, { recursive: true, force: true }); -fs.mkdirSync(outDir, { recursive: true }); - -const loaderSource = fs.readFileSync( - path.join(pkgDir, 'openapi-ng.wasi-browser.js'), - 'utf8', -); -// `napi artifacts` rewrites the published loader to the bare specifier; a local -// `napi build` already emits the relative one, so the replace below is a no-op. -if ( - !loaderSource.includes(PACKAGE_WORKER_URL) && - !loaderSource.includes(LOCAL_WORKER_URL) -) { - throw new Error( - 'bundle-engine: worker URL in the WASI loader changed; update PACKAGE_WORKER_URL', - ); -} - -await build({ - stdin: { - contents: loaderSource.replace(PACKAGE_WORKER_URL, LOCAL_WORKER_URL), - resolveDir: pkgDir, - sourcefile: 'openapi-ng.wasi-browser.js', - loader: 'js', - }, - bundle: true, - format: 'esm', - platform: 'browser', - target: 'es2022', - minify: true, - outfile: path.join(outDir, 'openapi-ng.wasi-browser.js'), -}); - -await build({ - entryPoints: [path.join(pkgDir, 'wasi-worker-browser.mjs')], - bundle: true, - format: 'esm', - platform: 'browser', - target: 'es2022', - minify: true, - outfile: path.join(outDir, 'wasi-worker-browser.mjs'), -}); - -fs.copyFileSync( - path.join(pkgDir, 'openapi-ng.wasm32-wasi.wasm'), - path.join(outDir, 'openapi-ng.wasm32-wasi.wasm'), -); - -const { version } = require('@avsystem/openapi-ng-wasm32-wasi/package.json'); -fs.writeFileSync(path.join(outDir, 'version.json'), JSON.stringify({ version })); -console.log(`bundle-engine: wrote ${outDir} (v${version})`); diff --git a/website/scripts/bundle-engine.ts b/website/scripts/bundle-engine.ts new file mode 100644 index 0000000..abf80f3 --- /dev/null +++ b/website/scripts/bundle-engine.ts @@ -0,0 +1,76 @@ +#!/usr/bin/env bun +// Pre-bundles the napi-rs WASI browser loader into public/playground-engine/ +// so the playground page can import it without Vite reaching into +// node_modules internals. + +import fs from 'node:fs'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { build } from 'esbuild'; + +const WASI_PACKAGE = '@avsystem/openapi-ng-wasm32-wasi'; + +/** The worker specifier `napi artifacts` rewrites into the published loader. */ +const PACKAGE_WORKER_URL = `new URL('${WASI_PACKAGE}/wasi-worker-browser.mjs', import.meta.url)`; +/** The specifier a local `napi build` emits instead. */ +const LOCAL_WORKER_URL = "new URL('./wasi-worker-browser.mjs', import.meta.url)"; + +const require = createRequire(import.meta.url); +const websiteRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const outDir = path.join(websiteRoot, 'public', 'playground-engine'); +const packageDir = path.dirname(require.resolve(`${WASI_PACKAGE}/package.json`)); + +const shared = { + bundle: true, + format: 'esm', + platform: 'browser', + target: 'es2022', + minify: true, +} as const; + +/** + * Rewrites the loader's worker URL to the local one so both a published and + * a locally built engine resolve the worker from the output directory. + */ +function localiseWorkerUrl(source: string): string { + if (!source.includes(PACKAGE_WORKER_URL) && !source.includes(LOCAL_WORKER_URL)) { + throw new Error( + 'bundle-engine: the worker URL in the WASI loader changed; update PACKAGE_WORKER_URL', + ); + } + return source.replace(PACKAGE_WORKER_URL, LOCAL_WORKER_URL); +} + +fs.rmSync(outDir, { recursive: true, force: true }); +fs.mkdirSync(outDir, { recursive: true }); + +const loaderName = 'openapi-ng.wasi-browser.js'; +const loaderSource = fs.readFileSync(path.join(packageDir, loaderName), 'utf8'); + +await build({ + ...shared, + stdin: { + contents: localiseWorkerUrl(loaderSource), + resolveDir: packageDir, + sourcefile: loaderName, + loader: 'js', + }, + outfile: path.join(outDir, loaderName), +}); + +await build({ + ...shared, + entryPoints: [path.join(packageDir, 'wasi-worker-browser.mjs')], + outfile: path.join(outDir, 'wasi-worker-browser.mjs'), +}); + +fs.copyFileSync( + path.join(packageDir, 'openapi-ng.wasm32-wasi.wasm'), + path.join(outDir, 'openapi-ng.wasm32-wasi.wasm'), +); + +const { version } = require(`${WASI_PACKAGE}/package.json`) as { version: string }; +fs.writeFileSync(path.join(outDir, 'version.json'), JSON.stringify({ version })); +console.log(`bundle-engine: wrote ${outDir} (v${version})`); From f0f71dc721bab28b53dfeb9cc2062c45a01625c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andrzej=20=C5=9Awi=C4=99tek?= Date: Tue, 8 Sep 2026 17:25:14 +0200 Subject: [PATCH 03/11] Added typecheck and test-target clippy to CI --- .github/workflows/CI.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index d05dfcf..5b89308 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -45,12 +45,14 @@ jobs: components: clippy, rustfmt - name: Install dependencies run: bun ci - - name: ESLint + - name: Lint run: bun run lint + - name: Typecheck + run: bun run typecheck - name: Cargo fmt run: cargo fmt -- --check - name: Clippy - run: cargo clippy + run: cargo clippy --all-targets - name: Cargo test run: cargo test --all-targets test-rust-cross-os: From 3598e3b643d2e7432f9210364b4ed754e6f5afbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andrzej=20=C5=9Awi=C4=99tek?= Date: Tue, 8 Sep 2026 18:02:44 +0200 Subject: [PATCH 04/11] Pinned every remaining fixture and unified the snapshot manifest --- __test__/generate.snapshot.spec.ts | 271 ++-------- .../bench-multi-tag.openapi.yaml.success.json | 40 ++ .../model.generated.ts | 499 ++++++++++++++++++ .../rest/catalog.rest.generated.ts | 330 ++++++++++++ .../rest/customers.rest.generated.ts | 330 ++++++++++++ .../rest/inventory.rest.generated.ts | 330 ++++++++++++ .../rest/orders.rest.generated.ts | 330 ++++++++++++ .../rest/shipping.rest.generated.ts | 330 ++++++++++++ ...rms-and-non-json.openapi.yaml.success.json | 52 ++ .../rest/binary.rest.generated.ts | 15 + .../rest/config.rest.generated.ts | 15 + .../rest/invoice.rest.generated.ts | 22 + .../rest/pet.rest.generated.ts | 38 ++ .../rest/search.rest.generated.ts | 31 ++ .../cookie-param.openapi.yaml.success.json | 33 ++ .../rest/pet.rest.generated.ts | 15 + ...ate-operation-id.openapi.yaml.failure.json | 6 + ...cate-schema-name.openapi.yaml.failure.json | 6 + .../errors-typed.openapi.yaml.success.json | 28 + .../model.generated.ts | 16 + .../rest/pet.rest.generated.ts | 33 ++ .../missing-tag.openapi.yaml.success.json | 25 + .../rest/pets.rest.generated.ts | 15 + ...nsupported-trace.openapi.yaml.failure.json | 6 + .../verb-prefix.openapi.yaml.success.json | 25 + .../rest/post.rest.generated.ts | 30 ++ ...rning-then-fatal.openapi.yaml.failure.json | 14 + scripts/lib/snapshot-layout.ts | 163 +++++- scripts/regen-snapshots.ts | 145 +---- 29 files changed, 2817 insertions(+), 376 deletions(-) create mode 100644 __test__/snapshots/generate-native/bench-multi-tag.openapi.yaml.success.json create mode 100644 __test__/snapshots/generate-native/bench-multi-tag.openapi.yaml/model.generated.ts create mode 100644 __test__/snapshots/generate-native/bench-multi-tag.openapi.yaml/rest/catalog.rest.generated.ts create mode 100644 __test__/snapshots/generate-native/bench-multi-tag.openapi.yaml/rest/customers.rest.generated.ts create mode 100644 __test__/snapshots/generate-native/bench-multi-tag.openapi.yaml/rest/inventory.rest.generated.ts create mode 100644 __test__/snapshots/generate-native/bench-multi-tag.openapi.yaml/rest/orders.rest.generated.ts create mode 100644 __test__/snapshots/generate-native/bench-multi-tag.openapi.yaml/rest/shipping.rest.generated.ts create mode 100644 __test__/snapshots/generate-native/consumer-forms-and-non-json.openapi.yaml.success.json create mode 100644 __test__/snapshots/generate-native/consumer-forms-and-non-json.openapi.yaml/rest/binary.rest.generated.ts create mode 100644 __test__/snapshots/generate-native/consumer-forms-and-non-json.openapi.yaml/rest/config.rest.generated.ts create mode 100644 __test__/snapshots/generate-native/consumer-forms-and-non-json.openapi.yaml/rest/invoice.rest.generated.ts create mode 100644 __test__/snapshots/generate-native/consumer-forms-and-non-json.openapi.yaml/rest/pet.rest.generated.ts create mode 100644 __test__/snapshots/generate-native/consumer-forms-and-non-json.openapi.yaml/rest/search.rest.generated.ts create mode 100644 __test__/snapshots/generate-native/cookie-param.openapi.yaml.success.json create mode 100644 __test__/snapshots/generate-native/cookie-param.openapi.yaml/rest/pet.rest.generated.ts create mode 100644 __test__/snapshots/generate-native/duplicate-operation-id.openapi.yaml.failure.json create mode 100644 __test__/snapshots/generate-native/duplicate-schema-name.openapi.yaml.failure.json create mode 100644 __test__/snapshots/generate-native/errors-typed.openapi.yaml.success.json create mode 100644 __test__/snapshots/generate-native/errors-typed.openapi.yaml/model.generated.ts create mode 100644 __test__/snapshots/generate-native/errors-typed.openapi.yaml/rest/pet.rest.generated.ts create mode 100644 __test__/snapshots/generate-native/missing-tag.openapi.yaml.success.json create mode 100644 __test__/snapshots/generate-native/missing-tag.openapi.yaml/rest/pets.rest.generated.ts create mode 100644 __test__/snapshots/generate-native/unsupported-trace.openapi.yaml.failure.json create mode 100644 __test__/snapshots/generate-native/verb-prefix.openapi.yaml.success.json create mode 100644 __test__/snapshots/generate-native/verb-prefix.openapi.yaml/rest/post.rest.generated.ts create mode 100644 __test__/snapshots/generate-native/warning-then-fatal.openapi.yaml.failure.json diff --git a/__test__/generate.snapshot.spec.ts b/__test__/generate.snapshot.spec.ts index 37eab44..2d90bbb 100644 --- a/__test__/generate.snapshot.spec.ts +++ b/__test__/generate.snapshot.spec.ts @@ -4,13 +4,20 @@ import path from 'node:path'; import { execFileSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; -// Use the wrapper so caught errors are real `GenerateError` instances -// (the failure snapshots pin `warnings`/`path` from the upgraded class -// shape). -import { generate } from '../lib/index.js'; -// The banner regex and the static-template list come from the module the -// regenerator writes with, so reader and writer cannot drift. -import { BANNER_RE, STATIC_TEMPLATE_PATHS } from '../scripts/lib/snapshot-layout.ts'; +// Through the wrapper, so a caught failure is a real `GenerateError` and +// the snapshot can pin its `path` and `warnings`. +import { generate, isGenerateError } from '../scripts/lib/engine.ts'; +import type { GenerateOptions } from '../scripts/lib/engine.ts'; +// Every fixture list, the banner regex and the static-template set come +// from the module the regenerator writes with, so reader and writer cannot +// drift from each other or from test/fixtures/. +import { + BANNER_RE, + FAILURE_FIXTURES, + SNAPSHOT_EMIT, + STATIC_TEMPLATE_PATHS, + SUCCESS_FIXTURES, +} from '../scripts/lib/snapshot-layout.ts'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.join(__dirname, '..'); @@ -24,8 +31,9 @@ function readJsonSnapshot(name: string) { return JSON.parse(fs.readFileSync(snapshot(name), 'utf8')); } -// The banner regex and the static-template list come from the module the -// regenerator writes with, so reader and writer cannot drift. +// Every fixture list, the banner regex and the static-template set come +// from the module the regenerator writes with, so reader and writer cannot +// drift from each other or from test/fixtures/. const STATIC_TEMPLATE_DIR = path.join( repoRoot, '__test__', @@ -112,164 +120,29 @@ async function successResult(name: string, options: Record = {} ); } -async function failurePayload(name: string, options: Record = {}) { +async function failurePayload(name: string, options: Partial = {}) { try { await generate({ inputPath: fixture(name), - emit: ['models', 'angular'], + emit: [...SNAPSHOT_EMIT], ...options, }); - throw new Error(`Expected ${name} to fail.`); } catch (thrown) { - const e = thrown as any; + if (!isGenerateError(thrown)) { + throw new Error(`${name} failed with a non-generator error`, { cause: thrown }); + } return { - code: e.code, - message: e.message, - path: e.path ?? null, - warnings: e.warnings ?? [], + code: thrown.code, + message: thrown.message, + path: thrown.path, + warnings: thrown.warnings, }; } + throw new Error(`Expected ${name} to fail.`); } -const successFixtures = [ - 'petstore-minimal.openapi.yaml', - 'petstore-minimal.openapi.json', - 'petstore-rich.openapi.yaml', - 'petstore-rich.openapi.json', - 'oneof-anyof-composition.openapi.yaml', - 'oneof-anyof-composition.openapi.json', - 'allof-composition.openapi.yaml', - 'additional-properties.openapi.yaml', - // additional-properties: false combined with declared `properties` - // emits the named interface unchanged — OpenAPI semantics treat - // `false` as "no extras beyond what's declared", which is the - // default for TypeScript interfaces, so the field is structurally - // a no-op for our emit. (The `true` form remains rejected — see - // failureFixtures below.) - 'additional-properties-false.openapi.yaml', - 'recursive-model.openapi.yaml', - 'single-entry-composition.openapi.yaml', - 'empty-shapes.openapi.yaml', - 'inline-model.openapi.yaml', - 'nullable-optional.openapi.yaml', - // header-param emits a non-fatal warning for the unsupported `header` - // location; the snapshot pins the full warning shape (code/stage/ - // severity/fatal/message/path) so multi-warning regressions surface - // here, not via a separate substring assertion (E12). - 'header-param.openapi.yaml', - // large-enum exercises both branches of the D8 enum-rendering width - // threshold: 50 values forces multi-line, 2 values stays inline. - 'large-enum.openapi.yaml', - // multi-tag-operation pins the (currently silent) policy that secondary - // tags are dropped — operation_grouper.rs uses tags.first() only. If - // someone later wires multi-tag emission, this snapshot surfaces the - // change. - 'multi-tag-operation.openapi.yaml', - // nullable-oneof exercises apply_nullable over composition: a oneOf - // with `nullable: true` (both at top level and as an inline property - // shape). Pins how the nullable wrapper combines with union semantics. - 'nullable-oneof.openapi.yaml', - // security-schemes confirms that components.securitySchemes blocks are - // silently accepted (no error, no warning) — generation proceeds as if - // the block were not present. If we ever wire auth-aware emission this - // snapshot surfaces the change. - 'security-schemes.openapi.yaml', - // circular-allof exercises the E4 recursion-guard happy path: 5 layers - // of allOf composition that bottom out at BaseAuditFields. Catches any - // off-by-one in MAX_NORMALIZE_DEPTH that would falsely fail legit - // deeply-nested specs. - 'circular-allof.openapi.yaml', - // discriminated-union pins the TS surface for `oneOf` + `discriminator:` - // (D11). - 'discriminated-union.openapi.yaml', - // bench-large is a large realistic spec (100+ schemas, 40+ operations). - // Adding it to the snapshot suite catches regressions that only surface - // at scale — sort order changes, buffer sizing issues, etc. - 'bench-large.openapi.yaml', - // reserved-prop-names pins the identifier-escaping policy: reserved - // words like `class` stay unquoted (valid TS property names), while - // digit-first / kebab / dotted / space-containing names get - // single-quoted via safe_property_name. - 'reserved-prop-names.openapi.yaml', - // jsdoc-descriptions pins JSDoc preservation: schema description on - // interface/type alias/enum, per-property description, and - // operation summary+description merged onto the service member. - 'jsdoc-descriptions.openapi.yaml', - // multi-warning triggers two normalize-stage cookie-parameter warnings - // in one operation. Pins the warning order (sessionId before - // trackingId) so a regression that reorders or coalesces the - // pipeline's pre-fatal diagnostics surfaces here, not as a silent - // change. - 'multi-warning.openapi.yaml', - // deprecated-fields exercises OpenAPI `deprecated: true` mapping to - // `@deprecated` JSDoc on the emitted operation, top-level type alias - // (enum), and per-property declaration. Pins the JSDoc emission so a - // regression that drops the tag surfaces here. - 'deprecated-fields.openapi.yaml', - // recursive-oneof closes the cycle-handling coverage matrix. The - // existing fixtures cover cycles via plain `$ref` properties - // (recursive-model) and bounded deep `allOf` chains (circular-allof); - // this one pins cycles routed through `oneOf` composition, where the - // back-edge lands as an unresolved Ref inside a union member. - 'recursive-oneof.openapi.yaml', - // string-formats exercises the format-dropped warning policy: every - // schema-level `format` hint (uuid, email, uri, date, date-time) - // surfaces as an E_UNSUPPORTED_SEMANTIC warning with subcode - // 'format-dropped'. Pins both the warning text/order and the fact that - // generation still proceeds with the base type. - 'string-formats.openapi.yaml', - // discriminator-mapping pins the `discriminator.mapping` honoring policy: - // when a oneOf carries an explicit wire-value mapping, the narrowed - // literal on each member must use the mapping key (e.g. `feline`, - // `canine`) rather than the lowercased schema name. Regressions that - // ignore `mapping` would emit `'cat'`/`'dog'` here. - 'discriminator-mapping.openapi.yaml', - // discriminator-allof exercises the `oneOf` + `allOf`-shaped members - // path: each member composes a shared base (Animal) with a - // variant-specific tail that redeclares the discriminator property. - // The Intersection walk must reach into the inline part to narrow - // `kind` to a single string literal on Cat/Dog. - 'discriminator-allof.openapi.yaml', - // anchor-modest pins the accept-side boundary of the - // mapping-expansion-exceeded guard: a small anchor (Audit) reused 3× - // across composed schemas — the kind of legitimate anchor pattern - // hand-written specs commonly use. The re-serialised byte ratio stays - // well under the 50× cap, so this fixture proves the guard does not - // regress modest anchor use. The reject-side boundary lives in - // `failureFixtures` as `anchor-fanout.openapi.yaml`. - 'anchor-modest.openapi.yaml', - // body-multipart-mixed-fields exercises the comprehensive multipart - // form-body walker: scalar + array-of-scalar + binary + array-of-binary - // + optional scalar. Pins the FormData IIFE shape (`fd.append(...)` - // per field, `String(...)` cast for scalars, raw passthrough for - // binaries, `if (... !== undefined)` guards for optional fields) and - // the request-interface field types (`Blob | File` and `(Blob | File)[]`). - 'body-multipart-mixed-fields.openapi.yaml', - // body-multipart-ref-to-named-object verifies the flattened_body_ref - // import suppression: when a multipart body schema is a top-level $ref - // to a named object (UploadForm), its properties are inlined as - // form-fields and the now-redundant UploadForm import is omitted. - 'body-multipart-ref-to-named-object.openapi.yaml', - // body-urlencoded-scalar-and-array exercises the urlencoded form-body - // walker: scalar + array-of-scalar. Pins the URLSearchParams IIFE - // shape (distinct from FormData) and the absence of binary fields - // (rejected upstream in normalize for urlencoded). - 'body-urlencoded-scalar-and-array.openapi.yaml', - // response-blob-via-pdf verifies the default response-kind classifier - // routes `application/pdf` to `Blob` using the `requestFactory.blob<…>(…)` variant. - 'response-blob-via-pdf.openapi.yaml', - // response-text-via-text-plain verifies the default response-kind - // classifier routes `text/plain` to `string` using the `requestFactory.text<…>(…)` variant. - 'response-text-via-text-plain.openapi.yaml', - // response-problem-json verifies the `*+json` suffix rule: a media - // type like `application/problem+json` classifies as Json (not Blob), - // so the response is emitted as a typed JSON shape via the default - // `requestFactory<…>(…)` (no non-JSON variant). - 'response-problem-json.openapi.yaml', -] as const; - -for (const fixtureName of successFixtures) { - test(`generate preserves full success payload snapshot for ${fixtureName}`, async t => { +for (const { fixture: fixtureName, pins } of SUCCESS_FIXTURES) { + test(`${fixtureName} snapshot: ${pins}`, async t => { t.deepEqual(await successResult(fixtureName), hydrateSuccessSnapshot(fixtureName)); }); } @@ -319,7 +192,7 @@ test('snapshot artifacts type-check under tsc --noEmit', t => { hydrateStaticTemplate().artifacts; const includeGlobs: string[] = []; - for (const fixtureName of successFixtures) { + for (const { fixture: fixtureName } of SUCCESS_FIXTURES) { const snap = hydrateSuccessSnapshot(fixtureName); const fixtureDir = path.join( compileRoot, @@ -367,73 +240,11 @@ test('snapshot artifacts type-check under tsc --noEmit', t => { t.pass('tsc --noEmit succeeded on every success-snapshot artifact set'); }); -const failureFixtures = [ - 'unsupported-semantic.openapi.yaml', - 'unsupported-root.yaml', - 'empty-parameter.openapi.yaml', - 'inline-parameter.openapi.yaml', - 'invalid-enum-type.openapi.yaml', - 'invalid-enum-value.openapi.json', - // additional-properties: true (boolean form, not a schema object) is - // explicitly rejected at normalize/schema.rs. Snapshot pins the - // unsupported-subset diagnostic shape. - 'additional-properties-boolean.openapi.yaml', - // External $ref like 'shared.yaml#/...' is rejected at - // normalize/schema.rs's normalize_reference. Snapshot pins the - // unsupported-reference message format. - 'external-ref.openapi.yaml', - // field-collision pins the field-collision policy under smart-flatten: - // an inline-object body whose property name duplicates a path/query - // param is rejected, because hoisting the property to top-level would - // produce a duplicate field on the request interface. The author's - // escape hatch is to either rename the property or hoist the body to a - // named $ref (which nests under `body` instead of flattening). - 'field-collision.openapi.yaml', - // deep-nested-allof exercises the MAX_NORMALIZE_DEPTH guard at 40 - // levels of inline allOf nesting. Pins the unsupported-semantic - // diagnostic so a regression that removes the guard or shifts the - // error code surfaces here, not just in the targeted spec test. - 'deep-nested-allof.openapi.yaml', - // unbalanced-path-template exercises the normalize-stage path string - // validation: paths with unmatched `{` / `}` would otherwise reach - // the emit stage and produce a broken TypeScript template - // (`url: `/pets/id`` with no `${encodeURIComponent(id)}` expansion). - 'unbalanced-path-template.openapi.yaml', - // discriminator-mapping-external-ref pins the normalize-stage - // rejection of a mapping value whose ref shape is not an internal - // `#/components/schemas/` ref. Without routing the mapping resolution - // through `normalize_reference`, an external URL like - // `http://example.com/schemas/Cat` would silently pass as a bare - // literal that never matches any union member. - 'discriminator-mapping-external-ref.openapi.yaml', - // anchor-fanout pins the reject-side boundary of the - // mapping-expansion-exceeded guard: a ~45 KB source whose 500 schemas - // × 16 anchor aliases each re-serialise into ~15 MB of inlined node - // tree (~340× ratio). The guard rejects the input at decode time - // before any normalize/emit work runs. Without this guard, the - // expanded tree would be carried through the entire pipeline and - // surface as a slowdown or memory blow-up rather than a clean - // diagnostic. - 'anchor-fanout.openapi.yaml', - // Policy-violation subcodes introduced in Phase 4 for multipart / - // urlencoded form bodies and unsupported content types. One fixture per - // subcode; each pins the diagnostic so a regression that renames a - // subcode or routes a reject path through a different arm surfaces here. - 'body-multi-content.openapi.yaml', - 'body-content-type-xml.openapi.yaml', - 'body-multipart-nested-object.openapi.yaml', - 'body-multipart-composed-field.openapi.yaml', - 'body-multipart-non-object.openapi.yaml', - 'body-multipart-open-schema.openapi.yaml', - 'body-urlencoded-binary-field.openapi.yaml', - 'body-urlencoded-nested-object.openapi.yaml', -] as const; - -for (const fixtureName of failureFixtures) { - test(`generate preserves full failure payload snapshot for ${fixtureName}`, async t => { +for (const { fixture: fixtureName, snapshot: snapshotName, pins, options } of FAILURE_FIXTURES) { + test(`${snapshotName} snapshot: ${pins}`, async t => { t.deepEqual( - await failurePayload(fixtureName), - readJsonSnapshot(`${fixtureName}.failure.json`), + await failurePayload(fixtureName, options ?? {}), + readJsonSnapshot(snapshotName), ); }); } @@ -449,17 +260,3 @@ test('generate preserves stable failure shape for malformed.yaml (regex message) t.deepEqual(payload.warnings, []); }); -test('generate preserves full failure payload snapshot for invalid mapped type option', async t => { - t.deepEqual( - await failurePayload('petstore-rich.openapi.yaml', { - mappedTypes: [ - { - schema: 'MissingSchema', - import: '@demo/x', - type: 'Missing', - }, - ], - }), - readJsonSnapshot('petstore-rich.openapi.yaml.invalid-mapped-type.failure.json'), - ); -}); diff --git a/__test__/snapshots/generate-native/bench-multi-tag.openapi.yaml.success.json b/__test__/snapshots/generate-native/bench-multi-tag.openapi.yaml.success.json new file mode 100644 index 0000000..a0542ce --- /dev/null +++ b/__test__/snapshots/generate-native/bench-multi-tag.openapi.yaml.success.json @@ -0,0 +1,40 @@ +{ + "summary": { + "normalizedSourcePath": "test/fixtures/bench-multi-tag.openapi.yaml", + "specVersion": "3.0.3", + "title": "Multi-Tag Bench", + "pathCount": 50, + "operationCount": 100, + "schemaCount": 150 + }, + "diagnostics": [], + "artifacts": [ + { + "path": "model.generated.ts" + }, + { + "path": "rest.model.ts" + }, + { + "path": "rest.util.ts" + }, + { + "path": "rest.validate.ts" + }, + { + "path": "rest/catalog.rest.generated.ts" + }, + { + "path": "rest/customers.rest.generated.ts" + }, + { + "path": "rest/inventory.rest.generated.ts" + }, + { + "path": "rest/orders.rest.generated.ts" + }, + { + "path": "rest/shipping.rest.generated.ts" + } + ] +} diff --git a/__test__/snapshots/generate-native/bench-multi-tag.openapi.yaml/model.generated.ts b/__test__/snapshots/generate-native/bench-multi-tag.openapi.yaml/model.generated.ts new file mode 100644 index 0000000..716e898 --- /dev/null +++ b/__test__/snapshots/generate-native/bench-multi-tag.openapi.yaml/model.generated.ts @@ -0,0 +1,499 @@ +export interface CatalogItem1 { + id: string; + name: string; + status?: CatalogItem1Status; +} + +export interface CatalogItem10 { + id: string; + name: string; + status?: CatalogItem10Status; +} + +export type CatalogItem10List = CatalogItem10[]; + +export type CatalogItem10Status = 'active' | 'inactive'; + +export type CatalogItem1List = CatalogItem1[]; + +export type CatalogItem1Status = 'active' | 'inactive'; + +export interface CatalogItem2 { + id: string; + name: string; + status?: CatalogItem2Status; +} + +export type CatalogItem2List = CatalogItem2[]; + +export type CatalogItem2Status = 'active' | 'inactive'; + +export interface CatalogItem3 { + id: string; + name: string; + status?: CatalogItem3Status; +} + +export type CatalogItem3List = CatalogItem3[]; + +export type CatalogItem3Status = 'active' | 'inactive'; + +export interface CatalogItem4 { + id: string; + name: string; + status?: CatalogItem4Status; +} + +export type CatalogItem4List = CatalogItem4[]; + +export type CatalogItem4Status = 'active' | 'inactive'; + +export interface CatalogItem5 { + id: string; + name: string; + status?: CatalogItem5Status; +} + +export type CatalogItem5List = CatalogItem5[]; + +export type CatalogItem5Status = 'active' | 'inactive'; + +export interface CatalogItem6 { + id: string; + name: string; + status?: CatalogItem6Status; +} + +export type CatalogItem6List = CatalogItem6[]; + +export type CatalogItem6Status = 'active' | 'inactive'; + +export interface CatalogItem7 { + id: string; + name: string; + status?: CatalogItem7Status; +} + +export type CatalogItem7List = CatalogItem7[]; + +export type CatalogItem7Status = 'active' | 'inactive'; + +export interface CatalogItem8 { + id: string; + name: string; + status?: CatalogItem8Status; +} + +export type CatalogItem8List = CatalogItem8[]; + +export type CatalogItem8Status = 'active' | 'inactive'; + +export interface CatalogItem9 { + id: string; + name: string; + status?: CatalogItem9Status; +} + +export type CatalogItem9List = CatalogItem9[]; + +export type CatalogItem9Status = 'active' | 'inactive'; + +export interface CustomersItem1 { + id: string; + name: string; + status?: CustomersItem1Status; +} + +export interface CustomersItem10 { + id: string; + name: string; + status?: CustomersItem10Status; +} + +export type CustomersItem10List = CustomersItem10[]; + +export type CustomersItem10Status = 'active' | 'inactive'; + +export type CustomersItem1List = CustomersItem1[]; + +export type CustomersItem1Status = 'active' | 'inactive'; + +export interface CustomersItem2 { + id: string; + name: string; + status?: CustomersItem2Status; +} + +export type CustomersItem2List = CustomersItem2[]; + +export type CustomersItem2Status = 'active' | 'inactive'; + +export interface CustomersItem3 { + id: string; + name: string; + status?: CustomersItem3Status; +} + +export type CustomersItem3List = CustomersItem3[]; + +export type CustomersItem3Status = 'active' | 'inactive'; + +export interface CustomersItem4 { + id: string; + name: string; + status?: CustomersItem4Status; +} + +export type CustomersItem4List = CustomersItem4[]; + +export type CustomersItem4Status = 'active' | 'inactive'; + +export interface CustomersItem5 { + id: string; + name: string; + status?: CustomersItem5Status; +} + +export type CustomersItem5List = CustomersItem5[]; + +export type CustomersItem5Status = 'active' | 'inactive'; + +export interface CustomersItem6 { + id: string; + name: string; + status?: CustomersItem6Status; +} + +export type CustomersItem6List = CustomersItem6[]; + +export type CustomersItem6Status = 'active' | 'inactive'; + +export interface CustomersItem7 { + id: string; + name: string; + status?: CustomersItem7Status; +} + +export type CustomersItem7List = CustomersItem7[]; + +export type CustomersItem7Status = 'active' | 'inactive'; + +export interface CustomersItem8 { + id: string; + name: string; + status?: CustomersItem8Status; +} + +export type CustomersItem8List = CustomersItem8[]; + +export type CustomersItem8Status = 'active' | 'inactive'; + +export interface CustomersItem9 { + id: string; + name: string; + status?: CustomersItem9Status; +} + +export type CustomersItem9List = CustomersItem9[]; + +export type CustomersItem9Status = 'active' | 'inactive'; + +export interface InventoryItem1 { + id: string; + name: string; + status?: InventoryItem1Status; +} + +export interface InventoryItem10 { + id: string; + name: string; + status?: InventoryItem10Status; +} + +export type InventoryItem10List = InventoryItem10[]; + +export type InventoryItem10Status = 'active' | 'inactive'; + +export type InventoryItem1List = InventoryItem1[]; + +export type InventoryItem1Status = 'active' | 'inactive'; + +export interface InventoryItem2 { + id: string; + name: string; + status?: InventoryItem2Status; +} + +export type InventoryItem2List = InventoryItem2[]; + +export type InventoryItem2Status = 'active' | 'inactive'; + +export interface InventoryItem3 { + id: string; + name: string; + status?: InventoryItem3Status; +} + +export type InventoryItem3List = InventoryItem3[]; + +export type InventoryItem3Status = 'active' | 'inactive'; + +export interface InventoryItem4 { + id: string; + name: string; + status?: InventoryItem4Status; +} + +export type InventoryItem4List = InventoryItem4[]; + +export type InventoryItem4Status = 'active' | 'inactive'; + +export interface InventoryItem5 { + id: string; + name: string; + status?: InventoryItem5Status; +} + +export type InventoryItem5List = InventoryItem5[]; + +export type InventoryItem5Status = 'active' | 'inactive'; + +export interface InventoryItem6 { + id: string; + name: string; + status?: InventoryItem6Status; +} + +export type InventoryItem6List = InventoryItem6[]; + +export type InventoryItem6Status = 'active' | 'inactive'; + +export interface InventoryItem7 { + id: string; + name: string; + status?: InventoryItem7Status; +} + +export type InventoryItem7List = InventoryItem7[]; + +export type InventoryItem7Status = 'active' | 'inactive'; + +export interface InventoryItem8 { + id: string; + name: string; + status?: InventoryItem8Status; +} + +export type InventoryItem8List = InventoryItem8[]; + +export type InventoryItem8Status = 'active' | 'inactive'; + +export interface InventoryItem9 { + id: string; + name: string; + status?: InventoryItem9Status; +} + +export type InventoryItem9List = InventoryItem9[]; + +export type InventoryItem9Status = 'active' | 'inactive'; + +export interface OrdersItem1 { + id: string; + name: string; + status?: OrdersItem1Status; +} + +export interface OrdersItem10 { + id: string; + name: string; + status?: OrdersItem10Status; +} + +export type OrdersItem10List = OrdersItem10[]; + +export type OrdersItem10Status = 'active' | 'inactive'; + +export type OrdersItem1List = OrdersItem1[]; + +export type OrdersItem1Status = 'active' | 'inactive'; + +export interface OrdersItem2 { + id: string; + name: string; + status?: OrdersItem2Status; +} + +export type OrdersItem2List = OrdersItem2[]; + +export type OrdersItem2Status = 'active' | 'inactive'; + +export interface OrdersItem3 { + id: string; + name: string; + status?: OrdersItem3Status; +} + +export type OrdersItem3List = OrdersItem3[]; + +export type OrdersItem3Status = 'active' | 'inactive'; + +export interface OrdersItem4 { + id: string; + name: string; + status?: OrdersItem4Status; +} + +export type OrdersItem4List = OrdersItem4[]; + +export type OrdersItem4Status = 'active' | 'inactive'; + +export interface OrdersItem5 { + id: string; + name: string; + status?: OrdersItem5Status; +} + +export type OrdersItem5List = OrdersItem5[]; + +export type OrdersItem5Status = 'active' | 'inactive'; + +export interface OrdersItem6 { + id: string; + name: string; + status?: OrdersItem6Status; +} + +export type OrdersItem6List = OrdersItem6[]; + +export type OrdersItem6Status = 'active' | 'inactive'; + +export interface OrdersItem7 { + id: string; + name: string; + status?: OrdersItem7Status; +} + +export type OrdersItem7List = OrdersItem7[]; + +export type OrdersItem7Status = 'active' | 'inactive'; + +export interface OrdersItem8 { + id: string; + name: string; + status?: OrdersItem8Status; +} + +export type OrdersItem8List = OrdersItem8[]; + +export type OrdersItem8Status = 'active' | 'inactive'; + +export interface OrdersItem9 { + id: string; + name: string; + status?: OrdersItem9Status; +} + +export type OrdersItem9List = OrdersItem9[]; + +export type OrdersItem9Status = 'active' | 'inactive'; + +export interface ShippingItem1 { + id: string; + name: string; + status?: ShippingItem1Status; +} + +export interface ShippingItem10 { + id: string; + name: string; + status?: ShippingItem10Status; +} + +export type ShippingItem10List = ShippingItem10[]; + +export type ShippingItem10Status = 'active' | 'inactive'; + +export type ShippingItem1List = ShippingItem1[]; + +export type ShippingItem1Status = 'active' | 'inactive'; + +export interface ShippingItem2 { + id: string; + name: string; + status?: ShippingItem2Status; +} + +export type ShippingItem2List = ShippingItem2[]; + +export type ShippingItem2Status = 'active' | 'inactive'; + +export interface ShippingItem3 { + id: string; + name: string; + status?: ShippingItem3Status; +} + +export type ShippingItem3List = ShippingItem3[]; + +export type ShippingItem3Status = 'active' | 'inactive'; + +export interface ShippingItem4 { + id: string; + name: string; + status?: ShippingItem4Status; +} + +export type ShippingItem4List = ShippingItem4[]; + +export type ShippingItem4Status = 'active' | 'inactive'; + +export interface ShippingItem5 { + id: string; + name: string; + status?: ShippingItem5Status; +} + +export type ShippingItem5List = ShippingItem5[]; + +export type ShippingItem5Status = 'active' | 'inactive'; + +export interface ShippingItem6 { + id: string; + name: string; + status?: ShippingItem6Status; +} + +export type ShippingItem6List = ShippingItem6[]; + +export type ShippingItem6Status = 'active' | 'inactive'; + +export interface ShippingItem7 { + id: string; + name: string; + status?: ShippingItem7Status; +} + +export type ShippingItem7List = ShippingItem7[]; + +export type ShippingItem7Status = 'active' | 'inactive'; + +export interface ShippingItem8 { + id: string; + name: string; + status?: ShippingItem8Status; +} + +export type ShippingItem8List = ShippingItem8[]; + +export type ShippingItem8Status = 'active' | 'inactive'; + +export interface ShippingItem9 { + id: string; + name: string; + status?: ShippingItem9Status; +} + +export type ShippingItem9List = ShippingItem9[]; + +export type ShippingItem9Status = 'active' | 'inactive'; diff --git a/__test__/snapshots/generate-native/bench-multi-tag.openapi.yaml/rest/catalog.rest.generated.ts b/__test__/snapshots/generate-native/bench-multi-tag.openapi.yaml/rest/catalog.rest.generated.ts new file mode 100644 index 0000000..96c788d --- /dev/null +++ b/__test__/snapshots/generate-native/bench-multi-tag.openapi.yaml/rest/catalog.rest.generated.ts @@ -0,0 +1,330 @@ +import { Injectable } from '@angular/core'; +import { httpParams, requestFactory } from '../rest.util'; +import type { + CatalogItem1, + CatalogItem10, + CatalogItem10List, + CatalogItem1List, + CatalogItem2, + CatalogItem2List, + CatalogItem3, + CatalogItem3List, + CatalogItem4, + CatalogItem4List, + CatalogItem5, + CatalogItem5List, + CatalogItem6, + CatalogItem6List, + CatalogItem7, + CatalogItem7List, + CatalogItem8, + CatalogItem8List, + CatalogItem9, + CatalogItem9List, +} from '../model.generated'; + +@Injectable({ + providedIn: 'root', +}) +export class CatalogRest { + + readonly createCatalogItem1 = requestFactory( + (request: CreateCatalogItem1Params) => { + const { body } = request; + return { + method: 'POST', + url: `/catalog/1`, + body: body, + }; + }, + ); + + readonly createCatalogItem10 = requestFactory( + (request: CreateCatalogItem10Params) => { + const { body } = request; + return { + method: 'POST', + url: `/catalog/10`, + body: body, + }; + }, + ); + + readonly createCatalogItem2 = requestFactory( + (request: CreateCatalogItem2Params) => { + const { body } = request; + return { + method: 'POST', + url: `/catalog/2`, + body: body, + }; + }, + ); + + readonly createCatalogItem3 = requestFactory( + (request: CreateCatalogItem3Params) => { + const { body } = request; + return { + method: 'POST', + url: `/catalog/3`, + body: body, + }; + }, + ); + + readonly createCatalogItem4 = requestFactory( + (request: CreateCatalogItem4Params) => { + const { body } = request; + return { + method: 'POST', + url: `/catalog/4`, + body: body, + }; + }, + ); + + readonly createCatalogItem5 = requestFactory( + (request: CreateCatalogItem5Params) => { + const { body } = request; + return { + method: 'POST', + url: `/catalog/5`, + body: body, + }; + }, + ); + + readonly createCatalogItem6 = requestFactory( + (request: CreateCatalogItem6Params) => { + const { body } = request; + return { + method: 'POST', + url: `/catalog/6`, + body: body, + }; + }, + ); + + readonly createCatalogItem7 = requestFactory( + (request: CreateCatalogItem7Params) => { + const { body } = request; + return { + method: 'POST', + url: `/catalog/7`, + body: body, + }; + }, + ); + + readonly createCatalogItem8 = requestFactory( + (request: CreateCatalogItem8Params) => { + const { body } = request; + return { + method: 'POST', + url: `/catalog/8`, + body: body, + }; + }, + ); + + readonly createCatalogItem9 = requestFactory( + (request: CreateCatalogItem9Params) => { + const { body } = request; + return { + method: 'POST', + url: `/catalog/9`, + body: body, + }; + }, + ); + + readonly getCatalogItem1 = requestFactory( + (request: GetCatalogItem1Params) => { + const { limit } = request; + return { + method: 'GET', + url: `/catalog/1`, + params: httpParams({ limit }), + }; + }, + ); + + readonly getCatalogItem10 = requestFactory( + (request: GetCatalogItem10Params) => { + const { limit } = request; + return { + method: 'GET', + url: `/catalog/10`, + params: httpParams({ limit }), + }; + }, + ); + + readonly getCatalogItem2 = requestFactory( + (request: GetCatalogItem2Params) => { + const { limit } = request; + return { + method: 'GET', + url: `/catalog/2`, + params: httpParams({ limit }), + }; + }, + ); + + readonly getCatalogItem3 = requestFactory( + (request: GetCatalogItem3Params) => { + const { limit } = request; + return { + method: 'GET', + url: `/catalog/3`, + params: httpParams({ limit }), + }; + }, + ); + + readonly getCatalogItem4 = requestFactory( + (request: GetCatalogItem4Params) => { + const { limit } = request; + return { + method: 'GET', + url: `/catalog/4`, + params: httpParams({ limit }), + }; + }, + ); + + readonly getCatalogItem5 = requestFactory( + (request: GetCatalogItem5Params) => { + const { limit } = request; + return { + method: 'GET', + url: `/catalog/5`, + params: httpParams({ limit }), + }; + }, + ); + + readonly getCatalogItem6 = requestFactory( + (request: GetCatalogItem6Params) => { + const { limit } = request; + return { + method: 'GET', + url: `/catalog/6`, + params: httpParams({ limit }), + }; + }, + ); + + readonly getCatalogItem7 = requestFactory( + (request: GetCatalogItem7Params) => { + const { limit } = request; + return { + method: 'GET', + url: `/catalog/7`, + params: httpParams({ limit }), + }; + }, + ); + + readonly getCatalogItem8 = requestFactory( + (request: GetCatalogItem8Params) => { + const { limit } = request; + return { + method: 'GET', + url: `/catalog/8`, + params: httpParams({ limit }), + }; + }, + ); + + readonly getCatalogItem9 = requestFactory( + (request: GetCatalogItem9Params) => { + const { limit } = request; + return { + method: 'GET', + url: `/catalog/9`, + params: httpParams({ limit }), + }; + }, + ); +} + +export interface CreateCatalogItem1Params { + body: CatalogItem1; +} + +export interface CreateCatalogItem10Params { + body: CatalogItem10; +} + +export interface CreateCatalogItem2Params { + body: CatalogItem2; +} + +export interface CreateCatalogItem3Params { + body: CatalogItem3; +} + +export interface CreateCatalogItem4Params { + body: CatalogItem4; +} + +export interface CreateCatalogItem5Params { + body: CatalogItem5; +} + +export interface CreateCatalogItem6Params { + body: CatalogItem6; +} + +export interface CreateCatalogItem7Params { + body: CatalogItem7; +} + +export interface CreateCatalogItem8Params { + body: CatalogItem8; +} + +export interface CreateCatalogItem9Params { + body: CatalogItem9; +} + +export interface GetCatalogItem1Params { + limit?: number; +} + +export interface GetCatalogItem10Params { + limit?: number; +} + +export interface GetCatalogItem2Params { + limit?: number; +} + +export interface GetCatalogItem3Params { + limit?: number; +} + +export interface GetCatalogItem4Params { + limit?: number; +} + +export interface GetCatalogItem5Params { + limit?: number; +} + +export interface GetCatalogItem6Params { + limit?: number; +} + +export interface GetCatalogItem7Params { + limit?: number; +} + +export interface GetCatalogItem8Params { + limit?: number; +} + +export interface GetCatalogItem9Params { + limit?: number; +} diff --git a/__test__/snapshots/generate-native/bench-multi-tag.openapi.yaml/rest/customers.rest.generated.ts b/__test__/snapshots/generate-native/bench-multi-tag.openapi.yaml/rest/customers.rest.generated.ts new file mode 100644 index 0000000..fa3edd8 --- /dev/null +++ b/__test__/snapshots/generate-native/bench-multi-tag.openapi.yaml/rest/customers.rest.generated.ts @@ -0,0 +1,330 @@ +import { Injectable } from '@angular/core'; +import { httpParams, requestFactory } from '../rest.util'; +import type { + CustomersItem1, + CustomersItem10, + CustomersItem10List, + CustomersItem1List, + CustomersItem2, + CustomersItem2List, + CustomersItem3, + CustomersItem3List, + CustomersItem4, + CustomersItem4List, + CustomersItem5, + CustomersItem5List, + CustomersItem6, + CustomersItem6List, + CustomersItem7, + CustomersItem7List, + CustomersItem8, + CustomersItem8List, + CustomersItem9, + CustomersItem9List, +} from '../model.generated'; + +@Injectable({ + providedIn: 'root', +}) +export class CustomersRest { + + readonly createCustomersItem1 = requestFactory( + (request: CreateCustomersItem1Params) => { + const { body } = request; + return { + method: 'POST', + url: `/customers/1`, + body: body, + }; + }, + ); + + readonly createCustomersItem10 = requestFactory( + (request: CreateCustomersItem10Params) => { + const { body } = request; + return { + method: 'POST', + url: `/customers/10`, + body: body, + }; + }, + ); + + readonly createCustomersItem2 = requestFactory( + (request: CreateCustomersItem2Params) => { + const { body } = request; + return { + method: 'POST', + url: `/customers/2`, + body: body, + }; + }, + ); + + readonly createCustomersItem3 = requestFactory( + (request: CreateCustomersItem3Params) => { + const { body } = request; + return { + method: 'POST', + url: `/customers/3`, + body: body, + }; + }, + ); + + readonly createCustomersItem4 = requestFactory( + (request: CreateCustomersItem4Params) => { + const { body } = request; + return { + method: 'POST', + url: `/customers/4`, + body: body, + }; + }, + ); + + readonly createCustomersItem5 = requestFactory( + (request: CreateCustomersItem5Params) => { + const { body } = request; + return { + method: 'POST', + url: `/customers/5`, + body: body, + }; + }, + ); + + readonly createCustomersItem6 = requestFactory( + (request: CreateCustomersItem6Params) => { + const { body } = request; + return { + method: 'POST', + url: `/customers/6`, + body: body, + }; + }, + ); + + readonly createCustomersItem7 = requestFactory( + (request: CreateCustomersItem7Params) => { + const { body } = request; + return { + method: 'POST', + url: `/customers/7`, + body: body, + }; + }, + ); + + readonly createCustomersItem8 = requestFactory( + (request: CreateCustomersItem8Params) => { + const { body } = request; + return { + method: 'POST', + url: `/customers/8`, + body: body, + }; + }, + ); + + readonly createCustomersItem9 = requestFactory( + (request: CreateCustomersItem9Params) => { + const { body } = request; + return { + method: 'POST', + url: `/customers/9`, + body: body, + }; + }, + ); + + readonly getCustomersItem1 = requestFactory( + (request: GetCustomersItem1Params) => { + const { limit } = request; + return { + method: 'GET', + url: `/customers/1`, + params: httpParams({ limit }), + }; + }, + ); + + readonly getCustomersItem10 = requestFactory( + (request: GetCustomersItem10Params) => { + const { limit } = request; + return { + method: 'GET', + url: `/customers/10`, + params: httpParams({ limit }), + }; + }, + ); + + readonly getCustomersItem2 = requestFactory( + (request: GetCustomersItem2Params) => { + const { limit } = request; + return { + method: 'GET', + url: `/customers/2`, + params: httpParams({ limit }), + }; + }, + ); + + readonly getCustomersItem3 = requestFactory( + (request: GetCustomersItem3Params) => { + const { limit } = request; + return { + method: 'GET', + url: `/customers/3`, + params: httpParams({ limit }), + }; + }, + ); + + readonly getCustomersItem4 = requestFactory( + (request: GetCustomersItem4Params) => { + const { limit } = request; + return { + method: 'GET', + url: `/customers/4`, + params: httpParams({ limit }), + }; + }, + ); + + readonly getCustomersItem5 = requestFactory( + (request: GetCustomersItem5Params) => { + const { limit } = request; + return { + method: 'GET', + url: `/customers/5`, + params: httpParams({ limit }), + }; + }, + ); + + readonly getCustomersItem6 = requestFactory( + (request: GetCustomersItem6Params) => { + const { limit } = request; + return { + method: 'GET', + url: `/customers/6`, + params: httpParams({ limit }), + }; + }, + ); + + readonly getCustomersItem7 = requestFactory( + (request: GetCustomersItem7Params) => { + const { limit } = request; + return { + method: 'GET', + url: `/customers/7`, + params: httpParams({ limit }), + }; + }, + ); + + readonly getCustomersItem8 = requestFactory( + (request: GetCustomersItem8Params) => { + const { limit } = request; + return { + method: 'GET', + url: `/customers/8`, + params: httpParams({ limit }), + }; + }, + ); + + readonly getCustomersItem9 = requestFactory( + (request: GetCustomersItem9Params) => { + const { limit } = request; + return { + method: 'GET', + url: `/customers/9`, + params: httpParams({ limit }), + }; + }, + ); +} + +export interface CreateCustomersItem1Params { + body: CustomersItem1; +} + +export interface CreateCustomersItem10Params { + body: CustomersItem10; +} + +export interface CreateCustomersItem2Params { + body: CustomersItem2; +} + +export interface CreateCustomersItem3Params { + body: CustomersItem3; +} + +export interface CreateCustomersItem4Params { + body: CustomersItem4; +} + +export interface CreateCustomersItem5Params { + body: CustomersItem5; +} + +export interface CreateCustomersItem6Params { + body: CustomersItem6; +} + +export interface CreateCustomersItem7Params { + body: CustomersItem7; +} + +export interface CreateCustomersItem8Params { + body: CustomersItem8; +} + +export interface CreateCustomersItem9Params { + body: CustomersItem9; +} + +export interface GetCustomersItem1Params { + limit?: number; +} + +export interface GetCustomersItem10Params { + limit?: number; +} + +export interface GetCustomersItem2Params { + limit?: number; +} + +export interface GetCustomersItem3Params { + limit?: number; +} + +export interface GetCustomersItem4Params { + limit?: number; +} + +export interface GetCustomersItem5Params { + limit?: number; +} + +export interface GetCustomersItem6Params { + limit?: number; +} + +export interface GetCustomersItem7Params { + limit?: number; +} + +export interface GetCustomersItem8Params { + limit?: number; +} + +export interface GetCustomersItem9Params { + limit?: number; +} diff --git a/__test__/snapshots/generate-native/bench-multi-tag.openapi.yaml/rest/inventory.rest.generated.ts b/__test__/snapshots/generate-native/bench-multi-tag.openapi.yaml/rest/inventory.rest.generated.ts new file mode 100644 index 0000000..d894693 --- /dev/null +++ b/__test__/snapshots/generate-native/bench-multi-tag.openapi.yaml/rest/inventory.rest.generated.ts @@ -0,0 +1,330 @@ +import { Injectable } from '@angular/core'; +import { httpParams, requestFactory } from '../rest.util'; +import type { + InventoryItem1, + InventoryItem10, + InventoryItem10List, + InventoryItem1List, + InventoryItem2, + InventoryItem2List, + InventoryItem3, + InventoryItem3List, + InventoryItem4, + InventoryItem4List, + InventoryItem5, + InventoryItem5List, + InventoryItem6, + InventoryItem6List, + InventoryItem7, + InventoryItem7List, + InventoryItem8, + InventoryItem8List, + InventoryItem9, + InventoryItem9List, +} from '../model.generated'; + +@Injectable({ + providedIn: 'root', +}) +export class InventoryRest { + + readonly createInventoryItem1 = requestFactory( + (request: CreateInventoryItem1Params) => { + const { body } = request; + return { + method: 'POST', + url: `/inventory/1`, + body: body, + }; + }, + ); + + readonly createInventoryItem10 = requestFactory( + (request: CreateInventoryItem10Params) => { + const { body } = request; + return { + method: 'POST', + url: `/inventory/10`, + body: body, + }; + }, + ); + + readonly createInventoryItem2 = requestFactory( + (request: CreateInventoryItem2Params) => { + const { body } = request; + return { + method: 'POST', + url: `/inventory/2`, + body: body, + }; + }, + ); + + readonly createInventoryItem3 = requestFactory( + (request: CreateInventoryItem3Params) => { + const { body } = request; + return { + method: 'POST', + url: `/inventory/3`, + body: body, + }; + }, + ); + + readonly createInventoryItem4 = requestFactory( + (request: CreateInventoryItem4Params) => { + const { body } = request; + return { + method: 'POST', + url: `/inventory/4`, + body: body, + }; + }, + ); + + readonly createInventoryItem5 = requestFactory( + (request: CreateInventoryItem5Params) => { + const { body } = request; + return { + method: 'POST', + url: `/inventory/5`, + body: body, + }; + }, + ); + + readonly createInventoryItem6 = requestFactory( + (request: CreateInventoryItem6Params) => { + const { body } = request; + return { + method: 'POST', + url: `/inventory/6`, + body: body, + }; + }, + ); + + readonly createInventoryItem7 = requestFactory( + (request: CreateInventoryItem7Params) => { + const { body } = request; + return { + method: 'POST', + url: `/inventory/7`, + body: body, + }; + }, + ); + + readonly createInventoryItem8 = requestFactory( + (request: CreateInventoryItem8Params) => { + const { body } = request; + return { + method: 'POST', + url: `/inventory/8`, + body: body, + }; + }, + ); + + readonly createInventoryItem9 = requestFactory( + (request: CreateInventoryItem9Params) => { + const { body } = request; + return { + method: 'POST', + url: `/inventory/9`, + body: body, + }; + }, + ); + + readonly getInventoryItem1 = requestFactory( + (request: GetInventoryItem1Params) => { + const { limit } = request; + return { + method: 'GET', + url: `/inventory/1`, + params: httpParams({ limit }), + }; + }, + ); + + readonly getInventoryItem10 = requestFactory( + (request: GetInventoryItem10Params) => { + const { limit } = request; + return { + method: 'GET', + url: `/inventory/10`, + params: httpParams({ limit }), + }; + }, + ); + + readonly getInventoryItem2 = requestFactory( + (request: GetInventoryItem2Params) => { + const { limit } = request; + return { + method: 'GET', + url: `/inventory/2`, + params: httpParams({ limit }), + }; + }, + ); + + readonly getInventoryItem3 = requestFactory( + (request: GetInventoryItem3Params) => { + const { limit } = request; + return { + method: 'GET', + url: `/inventory/3`, + params: httpParams({ limit }), + }; + }, + ); + + readonly getInventoryItem4 = requestFactory( + (request: GetInventoryItem4Params) => { + const { limit } = request; + return { + method: 'GET', + url: `/inventory/4`, + params: httpParams({ limit }), + }; + }, + ); + + readonly getInventoryItem5 = requestFactory( + (request: GetInventoryItem5Params) => { + const { limit } = request; + return { + method: 'GET', + url: `/inventory/5`, + params: httpParams({ limit }), + }; + }, + ); + + readonly getInventoryItem6 = requestFactory( + (request: GetInventoryItem6Params) => { + const { limit } = request; + return { + method: 'GET', + url: `/inventory/6`, + params: httpParams({ limit }), + }; + }, + ); + + readonly getInventoryItem7 = requestFactory( + (request: GetInventoryItem7Params) => { + const { limit } = request; + return { + method: 'GET', + url: `/inventory/7`, + params: httpParams({ limit }), + }; + }, + ); + + readonly getInventoryItem8 = requestFactory( + (request: GetInventoryItem8Params) => { + const { limit } = request; + return { + method: 'GET', + url: `/inventory/8`, + params: httpParams({ limit }), + }; + }, + ); + + readonly getInventoryItem9 = requestFactory( + (request: GetInventoryItem9Params) => { + const { limit } = request; + return { + method: 'GET', + url: `/inventory/9`, + params: httpParams({ limit }), + }; + }, + ); +} + +export interface CreateInventoryItem1Params { + body: InventoryItem1; +} + +export interface CreateInventoryItem10Params { + body: InventoryItem10; +} + +export interface CreateInventoryItem2Params { + body: InventoryItem2; +} + +export interface CreateInventoryItem3Params { + body: InventoryItem3; +} + +export interface CreateInventoryItem4Params { + body: InventoryItem4; +} + +export interface CreateInventoryItem5Params { + body: InventoryItem5; +} + +export interface CreateInventoryItem6Params { + body: InventoryItem6; +} + +export interface CreateInventoryItem7Params { + body: InventoryItem7; +} + +export interface CreateInventoryItem8Params { + body: InventoryItem8; +} + +export interface CreateInventoryItem9Params { + body: InventoryItem9; +} + +export interface GetInventoryItem1Params { + limit?: number; +} + +export interface GetInventoryItem10Params { + limit?: number; +} + +export interface GetInventoryItem2Params { + limit?: number; +} + +export interface GetInventoryItem3Params { + limit?: number; +} + +export interface GetInventoryItem4Params { + limit?: number; +} + +export interface GetInventoryItem5Params { + limit?: number; +} + +export interface GetInventoryItem6Params { + limit?: number; +} + +export interface GetInventoryItem7Params { + limit?: number; +} + +export interface GetInventoryItem8Params { + limit?: number; +} + +export interface GetInventoryItem9Params { + limit?: number; +} diff --git a/__test__/snapshots/generate-native/bench-multi-tag.openapi.yaml/rest/orders.rest.generated.ts b/__test__/snapshots/generate-native/bench-multi-tag.openapi.yaml/rest/orders.rest.generated.ts new file mode 100644 index 0000000..a9a83bb --- /dev/null +++ b/__test__/snapshots/generate-native/bench-multi-tag.openapi.yaml/rest/orders.rest.generated.ts @@ -0,0 +1,330 @@ +import { Injectable } from '@angular/core'; +import { httpParams, requestFactory } from '../rest.util'; +import type { + OrdersItem1, + OrdersItem10, + OrdersItem10List, + OrdersItem1List, + OrdersItem2, + OrdersItem2List, + OrdersItem3, + OrdersItem3List, + OrdersItem4, + OrdersItem4List, + OrdersItem5, + OrdersItem5List, + OrdersItem6, + OrdersItem6List, + OrdersItem7, + OrdersItem7List, + OrdersItem8, + OrdersItem8List, + OrdersItem9, + OrdersItem9List, +} from '../model.generated'; + +@Injectable({ + providedIn: 'root', +}) +export class OrdersRest { + + readonly createOrdersItem1 = requestFactory( + (request: CreateOrdersItem1Params) => { + const { body } = request; + return { + method: 'POST', + url: `/orders/1`, + body: body, + }; + }, + ); + + readonly createOrdersItem10 = requestFactory( + (request: CreateOrdersItem10Params) => { + const { body } = request; + return { + method: 'POST', + url: `/orders/10`, + body: body, + }; + }, + ); + + readonly createOrdersItem2 = requestFactory( + (request: CreateOrdersItem2Params) => { + const { body } = request; + return { + method: 'POST', + url: `/orders/2`, + body: body, + }; + }, + ); + + readonly createOrdersItem3 = requestFactory( + (request: CreateOrdersItem3Params) => { + const { body } = request; + return { + method: 'POST', + url: `/orders/3`, + body: body, + }; + }, + ); + + readonly createOrdersItem4 = requestFactory( + (request: CreateOrdersItem4Params) => { + const { body } = request; + return { + method: 'POST', + url: `/orders/4`, + body: body, + }; + }, + ); + + readonly createOrdersItem5 = requestFactory( + (request: CreateOrdersItem5Params) => { + const { body } = request; + return { + method: 'POST', + url: `/orders/5`, + body: body, + }; + }, + ); + + readonly createOrdersItem6 = requestFactory( + (request: CreateOrdersItem6Params) => { + const { body } = request; + return { + method: 'POST', + url: `/orders/6`, + body: body, + }; + }, + ); + + readonly createOrdersItem7 = requestFactory( + (request: CreateOrdersItem7Params) => { + const { body } = request; + return { + method: 'POST', + url: `/orders/7`, + body: body, + }; + }, + ); + + readonly createOrdersItem8 = requestFactory( + (request: CreateOrdersItem8Params) => { + const { body } = request; + return { + method: 'POST', + url: `/orders/8`, + body: body, + }; + }, + ); + + readonly createOrdersItem9 = requestFactory( + (request: CreateOrdersItem9Params) => { + const { body } = request; + return { + method: 'POST', + url: `/orders/9`, + body: body, + }; + }, + ); + + readonly getOrdersItem1 = requestFactory( + (request: GetOrdersItem1Params) => { + const { limit } = request; + return { + method: 'GET', + url: `/orders/1`, + params: httpParams({ limit }), + }; + }, + ); + + readonly getOrdersItem10 = requestFactory( + (request: GetOrdersItem10Params) => { + const { limit } = request; + return { + method: 'GET', + url: `/orders/10`, + params: httpParams({ limit }), + }; + }, + ); + + readonly getOrdersItem2 = requestFactory( + (request: GetOrdersItem2Params) => { + const { limit } = request; + return { + method: 'GET', + url: `/orders/2`, + params: httpParams({ limit }), + }; + }, + ); + + readonly getOrdersItem3 = requestFactory( + (request: GetOrdersItem3Params) => { + const { limit } = request; + return { + method: 'GET', + url: `/orders/3`, + params: httpParams({ limit }), + }; + }, + ); + + readonly getOrdersItem4 = requestFactory( + (request: GetOrdersItem4Params) => { + const { limit } = request; + return { + method: 'GET', + url: `/orders/4`, + params: httpParams({ limit }), + }; + }, + ); + + readonly getOrdersItem5 = requestFactory( + (request: GetOrdersItem5Params) => { + const { limit } = request; + return { + method: 'GET', + url: `/orders/5`, + params: httpParams({ limit }), + }; + }, + ); + + readonly getOrdersItem6 = requestFactory( + (request: GetOrdersItem6Params) => { + const { limit } = request; + return { + method: 'GET', + url: `/orders/6`, + params: httpParams({ limit }), + }; + }, + ); + + readonly getOrdersItem7 = requestFactory( + (request: GetOrdersItem7Params) => { + const { limit } = request; + return { + method: 'GET', + url: `/orders/7`, + params: httpParams({ limit }), + }; + }, + ); + + readonly getOrdersItem8 = requestFactory( + (request: GetOrdersItem8Params) => { + const { limit } = request; + return { + method: 'GET', + url: `/orders/8`, + params: httpParams({ limit }), + }; + }, + ); + + readonly getOrdersItem9 = requestFactory( + (request: GetOrdersItem9Params) => { + const { limit } = request; + return { + method: 'GET', + url: `/orders/9`, + params: httpParams({ limit }), + }; + }, + ); +} + +export interface CreateOrdersItem1Params { + body: OrdersItem1; +} + +export interface CreateOrdersItem10Params { + body: OrdersItem10; +} + +export interface CreateOrdersItem2Params { + body: OrdersItem2; +} + +export interface CreateOrdersItem3Params { + body: OrdersItem3; +} + +export interface CreateOrdersItem4Params { + body: OrdersItem4; +} + +export interface CreateOrdersItem5Params { + body: OrdersItem5; +} + +export interface CreateOrdersItem6Params { + body: OrdersItem6; +} + +export interface CreateOrdersItem7Params { + body: OrdersItem7; +} + +export interface CreateOrdersItem8Params { + body: OrdersItem8; +} + +export interface CreateOrdersItem9Params { + body: OrdersItem9; +} + +export interface GetOrdersItem1Params { + limit?: number; +} + +export interface GetOrdersItem10Params { + limit?: number; +} + +export interface GetOrdersItem2Params { + limit?: number; +} + +export interface GetOrdersItem3Params { + limit?: number; +} + +export interface GetOrdersItem4Params { + limit?: number; +} + +export interface GetOrdersItem5Params { + limit?: number; +} + +export interface GetOrdersItem6Params { + limit?: number; +} + +export interface GetOrdersItem7Params { + limit?: number; +} + +export interface GetOrdersItem8Params { + limit?: number; +} + +export interface GetOrdersItem9Params { + limit?: number; +} diff --git a/__test__/snapshots/generate-native/bench-multi-tag.openapi.yaml/rest/shipping.rest.generated.ts b/__test__/snapshots/generate-native/bench-multi-tag.openapi.yaml/rest/shipping.rest.generated.ts new file mode 100644 index 0000000..26bfae7 --- /dev/null +++ b/__test__/snapshots/generate-native/bench-multi-tag.openapi.yaml/rest/shipping.rest.generated.ts @@ -0,0 +1,330 @@ +import { Injectable } from '@angular/core'; +import { httpParams, requestFactory } from '../rest.util'; +import type { + ShippingItem1, + ShippingItem10, + ShippingItem10List, + ShippingItem1List, + ShippingItem2, + ShippingItem2List, + ShippingItem3, + ShippingItem3List, + ShippingItem4, + ShippingItem4List, + ShippingItem5, + ShippingItem5List, + ShippingItem6, + ShippingItem6List, + ShippingItem7, + ShippingItem7List, + ShippingItem8, + ShippingItem8List, + ShippingItem9, + ShippingItem9List, +} from '../model.generated'; + +@Injectable({ + providedIn: 'root', +}) +export class ShippingRest { + + readonly createShippingItem1 = requestFactory( + (request: CreateShippingItem1Params) => { + const { body } = request; + return { + method: 'POST', + url: `/shipping/1`, + body: body, + }; + }, + ); + + readonly createShippingItem10 = requestFactory( + (request: CreateShippingItem10Params) => { + const { body } = request; + return { + method: 'POST', + url: `/shipping/10`, + body: body, + }; + }, + ); + + readonly createShippingItem2 = requestFactory( + (request: CreateShippingItem2Params) => { + const { body } = request; + return { + method: 'POST', + url: `/shipping/2`, + body: body, + }; + }, + ); + + readonly createShippingItem3 = requestFactory( + (request: CreateShippingItem3Params) => { + const { body } = request; + return { + method: 'POST', + url: `/shipping/3`, + body: body, + }; + }, + ); + + readonly createShippingItem4 = requestFactory( + (request: CreateShippingItem4Params) => { + const { body } = request; + return { + method: 'POST', + url: `/shipping/4`, + body: body, + }; + }, + ); + + readonly createShippingItem5 = requestFactory( + (request: CreateShippingItem5Params) => { + const { body } = request; + return { + method: 'POST', + url: `/shipping/5`, + body: body, + }; + }, + ); + + readonly createShippingItem6 = requestFactory( + (request: CreateShippingItem6Params) => { + const { body } = request; + return { + method: 'POST', + url: `/shipping/6`, + body: body, + }; + }, + ); + + readonly createShippingItem7 = requestFactory( + (request: CreateShippingItem7Params) => { + const { body } = request; + return { + method: 'POST', + url: `/shipping/7`, + body: body, + }; + }, + ); + + readonly createShippingItem8 = requestFactory( + (request: CreateShippingItem8Params) => { + const { body } = request; + return { + method: 'POST', + url: `/shipping/8`, + body: body, + }; + }, + ); + + readonly createShippingItem9 = requestFactory( + (request: CreateShippingItem9Params) => { + const { body } = request; + return { + method: 'POST', + url: `/shipping/9`, + body: body, + }; + }, + ); + + readonly getShippingItem1 = requestFactory( + (request: GetShippingItem1Params) => { + const { limit } = request; + return { + method: 'GET', + url: `/shipping/1`, + params: httpParams({ limit }), + }; + }, + ); + + readonly getShippingItem10 = requestFactory( + (request: GetShippingItem10Params) => { + const { limit } = request; + return { + method: 'GET', + url: `/shipping/10`, + params: httpParams({ limit }), + }; + }, + ); + + readonly getShippingItem2 = requestFactory( + (request: GetShippingItem2Params) => { + const { limit } = request; + return { + method: 'GET', + url: `/shipping/2`, + params: httpParams({ limit }), + }; + }, + ); + + readonly getShippingItem3 = requestFactory( + (request: GetShippingItem3Params) => { + const { limit } = request; + return { + method: 'GET', + url: `/shipping/3`, + params: httpParams({ limit }), + }; + }, + ); + + readonly getShippingItem4 = requestFactory( + (request: GetShippingItem4Params) => { + const { limit } = request; + return { + method: 'GET', + url: `/shipping/4`, + params: httpParams({ limit }), + }; + }, + ); + + readonly getShippingItem5 = requestFactory( + (request: GetShippingItem5Params) => { + const { limit } = request; + return { + method: 'GET', + url: `/shipping/5`, + params: httpParams({ limit }), + }; + }, + ); + + readonly getShippingItem6 = requestFactory( + (request: GetShippingItem6Params) => { + const { limit } = request; + return { + method: 'GET', + url: `/shipping/6`, + params: httpParams({ limit }), + }; + }, + ); + + readonly getShippingItem7 = requestFactory( + (request: GetShippingItem7Params) => { + const { limit } = request; + return { + method: 'GET', + url: `/shipping/7`, + params: httpParams({ limit }), + }; + }, + ); + + readonly getShippingItem8 = requestFactory( + (request: GetShippingItem8Params) => { + const { limit } = request; + return { + method: 'GET', + url: `/shipping/8`, + params: httpParams({ limit }), + }; + }, + ); + + readonly getShippingItem9 = requestFactory( + (request: GetShippingItem9Params) => { + const { limit } = request; + return { + method: 'GET', + url: `/shipping/9`, + params: httpParams({ limit }), + }; + }, + ); +} + +export interface CreateShippingItem1Params { + body: ShippingItem1; +} + +export interface CreateShippingItem10Params { + body: ShippingItem10; +} + +export interface CreateShippingItem2Params { + body: ShippingItem2; +} + +export interface CreateShippingItem3Params { + body: ShippingItem3; +} + +export interface CreateShippingItem4Params { + body: ShippingItem4; +} + +export interface CreateShippingItem5Params { + body: ShippingItem5; +} + +export interface CreateShippingItem6Params { + body: ShippingItem6; +} + +export interface CreateShippingItem7Params { + body: ShippingItem7; +} + +export interface CreateShippingItem8Params { + body: ShippingItem8; +} + +export interface CreateShippingItem9Params { + body: ShippingItem9; +} + +export interface GetShippingItem1Params { + limit?: number; +} + +export interface GetShippingItem10Params { + limit?: number; +} + +export interface GetShippingItem2Params { + limit?: number; +} + +export interface GetShippingItem3Params { + limit?: number; +} + +export interface GetShippingItem4Params { + limit?: number; +} + +export interface GetShippingItem5Params { + limit?: number; +} + +export interface GetShippingItem6Params { + limit?: number; +} + +export interface GetShippingItem7Params { + limit?: number; +} + +export interface GetShippingItem8Params { + limit?: number; +} + +export interface GetShippingItem9Params { + limit?: number; +} diff --git a/__test__/snapshots/generate-native/consumer-forms-and-non-json.openapi.yaml.success.json b/__test__/snapshots/generate-native/consumer-forms-and-non-json.openapi.yaml.success.json new file mode 100644 index 0000000..2584659 --- /dev/null +++ b/__test__/snapshots/generate-native/consumer-forms-and-non-json.openapi.yaml.success.json @@ -0,0 +1,52 @@ +{ + "summary": { + "normalizedSourcePath": "test/fixtures/consumer-forms-and-non-json.openapi.yaml", + "specVersion": "3.0.3", + "title": "Consumer Forms and Non-JSON Responses", + "pathCount": 5, + "operationCount": 5, + "schemaCount": 0 + }, + "diagnostics": [ + { + "code": "E_UNSUPPORTED_SEMANTIC", + "severity": "warning", + "message": "requestBody for POST /pets/{petId}/avatar.avatar declares format 'binary', which is currently dropped — the generator emits the base type without format-specific narrowing.", + "path": "test/fixtures/consumer-forms-and-non-json.openapi.yaml", + "subcode": "format-dropped" + }, + { + "code": "E_UNSUPPORTED_SEMANTIC", + "severity": "warning", + "message": "requestBody for POST /pets/{petId}/avatar.galleries declares format 'binary', which is currently dropped — the generator emits the base type without format-specific narrowing.", + "path": "test/fixtures/consumer-forms-and-non-json.openapi.yaml", + "subcode": "format-dropped" + } + ], + "artifacts": [ + { + "path": "rest.model.ts" + }, + { + "path": "rest.util.ts" + }, + { + "path": "rest.validate.ts" + }, + { + "path": "rest/binary.rest.generated.ts" + }, + { + "path": "rest/config.rest.generated.ts" + }, + { + "path": "rest/invoice.rest.generated.ts" + }, + { + "path": "rest/pet.rest.generated.ts" + }, + { + "path": "rest/search.rest.generated.ts" + } + ] +} diff --git a/__test__/snapshots/generate-native/consumer-forms-and-non-json.openapi.yaml/rest/binary.rest.generated.ts b/__test__/snapshots/generate-native/consumer-forms-and-non-json.openapi.yaml/rest/binary.rest.generated.ts new file mode 100644 index 0000000..6f5470d --- /dev/null +++ b/__test__/snapshots/generate-native/consumer-forms-and-non-json.openapi.yaml/rest/binary.rest.generated.ts @@ -0,0 +1,15 @@ +import { Injectable } from '@angular/core'; +import { requestFactory } from '../rest.util'; + +@Injectable({ + providedIn: 'root', +}) +export class BinaryRest { + + readonly fetchBlob = requestFactory.zeroArg.blob( + () => ({ + method: 'GET', + url: `/binary/fetch`, + }), + ); +} diff --git a/__test__/snapshots/generate-native/consumer-forms-and-non-json.openapi.yaml/rest/config.rest.generated.ts b/__test__/snapshots/generate-native/consumer-forms-and-non-json.openapi.yaml/rest/config.rest.generated.ts new file mode 100644 index 0000000..d8bc8c3 --- /dev/null +++ b/__test__/snapshots/generate-native/consumer-forms-and-non-json.openapi.yaml/rest/config.rest.generated.ts @@ -0,0 +1,15 @@ +import { Injectable } from '@angular/core'; +import { requestFactory } from '../rest.util'; + +@Injectable({ + providedIn: 'root', +}) +export class ConfigRest { + + readonly getRawConfig = requestFactory.zeroArg.text( + () => ({ + method: 'GET', + url: `/config/raw`, + }), + ); +} diff --git a/__test__/snapshots/generate-native/consumer-forms-and-non-json.openapi.yaml/rest/invoice.rest.generated.ts b/__test__/snapshots/generate-native/consumer-forms-and-non-json.openapi.yaml/rest/invoice.rest.generated.ts new file mode 100644 index 0000000..615323f --- /dev/null +++ b/__test__/snapshots/generate-native/consumer-forms-and-non-json.openapi.yaml/rest/invoice.rest.generated.ts @@ -0,0 +1,22 @@ +import { Injectable } from '@angular/core'; +import { requestFactory } from '../rest.util'; + +@Injectable({ + providedIn: 'root', +}) +export class InvoiceRest { + + readonly downloadInvoicePdf = requestFactory.blob( + (request: DownloadInvoicePdfParams) => { + const { invoiceId } = request; + return { + method: 'GET', + url: `/invoices/${encodeURIComponent(invoiceId)}/pdf`, + }; + }, + ); +} + +export interface DownloadInvoicePdfParams { + invoiceId: string; +} diff --git a/__test__/snapshots/generate-native/consumer-forms-and-non-json.openapi.yaml/rest/pet.rest.generated.ts b/__test__/snapshots/generate-native/consumer-forms-and-non-json.openapi.yaml/rest/pet.rest.generated.ts new file mode 100644 index 0000000..c05f85d --- /dev/null +++ b/__test__/snapshots/generate-native/consumer-forms-and-non-json.openapi.yaml/rest/pet.rest.generated.ts @@ -0,0 +1,38 @@ +import { Injectable } from '@angular/core'; +import { requestFactory } from '../rest.util'; + +@Injectable({ + providedIn: 'root', +}) +export class PetRest { + + readonly updatePetAvatar = requestFactory( + (request: UpdatePetAvatarParams) => { + const { petId, avatar, galleries, nickname, status, tagIds } = request; + return { + method: 'POST', + url: `/pets/${encodeURIComponent(petId)}/avatar`, + body: ((): FormData => { + const fd = new FormData(); + fd.append('avatar', avatar); + for (const v of galleries) fd.append('galleries', v); + if (nickname !== undefined) fd.append('nickname', String(nickname)); + fd.append('status', String(status)); + for (const v of tagIds) fd.append('tagIds', String(v)); + return fd; + })(), + }; + }, + ); +} + +export interface UpdatePetAvatarParams { + petId: string; + avatar: Blob | File; + galleries: (Blob | File)[]; + nickname?: string; + status: string; + tagIds: number[]; +} diff --git a/__test__/snapshots/generate-native/consumer-forms-and-non-json.openapi.yaml/rest/search.rest.generated.ts b/__test__/snapshots/generate-native/consumer-forms-and-non-json.openapi.yaml/rest/search.rest.generated.ts new file mode 100644 index 0000000..b99cc09 --- /dev/null +++ b/__test__/snapshots/generate-native/consumer-forms-and-non-json.openapi.yaml/rest/search.rest.generated.ts @@ -0,0 +1,31 @@ +import { Injectable } from '@angular/core'; +import { requestFactory } from '../rest.util'; + +@Injectable({ + providedIn: 'root', +}) +export class SearchRest { + + readonly submitForm = requestFactory( + (request: SubmitFormParams) => { + const { status, tagIds } = request; + return { + method: 'POST', + url: `/search`, + body: ((): URLSearchParams => { + const params = new URLSearchParams(); + params.append('status', String(status)); + for (const v of tagIds) params.append('tagIds', String(v)); + return params; + })(), + }; + }, + ); +} + +export interface SubmitFormParams { + status: string; + tagIds: number[]; +} diff --git a/__test__/snapshots/generate-native/cookie-param.openapi.yaml.success.json b/__test__/snapshots/generate-native/cookie-param.openapi.yaml.success.json new file mode 100644 index 0000000..08a108d --- /dev/null +++ b/__test__/snapshots/generate-native/cookie-param.openapi.yaml.success.json @@ -0,0 +1,33 @@ +{ + "summary": { + "normalizedSourcePath": "test/fixtures/cookie-param.openapi.yaml", + "specVersion": "3.0.3", + "title": "Cookie Param", + "pathCount": 1, + "operationCount": 1, + "schemaCount": 0 + }, + "diagnostics": [ + { + "code": "E_UNSUPPORTED_SEMANTIC", + "severity": "warning", + "message": "operationId 'listPets': parameter 'sessionId' uses location 'cookie', which is not supported in the generated service contract and will be omitted.", + "path": "test/fixtures/cookie-param.openapi.yaml", + "subcode": "unsupported-parameter-location" + } + ], + "artifacts": [ + { + "path": "rest.model.ts" + }, + { + "path": "rest.util.ts" + }, + { + "path": "rest.validate.ts" + }, + { + "path": "rest/pet.rest.generated.ts" + } + ] +} diff --git a/__test__/snapshots/generate-native/cookie-param.openapi.yaml/rest/pet.rest.generated.ts b/__test__/snapshots/generate-native/cookie-param.openapi.yaml/rest/pet.rest.generated.ts new file mode 100644 index 0000000..1f999d4 --- /dev/null +++ b/__test__/snapshots/generate-native/cookie-param.openapi.yaml/rest/pet.rest.generated.ts @@ -0,0 +1,15 @@ +import { Injectable } from '@angular/core'; +import { requestFactory } from '../rest.util'; + +@Injectable({ + providedIn: 'root', +}) +export class PetRest { + + readonly listPets = requestFactory.zeroArg( + () => ({ + method: 'GET', + url: `/pets`, + }), + ); +} diff --git a/__test__/snapshots/generate-native/duplicate-operation-id.openapi.yaml.failure.json b/__test__/snapshots/generate-native/duplicate-operation-id.openapi.yaml.failure.json new file mode 100644 index 0000000..186285e --- /dev/null +++ b/__test__/snapshots/generate-native/duplicate-operation-id.openapi.yaml.failure.json @@ -0,0 +1,6 @@ +{ + "code": "E_POLICY_VIOLATION", + "message": "Failed to plan services: operationId 'doIt' is defined on both GET /a and GET /b. operationIds must be globally unique.", + "path": "test/fixtures/duplicate-operation-id.openapi.yaml", + "warnings": [] +} diff --git a/__test__/snapshots/generate-native/duplicate-schema-name.openapi.yaml.failure.json b/__test__/snapshots/generate-native/duplicate-schema-name.openapi.yaml.failure.json new file mode 100644 index 0000000..bc9381a --- /dev/null +++ b/__test__/snapshots/generate-native/duplicate-schema-name.openapi.yaml.failure.json @@ -0,0 +1,6 @@ +{ + "code": "E_POLICY_VIOLATION", + "message": "Failed to decode OpenAPI input: components.schemas: duplicate key 'Pet' at line 8 column 5. Each schema name must be declared once.", + "path": "test/fixtures/duplicate-schema-name.openapi.yaml", + "warnings": [] +} diff --git a/__test__/snapshots/generate-native/errors-typed.openapi.yaml.success.json b/__test__/snapshots/generate-native/errors-typed.openapi.yaml.success.json new file mode 100644 index 0000000..7dc1edb --- /dev/null +++ b/__test__/snapshots/generate-native/errors-typed.openapi.yaml.success.json @@ -0,0 +1,28 @@ +{ + "summary": { + "normalizedSourcePath": "test/fixtures/errors-typed.openapi.yaml", + "specVersion": "3.0.3", + "title": "Errors Typed", + "pathCount": 1, + "operationCount": 1, + "schemaCount": 4 + }, + "diagnostics": [], + "artifacts": [ + { + "path": "model.generated.ts" + }, + { + "path": "rest.model.ts" + }, + { + "path": "rest.util.ts" + }, + { + "path": "rest.validate.ts" + }, + { + "path": "rest/pet.rest.generated.ts" + } + ] +} diff --git a/__test__/snapshots/generate-native/errors-typed.openapi.yaml/model.generated.ts b/__test__/snapshots/generate-native/errors-typed.openapi.yaml/model.generated.ts new file mode 100644 index 0000000..24665cc --- /dev/null +++ b/__test__/snapshots/generate-native/errors-typed.openapi.yaml/model.generated.ts @@ -0,0 +1,16 @@ +export interface NotFound { + resource: string; +} + +export interface Pet { + id: string; +} + +export interface UpdatePetRequest { + status: string; +} + +export interface ValidationProblem { + code: string; + field: string; +} diff --git a/__test__/snapshots/generate-native/errors-typed.openapi.yaml/rest/pet.rest.generated.ts b/__test__/snapshots/generate-native/errors-typed.openapi.yaml/rest/pet.rest.generated.ts new file mode 100644 index 0000000..7b01795 --- /dev/null +++ b/__test__/snapshots/generate-native/errors-typed.openapi.yaml/rest/pet.rest.generated.ts @@ -0,0 +1,33 @@ +import { Injectable } from '@angular/core'; +import { requestFactory } from '../rest.util'; +import type { NotFound, Pet, UpdatePetRequest, ValidationProblem } from '../model.generated'; + +@Injectable({ + providedIn: 'root', +}) +export class PetRest { + + readonly updatePet = requestFactory( + (request: UpdatePetParams) => { + const { petId, body } = request; + return { + method: 'POST', + url: `/pets/${encodeURIComponent(petId)}`, + body: body, + }; + }, + ); +} + +export interface UpdatePetParams { + petId: string; + body: UpdatePetRequest; +} + +export interface UpdatePetError { + 400: ValidationProblem; + 404: NotFound; + 500: { + traceId: string; + }; +} diff --git a/__test__/snapshots/generate-native/missing-tag.openapi.yaml.success.json b/__test__/snapshots/generate-native/missing-tag.openapi.yaml.success.json new file mode 100644 index 0000000..95845c6 --- /dev/null +++ b/__test__/snapshots/generate-native/missing-tag.openapi.yaml.success.json @@ -0,0 +1,25 @@ +{ + "summary": { + "normalizedSourcePath": "test/fixtures/missing-tag.openapi.yaml", + "specVersion": "3.0.3", + "title": "Missing Tag", + "pathCount": 1, + "operationCount": 1, + "schemaCount": 0 + }, + "diagnostics": [], + "artifacts": [ + { + "path": "rest.model.ts" + }, + { + "path": "rest.util.ts" + }, + { + "path": "rest.validate.ts" + }, + { + "path": "rest/pets.rest.generated.ts" + } + ] +} diff --git a/__test__/snapshots/generate-native/missing-tag.openapi.yaml/rest/pets.rest.generated.ts b/__test__/snapshots/generate-native/missing-tag.openapi.yaml/rest/pets.rest.generated.ts new file mode 100644 index 0000000..b37398b --- /dev/null +++ b/__test__/snapshots/generate-native/missing-tag.openapi.yaml/rest/pets.rest.generated.ts @@ -0,0 +1,15 @@ +import { Injectable } from '@angular/core'; +import { requestFactory } from '../rest.util'; + +@Injectable({ + providedIn: 'root', +}) +export class PetsRest { + + readonly listPets = requestFactory.zeroArg( + () => ({ + method: 'GET', + url: `/pets`, + }), + ); +} diff --git a/__test__/snapshots/generate-native/unsupported-trace.openapi.yaml.failure.json b/__test__/snapshots/generate-native/unsupported-trace.openapi.yaml.failure.json new file mode 100644 index 0000000..3e55233 --- /dev/null +++ b/__test__/snapshots/generate-native/unsupported-trace.openapi.yaml.failure.json @@ -0,0 +1,6 @@ +{ + "code": "E_UNSUPPORTED_SEMANTIC", + "message": "Unsupported OpenAPI semantic shape: HTTP method TRACE for /pets is not supported; remove the trace operation or split it into a non-generated client.. See the supported subset documented in README.md ('Out of Scope' section).", + "path": "test/fixtures/unsupported-trace.openapi.yaml", + "warnings": [] +} diff --git a/__test__/snapshots/generate-native/verb-prefix.openapi.yaml.success.json b/__test__/snapshots/generate-native/verb-prefix.openapi.yaml.success.json new file mode 100644 index 0000000..dc00402 --- /dev/null +++ b/__test__/snapshots/generate-native/verb-prefix.openapi.yaml.success.json @@ -0,0 +1,25 @@ +{ + "summary": { + "normalizedSourcePath": "test/fixtures/verb-prefix.openapi.yaml", + "specVersion": "3.0.3", + "title": "VerbPrefix", + "pathCount": 1, + "operationCount": 2, + "schemaCount": 0 + }, + "diagnostics": [], + "artifacts": [ + { + "path": "rest.model.ts" + }, + { + "path": "rest.util.ts" + }, + { + "path": "rest.validate.ts" + }, + { + "path": "rest/post.rest.generated.ts" + } + ] +} diff --git a/__test__/snapshots/generate-native/verb-prefix.openapi.yaml/rest/post.rest.generated.ts b/__test__/snapshots/generate-native/verb-prefix.openapi.yaml/rest/post.rest.generated.ts new file mode 100644 index 0000000..2432185 --- /dev/null +++ b/__test__/snapshots/generate-native/verb-prefix.openapi.yaml/rest/post.rest.generated.ts @@ -0,0 +1,30 @@ +import { Injectable } from '@angular/core'; +import { requestFactory } from '../rest.util'; + +@Injectable({ + providedIn: 'root', +}) +export class PostRest { + + readonly postsCreate = requestFactory( + (request: PostsCreateParams) => { + const { body } = request; + return { + method: 'POST', + url: `/posts`, + body: body, + }; + }, + ); + + readonly postsListAll = requestFactory.zeroArg( + () => ({ + method: 'GET', + url: `/posts`, + }), + ); +} + +export interface PostsCreateParams { + body: string; +} diff --git a/__test__/snapshots/generate-native/warning-then-fatal.openapi.yaml.failure.json b/__test__/snapshots/generate-native/warning-then-fatal.openapi.yaml.failure.json new file mode 100644 index 0000000..3b5a29e --- /dev/null +++ b/__test__/snapshots/generate-native/warning-then-fatal.openapi.yaml.failure.json @@ -0,0 +1,14 @@ +{ + "code": "E_UNSUPPORTED_SEMANTIC", + "message": "Unsupported OpenAPI semantic shape: parameter bad for GET /b uses unsupported location bogus_location.. See the supported subset documented in README.md ('Out of Scope' section).", + "path": "test/fixtures/warning-then-fatal.openapi.yaml", + "warnings": [ + { + "code": "E_UNSUPPORTED_SEMANTIC", + "severity": "warning", + "message": "operationId 'opWithCookieWarning': parameter 'sessionId' uses location 'cookie', which is not supported in the generated service contract and will be omitted.", + "path": "test/fixtures/warning-then-fatal.openapi.yaml", + "subcode": "unsupported-parameter-location" + } + ] +} diff --git a/scripts/lib/snapshot-layout.ts b/scripts/lib/snapshot-layout.ts index 435368b..3b60f05 100644 --- a/scripts/lib/snapshot-layout.ts +++ b/scripts/lib/snapshot-layout.ts @@ -1,12 +1,17 @@ -// The snapshot storage layout, shared by the regenerator -// (scripts/regen-snapshots.ts) and the reader -// (__test__/generate.snapshot.spec.ts), so the two cannot drift. +// The snapshot suite's single source of truth: which fixtures are pinned, +// what each one proves, and how the files are laid out. +// +// Read by the regenerator (scripts/regen-snapshots.ts) and by the reader +// (__test__/generate.snapshot.spec.ts), so neither can drift from the +// other or from test/fixtures/. import path from 'node:path'; +import type { GenerateOptions } from '../../index.js'; + /** - * The do-not-edit banner, stripped from every stored artifact so snapshots - * survive a version bump. + * The do-not-edit banner, stripped from every stored artifact so a + * snapshot survives a version bump. */ export const BANNER_RE = /^\/\/ Generated by openapi-ng v[^\n]*\n\/\/ Source: [^\n]*\n\/\/ DO NOT EDIT[^\n]*\n\n/u; @@ -22,10 +27,154 @@ export const STATIC_TEMPLATE_PATHS: ReadonlySet = new Set([ 'rest.validate.ts', ]); +/** The emit targets every snapshot is generated with. */ +export const SNAPSHOT_EMIT = ['models', 'angular'] as const; + /** Directory holding every snapshot, given the repository root. */ export function snapshotDir(repoRoot: string): string { return path.join(repoRoot, '__test__', 'snapshots', 'generate-native'); } -/** The emit targets every snapshot is generated with. */ -export const SNAPSHOT_EMIT = ['models', 'angular'] as const; +/** A fixture whose output is pinned, and what a change to it would mean. */ +export interface SuccessFixture { + readonly fixture: string; + /** What this snapshot proves, in the terms a failing diff should be read in. */ + readonly pins: string; +} + +/** A fixture whose failure is pinned. */ +export interface FailureFixture extends SuccessFixture { + /** File the payload is stored under, distinguishing runs of one fixture. */ + readonly snapshot: string; + /** Options the default run does not pass. */ + readonly options?: Partial; +} + +export const SUCCESS_FIXTURES: readonly SuccessFixture[] = [ + { fixture: 'additional-properties.openapi.yaml', pins: 'additionalProperties as Record' }, + { + fixture: 'additional-properties-false.openapi.yaml', + pins: '`additionalProperties: false` is a no-op, unlike the rejected `true`', + }, + { fixture: 'allof-composition.openapi.yaml', pins: 'allOf as an intersection' }, + { + fixture: 'anchor-modest.openapi.yaml', + pins: 'the accept side of the expansion cap: one anchor reused three times', + }, + { fixture: 'bench-large.openapi.yaml', pins: 'sort order and buffer sizing at 100+ schemas' }, + { + fixture: 'bench-multi-tag.openapi.yaml', + pins: 'per-tag service splitting and artifact ordering across many groups', + }, + { + fixture: 'body-multipart-mixed-fields.openapi.yaml', + pins: 'the FormData IIFE across scalar, array, binary and optional fields', + }, + { + fixture: 'body-multipart-ref-to-named-object.openapi.yaml', + pins: 'a `$ref` multipart body inlines its fields and drops the now-unused import', + }, + { + fixture: 'body-urlencoded-scalar-and-array.openapi.yaml', + pins: 'the URLSearchParams IIFE, distinct from FormData', + }, + { fixture: 'circular-allof.openapi.yaml', pins: 'five allOf layers stay under the depth cap' }, + { + fixture: 'consumer-forms-and-non-json.openapi.yaml', + pins: 'form bodies and non-JSON responses together in one spec', + }, + { fixture: 'cookie-param.openapi.yaml', pins: 'a cookie parameter is dropped with a warning' }, + { + fixture: 'deprecated-fields.openapi.yaml', + pins: '`@deprecated` on an operation, a type alias and a property', + }, + { fixture: 'discriminated-union.openapi.yaml', pins: 'oneOf with a discriminator' }, + { + fixture: 'discriminator-allof.openapi.yaml', + pins: 'narrowing reaches the inline part of an allOf-shaped member', + }, + { + fixture: 'discriminator-mapping.openapi.yaml', + pins: 'an explicit mapping key wins over the lowercased schema name', + }, + { fixture: 'empty-shapes.openapi.yaml', pins: 'an empty object emits Record' }, + { fixture: 'errors-typed.openapi.yaml', pins: 'the per-operation error interface keyed by status' }, + { fixture: 'header-param.openapi.yaml', pins: 'the synthetic nested `headers` member' }, + { + fixture: 'jsdoc-descriptions.openapi.yaml', + pins: 'descriptions on a type, a property, and a merged operation summary', + }, + { fixture: 'inline-model.openapi.yaml', pins: 'an inline object body hoists its properties' }, + { fixture: 'large-enum.openapi.yaml', pins: 'both sides of the literal-union width budget' }, + { fixture: 'missing-tag.openapi.yaml', pins: 'a tagless operation groups by its first path segment' }, + { fixture: 'multi-tag-operation.openapi.yaml', pins: 'only the first tag is used for grouping' }, + { fixture: 'multi-warning.openapi.yaml', pins: 'warning order within one operation' }, + { fixture: 'nullable-oneof.openapi.yaml', pins: '`nullable` over a union flattens to `| null`' }, + { fixture: 'nullable-optional.openapi.yaml', pins: 'nullable and optional are independent' }, + { fixture: 'oneof-anyof-composition.openapi.json', pins: 'oneOf and anyOf from a JSON source' }, + { fixture: 'oneof-anyof-composition.openapi.yaml', pins: 'oneOf and anyOf both emit a union' }, + { fixture: 'petstore-minimal.openapi.json', pins: 'the smallest accepted spec, JSON' }, + { fixture: 'petstore-minimal.openapi.yaml', pins: 'the smallest accepted spec, YAML' }, + { fixture: 'petstore-rich.openapi.json', pins: 'the reference spec, JSON' }, + { fixture: 'petstore-rich.openapi.yaml', pins: 'the reference spec, YAML' }, + { fixture: 'recursive-model.openapi.yaml', pins: 'a cycle through a `$ref` property' }, + { fixture: 'recursive-oneof.openapi.yaml', pins: 'a cycle through a union member' }, + { + fixture: 'reserved-prop-names.openapi.yaml', + pins: 'a reserved word stays bare while a non-identifier gets quoted', + }, + { fixture: 'response-204-no-content.openapi.yaml', pins: 'a bodyless response emits `void`' }, + { fixture: 'response-blob-via-pdf.openapi.yaml', pins: '`application/pdf` routes to the blob variant' }, + { + fixture: 'response-default-fallback.openapi.yaml', + pins: 'a `default`-only response contributes no success type', + }, + { fixture: 'response-octet-stream.openapi.yaml', pins: '`application/octet-stream` routes to blob' }, + { fixture: 'response-problem-json.openapi.yaml', pins: 'the `+json` suffix classifies as JSON' }, + { fixture: 'response-text-via-text-plain.openapi.yaml', pins: '`text/plain` routes to the text variant' }, + { fixture: 'security-schemes.openapi.yaml', pins: 'securitySchemes is accepted and ignored' }, + { fixture: 'single-entry-composition.openapi.yaml', pins: 'a one-member composition is its member' }, + { fixture: 'string-formats.openapi.yaml', pins: 'every dropped `format` reports a warning' }, + { fixture: 'verb-prefix.openapi.yaml', pins: 'an operationId already prefixed by its verb' }, +]; + +export const FAILURE_FIXTURES: readonly FailureFixture[] = [ + { fixture: 'additional-properties-boolean.openapi.yaml', pins: '`additionalProperties: true` is rejected' }, + { fixture: 'anchor-fanout.openapi.yaml', pins: 'the reject side of the expansion cap' }, + { fixture: 'body-content-type-xml.openapi.yaml', pins: 'an unsupported body content type' }, + { fixture: 'body-multi-content.openapi.yaml', pins: 'a body declaring more than one content type' }, + { fixture: 'body-multipart-composed-field.openapi.yaml', pins: 'a composed multipart field' }, + { fixture: 'body-multipart-nested-object.openapi.yaml', pins: 'a nested-object multipart field' }, + { fixture: 'body-multipart-non-object.openapi.yaml', pins: 'a non-object multipart body' }, + { fixture: 'body-multipart-open-schema.openapi.yaml', pins: 'an open-ended multipart body' }, + { fixture: 'body-urlencoded-binary-field.openapi.yaml', pins: 'a binary field under urlencoded' }, + { fixture: 'body-urlencoded-nested-object.openapi.yaml', pins: 'a nested-object urlencoded field' }, + { fixture: 'deep-nested-allof.openapi.yaml', pins: 'forty allOf layers exceed the depth cap' }, + { fixture: 'discriminator-mapping-external-ref.openapi.yaml', pins: 'a mapping value that is an external ref' }, + { fixture: 'discriminator-missing-property.openapi.yaml', pins: 'a union member missing its discriminator' }, + { fixture: 'duplicate-operation-id.openapi.yaml', pins: 'two operations sharing an operationId' }, + { fixture: 'duplicate-schema-name.openapi.yaml', pins: 'a repeated key under components.schemas' }, + { fixture: 'empty-parameter.openapi.yaml', pins: 'a parameter with an empty schema' }, + { fixture: 'external-ref.openapi.yaml', pins: 'a `$ref` outside components.schemas' }, + { fixture: 'field-collision.openapi.yaml', pins: 'a hoisted body field colliding with a path parameter' }, + { fixture: 'inline-parameter.openapi.yaml', pins: 'a parameter with an inline object schema' }, + { fixture: 'invalid-enum-type.openapi.yaml', pins: 'an enum on a non-string type' }, + { fixture: 'invalid-enum-value.openapi.json', pins: 'a non-string enum member' }, + { fixture: 'unbalanced-path-template.openapi.yaml', pins: 'an unbalanced brace in a path template' }, + { fixture: 'unsupported-root.yaml', pins: 'components.schemas declared as a sequence' }, + { fixture: 'unsupported-semantic.openapi.yaml', pins: 'additionalProperties combined with properties' }, + { fixture: 'unsupported-trace.openapi.yaml', pins: 'a `trace:` operation is rejected' }, + { fixture: 'warning-then-fatal.openapi.yaml', pins: 'warnings recorded before a fatal are retained' }, + { + fixture: 'petstore-rich.openapi.yaml', + snapshot: 'petstore-rich.openapi.yaml.invalid-mapped-type.failure.json', + pins: 'a mapped type naming a schema the spec does not declare', + options: { mappedTypes: [{ schema: 'MissingSchema', import: '@demo/x', type: 'Missing' }] }, + }, +].map(entry => ({ snapshot: `${entry.fixture}.failure.json`, ...entry })); + +/** + * Fixtures with no snapshot. `malformed.yaml`'s wording follows the YAML + * parser's own line and column output, which the spec asserts by regex. + */ +export const UNSNAPSHOTTED: readonly string[] = ['malformed.yaml']; diff --git a/scripts/regen-snapshots.ts b/scripts/regen-snapshots.ts index 4533152..f06142c 100644 --- a/scripts/regen-snapshots.ts +++ b/scripts/regen-snapshots.ts @@ -13,7 +13,8 @@ // every success fixture and so stored once // // Every fixture in test/fixtures/ must appear in exactly one of the three -// sets below; the script fails on one that appears in none. +// sets in scripts/lib/snapshot-layout.ts; the script fails on one that +// appears in none. // // Run with: bun run regen-snapshots @@ -25,8 +26,11 @@ import { generate, isGenerateError } from './lib/engine.ts'; import type { GenerateError, GenerateOptions, GenerateResult } from './lib/engine.ts'; import { BANNER_RE, + FAILURE_FIXTURES, SNAPSHOT_EMIT, STATIC_TEMPLATE_PATHS, + SUCCESS_FIXTURES, + UNSNAPSHOTTED, snapshotDir, } from './lib/snapshot-layout.ts'; @@ -36,138 +40,15 @@ const snapshots = snapshotDir(repoRoot); const staticTemplateDir = path.join(snapshots, 'static-template'); const staticTemplateIndex = path.join(snapshots, 'static-template.json'); -/** One failure snapshot: a fixture, the options it needs, and its label. */ -interface FailureSnapshot { - readonly fixture: string; - readonly snapshot: string; - readonly options?: Partial; -} - -/** - * Fixtures that generate successfully and whose output is pinned. - * - * Ordered as the snapshots directory reads. - */ -const SUCCESS_FIXTURES: readonly string[] = [ - 'additional-properties-false.openapi.yaml', - 'additional-properties.openapi.yaml', - 'allof-composition.openapi.yaml', - 'anchor-modest.openapi.yaml', - 'bench-large.openapi.yaml', - 'body-multipart-mixed-fields.openapi.yaml', - 'body-multipart-ref-to-named-object.openapi.yaml', - 'body-urlencoded-scalar-and-array.openapi.yaml', - 'circular-allof.openapi.yaml', - 'deprecated-fields.openapi.yaml', - 'discriminated-union.openapi.yaml', - 'discriminator-allof.openapi.yaml', - 'discriminator-mapping.openapi.yaml', - 'empty-shapes.openapi.yaml', - 'header-param.openapi.yaml', - 'inline-model.openapi.yaml', - 'jsdoc-descriptions.openapi.yaml', - 'large-enum.openapi.yaml', - 'multi-tag-operation.openapi.yaml', - 'multi-warning.openapi.yaml', - 'nullable-oneof.openapi.yaml', - 'nullable-optional.openapi.yaml', - 'oneof-anyof-composition.openapi.json', - 'oneof-anyof-composition.openapi.yaml', - 'petstore-minimal.openapi.json', - 'petstore-minimal.openapi.yaml', - 'petstore-rich.openapi.json', - 'petstore-rich.openapi.yaml', - 'recursive-model.openapi.yaml', - 'recursive-oneof.openapi.yaml', - 'reserved-prop-names.openapi.yaml', - 'response-204-no-content.openapi.yaml', - 'response-blob-via-pdf.openapi.yaml', - 'response-default-fallback.openapi.yaml', - 'response-octet-stream.openapi.yaml', - 'response-problem-json.openapi.yaml', - 'response-text-via-text-plain.openapi.yaml', - 'security-schemes.openapi.yaml', - 'single-entry-composition.openapi.yaml', - 'string-formats.openapi.yaml', -]; - -/** - * Fixtures with no snapshot. Every entry but `malformed.yaml` is a gap to - * close; that one's wording follows the YAML parser's own line and column - * output, which the spec asserts by regex. - */ -const UNSNAPSHOTTED: readonly string[] = [ - 'malformed.yaml', - // TODO: these generate or fail deterministically and should be pinned. - 'bench-multi-tag.openapi.yaml', - 'consumer-forms-and-non-json.openapi.yaml', - 'cookie-param.openapi.yaml', - 'duplicate-operation-id.openapi.yaml', - 'duplicate-schema-name.openapi.yaml', - 'errors-typed.openapi.yaml', - 'missing-tag.openapi.yaml', - 'unsupported-trace.openapi.yaml', - 'verb-prefix.openapi.yaml', - 'warning-then-fatal.openapi.yaml', -]; - -const PINNED_FAILURE_FIXTURES: readonly string[] = [ - // One entry per diagnostic the pipeline can end on, so a renamed - // subcode or a reject path rerouted through a different arm shows up - // here rather than in a consumer's generated output. - 'empty-parameter.openapi.yaml', - 'inline-parameter.openapi.yaml', - 'invalid-enum-type.openapi.yaml', - 'invalid-enum-value.openapi.json', - 'unsupported-root.yaml', - 'unsupported-semantic.openapi.yaml', - 'additional-properties-boolean.openapi.yaml', - 'external-ref.openapi.yaml', - 'field-collision.openapi.yaml', - 'deep-nested-allof.openapi.yaml', - 'discriminator-missing-property.openapi.yaml', - 'discriminator-mapping-external-ref.openapi.yaml', - 'unbalanced-path-template.openapi.yaml', - 'anchor-fanout.openapi.yaml', - 'body-multi-content.openapi.yaml', - 'body-content-type-xml.openapi.yaml', - 'body-multipart-nested-object.openapi.yaml', - 'body-multipart-composed-field.openapi.yaml', - 'body-multipart-non-object.openapi.yaml', - 'body-multipart-open-schema.openapi.yaml', - 'body-urlencoded-binary-field.openapi.yaml', - 'body-urlencoded-nested-object.openapi.yaml', -]; - -/** Failure snapshots that need options the default run does not pass. */ -const PARAMETERISED_FAILURES: readonly FailureSnapshot[] = [ - // The mapped-type validator refuses a schema the spec does not declare. - { - fixture: 'petstore-rich.openapi.yaml', - snapshot: 'petstore-rich.openapi.yaml.invalid-mapped-type.failure.json', - options: { - mappedTypes: [{ schema: 'MissingSchema', import: '@demo/x', type: 'Missing' }], - }, - }, -]; - -const FAILURE_SNAPSHOTS: readonly FailureSnapshot[] = [ - ...PINNED_FAILURE_FIXTURES.map(fixture => ({ - fixture, - snapshot: `${fixture}.failure.json`, - })), - ...PARAMETERISED_FAILURES, -]; - /** - * Fails when a fixture on disk is in none of the three sets, so a new + * Fails when a fixture on disk appears in none of the three sets, so a new * fixture must be classified rather than silently ignored. */ function assertEveryFixtureIsClassified(): void { const classified = new Set([ - ...SUCCESS_FIXTURES, + ...SUCCESS_FIXTURES.map(entry => entry.fixture), + ...FAILURE_FIXTURES.map(entry => entry.fixture), ...UNSNAPSHOTTED, - ...FAILURE_SNAPSHOTS.map(entry => entry.fixture), ]); const unclassified = fs .readdirSync(fixturesDir) @@ -176,8 +57,8 @@ function assertEveryFixtureIsClassified(): void { if (unclassified.length > 0) { console.error( - `regen-snapshots: ${unclassified.length} fixture(s) are in none of ` + - 'SUCCESS_FIXTURES, FAILURE_SNAPSHOTS or UNSNAPSHOTTED:\n' + + `regen-snapshots: ${unclassified.length} fixture(s) appear in none of ` + + 'SUCCESS_FIXTURES, FAILURE_FIXTURES or UNSNAPSHOTTED:\n' + unclassified.map(name => ` ${name}`).join('\n'), ); process.exit(1); @@ -294,14 +175,14 @@ function run( assertEveryFixtureIsClassified(); let staticTemplatesWritten = false; -for (const fixture of SUCCESS_FIXTURES) { +for (const { fixture } of SUCCESS_FIXTURES) { let result; try { result = await run(fixture); } catch (error) { console.error( `FAIL: ${fixture} was expected to generate but failed with ` + - `${asGenerateError(error, fixture).code}. Add it to FAILURE_SNAPSHOTS, or fix the fixture.`, + `${asGenerateError(error, fixture).code}. Add it to FAILURE_FIXTURES, or fix the fixture.`, ); process.exitCode = 1; continue; @@ -313,7 +194,7 @@ for (const fixture of SUCCESS_FIXTURES) { writeSuccessSnapshot(fixture, result); } -for (const { fixture, snapshot, options } of FAILURE_SNAPSHOTS) { +for (const { fixture, snapshot, options } of FAILURE_FIXTURES) { try { await run(fixture, options); console.warn(`SKIP: ${fixture} (${snapshot}) succeeded — failure snapshot not regenerated`); From 0999e2f2abfcf050e0b62c45d7935a31ac6177ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andrzej=20=C5=9Awi=C4=99tek?= Date: Tue, 8 Sep 2026 18:02:52 +0200 Subject: [PATCH 05/11] Typed the published runtime and gated it in CI --- .github/workflows/CI.yml | 2 + __test__/browser.spec.ts | 3 +- __test__/generate.spec.ts | 29 ++++-- __test__/tsconfig.json | 22 ++++- bin/lib/parse.js | 194 ++++++++++++++++++++++++++++---------- bin/openapi-ng.js | 47 ++++++--- index.d.ts | 22 +++-- lib/browser.js | 56 +++++++++-- lib/config.js | 13 ++- lib/diagnostic.js | 45 +++++++++ lib/fetch-input.js | 102 ++++++++++++++++++-- lib/generate-error.js | 11 ++- lib/index.js | 9 ++ lib/wrapper-core.js | 138 +++++++++++++++++++++------ package.json | 6 +- scripts/patch-types.ts | 32 +++++++ tsconfig.runtime.json | 27 ++++++ 17 files changed, 619 insertions(+), 139 deletions(-) create mode 100644 lib/diagnostic.js create mode 100644 tsconfig.runtime.json diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 5b89308..7d2d81c 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -49,6 +49,8 @@ jobs: run: bun run lint - name: Typecheck run: bun run typecheck + - name: Snapshot fixture coverage + run: bun run regen-snapshots - name: Cargo fmt run: cargo fmt -- --check - name: Clippy diff --git a/__test__/browser.spec.ts b/__test__/browser.spec.ts index 8d22bbc..edc83ba 100644 --- a/__test__/browser.spec.ts +++ b/__test__/browser.spec.ts @@ -3,6 +3,7 @@ import fs from 'node:fs'; import { createRequire } from 'node:module'; import path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; +import type { GenerateOptions } from '../index.js'; import { generate as nativeGenerate } from '../lib/index.js'; @@ -44,7 +45,7 @@ const petstoreOptions = { inputContents: petstore, displayPath: 'petstore-minimal.openapi.yaml', emit: ['models', 'angular'], -}; +} satisfies GenerateOptions; wasiTest( 'browser generate through the WASI binding matches the native output', diff --git a/__test__/generate.spec.ts b/__test__/generate.spec.ts index 759d0b8..9bddb81 100644 --- a/__test__/generate.spec.ts +++ b/__test__/generate.spec.ts @@ -141,6 +141,15 @@ function getArtifact(result: { artifacts: Artifact[] }, targetPath: string) { return result.artifacts.find(artifact => artifact.path === targetPath); } +/** Fails naming `targetPath` when the result does not carry it. */ +function requireArtifact(result: { artifacts: Artifact[] }, targetPath: string): Artifact { + const artifact = getArtifact(result, targetPath); + if (artifact === undefined) { + throw new Error(`expected the result to carry ${targetPath}`); + } + return artifact; +} + function assertRestHelpers(t: ExecutionContext, result: { artifacts: Artifact[] }) { const restModel = getArtifact(result, 'rest.model.ts'); const restUtil = getArtifact(result, 'rest.util.ts'); @@ -613,10 +622,10 @@ test('generate returns artifact contents and writes the same bytes to disk', asy } // Files were still written to disk. t.true(fs.existsSync(path.join(outputPath, 'model.generated.ts'))); - const modelArtifact = getArtifact(result, 'model.generated.ts'); + const modelArtifact = requireArtifact(result, 'model.generated.ts'); t.is( fs.readFileSync(path.join(outputPath, 'model.generated.ts'), 'utf8'), - modelArtifact?.contents, + modelArtifact.contents, ); }); }); @@ -1077,7 +1086,7 @@ test('generate maps a targeted schema to an imported external type without chang ], }); - const modelArtifact = getArtifact(result, 'model.generated.ts'); + const modelArtifact = requireArtifact(result, 'model.generated.ts'); t.truthy(modelArtifact); const serviceArtifact = getArtifact(result, 'rest/pet.rest.generated.ts'); @@ -1114,7 +1123,7 @@ test('generate encodes oneOf/anyOf composition as focused public contract fragme [], ); - const modelArtifact = getArtifact(result, 'model.generated.ts'); + const modelArtifact = requireArtifact(result, 'model.generated.ts'); const serviceArtifact = getArtifact( result, 'rest/adoption-request.rest.generated.ts', @@ -1162,7 +1171,7 @@ test('generate keeps mapped-type assertions explicit alongside composition contr ], }); - const modelArtifact = getArtifact(result, 'model.generated.ts'); + const modelArtifact = requireArtifact(result, 'model.generated.ts'); t.truthy(modelArtifact); t.true( @@ -1196,7 +1205,7 @@ test('generate emits a re-export for mapped types whose binding name equals the ], }); - const modelArtifact = getArtifact(result, 'model.generated.ts'); + const modelArtifact = requireArtifact(result, 'model.generated.ts'); t.truthy(modelArtifact); t.true( modelArtifact?.contents?.includes( @@ -1229,7 +1238,7 @@ test('generate emits a bare re-export when schema name equals imported type name ], }); - const modelArtifact = getArtifact(result, 'model.generated.ts'); + const modelArtifact = requireArtifact(result, 'model.generated.ts'); t.truthy(modelArtifact); t.true( modelArtifact?.contents?.includes( @@ -1258,7 +1267,7 @@ test('generate encodes allOf composition as an intersection contract with nullab [], ); - const modelArtifact = getArtifact(result, 'model.generated.ts'); + const modelArtifact = requireArtifact(result, 'model.generated.ts'); const serviceArtifact = getArtifact(result, 'rest/adopter.rest.generated.ts'); t.truthy(modelArtifact); @@ -1298,7 +1307,7 @@ test('generate collapses single-entry oneOf/anyOf/allOf wrappers instead of emit t.deepEqual(result.diagnostics, []); - const modelArtifact = getArtifact(result, 'model.generated.ts'); + const modelArtifact = requireArtifact(result, 'model.generated.ts'); t.truthy(modelArtifact); t.true(modelArtifact?.contents?.includes('export type AnimalView = AnimalBase;')); @@ -1321,7 +1330,7 @@ test('generate emits Record-based contracts for typed additionalProperties objec t.deepEqual(result.diagnostics, []); - const modelArtifact = getArtifact(result, 'model.generated.ts'); + const modelArtifact = requireArtifact(result, 'model.generated.ts'); const serviceArtifact = getArtifact(result, 'rest/pet.rest.generated.ts'); t.truthy(modelArtifact); diff --git a/__test__/tsconfig.json b/__test__/tsconfig.json index 5f6ab46..edd9d2f 100644 --- a/__test__/tsconfig.json +++ b/__test__/tsconfig.json @@ -2,10 +2,22 @@ "extends": "../tsconfig.json", "compilerOptions": { "module": "es2022", - "moduleResolution": "node", - "outDir": "lib", - "types": ["node"] + "moduleResolution": "bundler", + "types": [ + "node" + ], + "allowJs": true, + "checkJs": false, + "allowImportingTsExtensions": true, + "noEmit": true, + "rootDir": ".." }, - "include": ["."], - "exclude": ["lib"] + "include": [ + "." + ], + "exclude": [ + "lib", + "angular-consumer", + "snapshots" + ] } diff --git a/bin/lib/parse.js b/bin/lib/parse.js index f20d147..6b597ce 100644 --- a/bin/lib/parse.js +++ b/bin/lib/parse.js @@ -9,7 +9,55 @@ const fs = require('node:fs'); const path = require('node:path'); +const { field, inputError } = require('../../lib/diagnostic.js'); + +/** @typedef {import('../../index.js').Config} Config */ +/** @typedef {import('../../index.js').EmitTarget} EmitTarget */ +/** @typedef {import('../../index.js').MappedType} MappedType */ + +/** + * What `parseArgs` resolved the command line to. + * + * @typedef {{ kind: 'version' }} ParsedVersion + * @typedef {{ kind: 'help', subcommand: 'generate' | 'init' | null, explicit: boolean }} ParsedHelp + * @typedef {{ kind: 'init', format: string }} ParsedInit + * @typedef {{ + * kind: 'generate', + * inputPath: string | null, + * outputPath: string | null, + * verbose: boolean | null, + * emit: EmitTarget[] | null, + * mappedTypes: MappedType[] | null, + * configPath: string | null, + * naming?: import('../../index.js').NamingConfig | null, + * }} ParsedGenerate + * @typedef {ParsedVersion | ParsedHelp | ParsedInit | ParsedGenerate} ParsedArgs + */ + +/** + * The generate request after the file config and the flags are merged. + * + * @typedef {{ + * inputPath: string | null, + * outputPath: string | null, + * verbose: boolean, + * emit: EmitTarget[], + * mappedTypes: MappedType[] | null, + * responseTypeMapping: import('../../index.js').ResponseTypeMapping[] | null, + * naming: import('../../index.js').NamingConfig | null, + * }} MergedConfig + */ + const VALID_EMIT_TARGETS = Object.freeze(new Set(['models', 'angular'])); + +/** + * @param {string} value + * @returns {value is EmitTarget} + */ +function isEmitTarget(value) { + return VALID_EMIT_TARGETS.has(value); +} +/** @type {readonly EmitTarget[]} */ const DEFAULT_EMIT = Object.freeze(['models', 'angular']); const VALID_INIT_FORMATS = Object.freeze(new Set(['yaml', 'json', 'ts', 'js'])); @@ -19,6 +67,12 @@ const VALID_INIT_FORMATS = Object.freeze(new Set(['yaml', 'json', 'ts', 'js'])); // the config path, leaving the user staring at a config-not-found error // without ever seeing their `--input` argument honoured. Treat any token // starting with `-` (long `--foo` or short `-f`) as a flag, never a value. +/** + * @param {readonly string[]} argv + * @param {number} i Index of the flag itself. + * @param {string} flagName Name printed in the failure. + * @returns {string} + */ function requireValue(argv, i, flagName) { const value = argv[i + 1]; if ( @@ -34,6 +88,13 @@ function requireValue(argv, i, flagName) { // Normalize one user-supplied emit list (CLI comma-string or YAML // array) into a deduped array of recognised targets. Unknown entries // fail fast with a config-file hint. +/** + * Normalises one emit list — a CLI comma-string or a config array — into + * a deduped array of recognised targets. `null` when nothing was given. + * + * @param {unknown} value + * @returns {EmitTarget[] | null} + */ function normalizeEmit(value) { if (value === null || value === undefined) return null; @@ -52,15 +113,22 @@ function normalizeEmit(value) { ); } + /** @type {EmitTarget[]} */ + const targets = []; for (const item of items) { - if (!VALID_EMIT_TARGETS.has(item)) { + if (!isEmitTarget(item)) { throw new Error(`Unknown emit target: '${item}'. Allowed: 'models', 'angular'.`); } + if (!targets.includes(item)) targets.push(item); } - return Array.from(new Set(items)); + return targets; } +/** + * @param {string} value A `` triple or quad. + * @returns {MappedType} + */ function parseMappedType(value) { const source = String(value); // Reject up front: importPath segments may not contain ':' under the @@ -68,7 +136,15 @@ function parseMappedType(value) { // file when import paths contain colons (e.g. Windows-style absolute // paths like C:\foo, or :: namespace separators). const parts = source.split(':'); - if (parts.length < 3 || parts.length > 4 || parts.some(part => part.length === 0)) { + const [schema, importPath, typeName, alias] = parts; + if ( + parts.length < 3 || + parts.length > 4 || + schema === undefined || + importPath === undefined || + typeName === undefined || + parts.some(part => part.length === 0) + ) { throw new Error( `Invalid --mapped-type value: ${value}. Expected . ` + `For import paths containing ':' (e.g., Windows absolute paths), use the mappedTypes: ` + @@ -76,8 +152,6 @@ function parseMappedType(value) { ); } - const [schema, importPath, typeName, alias] = parts; - return { schema, import: importPath, @@ -102,6 +176,12 @@ const CONFIG_FILENAMES = Object.freeze([ '.openapi-ng.json', ]); +/** + * Walks up from `startDir` for the first recognised config file. + * + * @param {string} startDir + * @returns {string | null} + */ function discoverConfigPath(startDir) { let dir = path.resolve(startDir); let prev; @@ -120,6 +200,12 @@ function discoverConfigPath(startDir) { const JS_EXTENSIONS = new Set(['.js', '.mjs', '.cjs', '.ts', '.mts', '.cts']); +/** + * Loads a config file, dispatching on its extension. + * + * @param {string} configPath + * @returns {Promise} + */ async function loadConfigFile(configPath) { const ext = path.extname(configPath).toLowerCase(); @@ -133,9 +219,7 @@ async function loadConfigFile(configPath) { // formatter prints the same "Config file not found:" line YAML/JSON // produces today. if (!fs.existsSync(absPath)) { - const e = new Error(`Config file not found: ${configPath}`); - e.code = 'E_INPUT_INVALID'; - throw e; + throw inputError(`Config file not found: ${configPath}`); } let mod; @@ -148,29 +232,21 @@ async function loadConfigFile(configPath) { // of the generic "Failed to load" wrap so users know the fix // (upgrade Node, switch to .js, or pass --experimental-strip-types). const isTs = ext === '.ts' || ext === '.mts' || ext === '.cts'; - if (isTs && err?.code === 'ERR_UNKNOWN_FILE_EXTENSION') { - const e = new Error( + if (isTs && field(err, 'code') === 'ERR_UNKNOWN_FILE_EXTENSION') { + throw inputError( `TypeScript config files require Node 22.6+ with --experimental-strip-types, ` + `or Node 23.6+ (flag enabled by default). ` + `Alternatively, use a .js/.mjs config.`, ); - e.code = 'E_INPUT_INVALID'; - throw e; } - const e = new Error( - `Failed to load config file ${configPath}: ${err?.message ?? err}`, - ); - e.code = 'E_INPUT_INVALID'; - throw e; + throw inputError(`Failed to load config file ${configPath}: ${field(err, 'message') ?? err}`); } if (!('default' in mod) || mod.default === undefined) { - const e = new Error( + throw inputError( `Config file ${configPath} has no default export. ` + `Use \`export default { ... }\` or \`module.exports = { ... }\`.`, ); - e.code = 'E_INPUT_INVALID'; - throw e; } let value = mod.default; @@ -180,12 +256,10 @@ async function loadConfigFile(configPath) { } if (value === null || typeof value !== 'object' || Array.isArray(value)) { - const e = new Error( + throw inputError( `Config file ${configPath} default export must be an object or function returning one; ` + `got ${value === null ? 'null' : Array.isArray(value) ? 'array' : typeof value}.`, ); - e.code = 'E_INPUT_INVALID'; - throw e; } return value; @@ -201,16 +275,10 @@ async function loadConfigFile(configPath) { try { contents = fs.readFileSync(configPath, 'utf8'); } catch (err) { - if (err && err.code === 'ENOENT') { - const e = new Error(`Config file not found: ${configPath}`); - e.code = 'E_INPUT_INVALID'; - throw e; + if (field(err, 'code') === 'ENOENT') { + throw inputError(`Config file not found: ${configPath}`); } - const e = new Error( - `Failed to read config file ${configPath}: ${err?.message ?? err}`, - ); - e.code = 'E_INPUT_INVALID'; - throw e; + throw inputError(`Failed to read config file ${configPath}: ${field(err, 'message') ?? err}`); } try { @@ -221,14 +289,14 @@ async function loadConfigFile(configPath) { const YAML = require('yaml'); return YAML.parse(contents) ?? {}; } catch (err) { - const e = new Error( - `Failed to parse config file ${configPath}: ${err?.message ?? err}`, - ); - e.code = 'E_INPUT_INVALID'; - throw e; + throw inputError(`Failed to parse config file ${configPath}: ${field(err, 'message') ?? err}`); } } +/** + * @param {unknown} items + * @returns {MappedType[] | null} + */ function normalizeMappedTypes(items) { if (!Array.isArray(items)) return null; return items.map(item => ({ @@ -239,6 +307,10 @@ function normalizeMappedTypes(items) { })); } +/** + * @param {unknown} items + * @returns {import('../../index.js').ResponseTypeMapping[] | null} + */ function normalizeResponseTypeMapping(items) { if (!Array.isArray(items)) return null; return items.map(item => ({ @@ -247,14 +319,17 @@ function normalizeResponseTypeMapping(items) { })); } +/** + * Accepts a naming block from a config file, refusing a `parse` that is + * not a real `RegExp` — which is every value YAML or JSON can carry. + * + * @param {unknown} naming + * @returns {import('../../index.js').NamingConfig | null} + */ function normalizeNamingFromFile(naming) { if (naming === undefined || naming === null) return null; if (typeof naming !== 'object' || Array.isArray(naming)) { - const e = new Error( - `Invalid naming config: expected an object with optional 'methodName' and 'group' keys.`, - ); - e.code = 'E_INPUT_INVALID'; - throw e; + throw inputError(`Invalid naming config: expected an object with optional 'methodName' and 'group' keys.`); } // `parse` must be a JavaScript RegExp. JS/TS configs deliver one // directly; YAML/JSON cannot encode RegExp, so any `parse:` value @@ -262,7 +337,7 @@ function normalizeNamingFromFile(naming) { // This keeps the "no parse in YAML/JSON" safety property without // tracking the source format through the call chain. for (const key of ['methodName', 'group']) { - const value = naming[key]; + const value = field(naming, key); if (value === undefined) continue; const items = Array.isArray(value) ? value : [value]; for (const item of items) { @@ -272,21 +347,35 @@ function normalizeNamingFromFile(naming) { item.parse !== undefined && !(item.parse instanceof RegExp) ) { - const e = new Error( + throw inputError( `naming.${key}: 'parse' must be a JavaScript RegExp. ` + `YAML/JSON configs cannot encode RegExp — use an openapi-ng.config.ts ` + `(or .js/.mjs) file when you need 'parse' rules.`, ); - e.code = 'E_INPUT_INVALID'; - throw e; } } } return naming; } +/** + * Merges the file config under the CLI flags, which win field by field. + * + * @param {Config} fileConfig + * @param {ParsedGenerate} cliFlags + * @returns {MergedConfig} + */ function mergeConfig(fileConfig, cliFlags) { - const merged = {}; + /** @type {MergedConfig} */ + const merged = { + inputPath: null, + outputPath: null, + verbose: false, + emit: [...DEFAULT_EMIT], + mappedTypes: null, + responseTypeMapping: null, + naming: null, + }; merged.inputPath = cliFlags.inputPath ?? fileConfig.input ?? null; merged.outputPath = cliFlags.outputPath ?? fileConfig.output ?? null; @@ -308,6 +397,10 @@ function mergeConfig(fileConfig, cliFlags) { return merged; } +/** + * @param {readonly string[]} argv Arguments after the executable name. + * @returns {ParsedArgs} + */ function parseArgs(argv) { let configPath = null; @@ -320,9 +413,10 @@ function parseArgs(argv) { } // Extract global --config/-c before command parsing + /** @type {string[]} */ const filteredArgv = []; for (let i = 0; i < argv.length; i++) { - const token = argv[i]; + const token = argv[i] ?? ''; if (token === '--config' || token === '-c') { configPath = requireValue(argv, i, '--config'); i += 1; @@ -349,7 +443,7 @@ function parseArgs(argv) { if (command === 'init') { let format = 'yaml'; for (let i = 0; i < rest.length; i += 1) { - const token = rest[i]; + const token = rest[i] ?? ''; if (token === '--help' || token === '-h') { return { kind: 'help', subcommand: 'init', explicit: true }; } @@ -380,7 +474,7 @@ function parseArgs(argv) { const mappedTypes = []; for (let index = 0; index < rest.length; index += 1) { - const token = rest[index]; + const token = rest[index] ?? ''; // Per-subcommand help short-circuit. Recognised anywhere in the // argument list so users can append `--help` to a half-finished // command without erasing the rest first. diff --git a/bin/openapi-ng.js b/bin/openapi-ng.js index 669a292..3a48df7 100755 --- a/bin/openapi-ng.js +++ b/bin/openapi-ng.js @@ -12,6 +12,7 @@ function loadLibrary() { const fs = require('node:fs'); const path = require('node:path'); +const { field } = require('../lib/diagnostic.js'); const { CONFIG_FILENAMES, discoverConfigPath, @@ -22,7 +23,9 @@ const { // Minimal ANSI styler — emit colour only when stdout is a TTY and NO_COLOR is unset const USE_COLOR = process.stdout.isTTY === true && !process.env.NO_COLOR; -const wrap = code => (USE_COLOR ? s => `\x1b[${code}m${s}\x1b[0m` : s => String(s)); +/** @param {number} code @returns {(text: unknown) => string} */ +const wrap = code => + USE_COLOR ? text => `\x1b[${code}m${text}\x1b[0m` : text => String(text); const c = { bold: wrap(1), dim: wrap(2), @@ -108,6 +111,11 @@ function printInitUsage() { process.stdout.write('\n'); } +/** + * @param {import('../index.js').GenerateResult} result + * @param {boolean} verbose Include the warning list. + * @returns {string} + */ function formatSuccess(result, verbose) { const { summary, artifacts, diagnostics } = result; const count = artifacts.length; @@ -120,7 +128,7 @@ function formatSuccess(result, verbose) { lines.push(` ${c.cyan(artifact.path)}`); } if (verbose) { - const warnings = diagnostics.filter(d => d.severity === 'warning'); + const warnings = diagnostics.filter(entry => entry.severity === 'warning'); if (warnings.length > 0) { lines.push('', c.bold(c.yellow(`Warnings (${warnings.length}):`))); for (const w of warnings) { @@ -210,6 +218,7 @@ export default { }; `; +/** @param {string} format One of `yaml`, `json`, `ts`, `js`. */ function runInit(format) { const cwd = process.cwd(); const existing = CONFIG_FILENAMES.find(name => fs.existsSync(path.join(cwd, name))); @@ -252,6 +261,7 @@ function runInit(format) { // ── Main ──────────────────────────────────────────────────────────────────── +/** @param {readonly string[]} argv */ async function main(argv) { let parsed; @@ -338,31 +348,46 @@ async function main(argv) { } } +/** + * @param {unknown} error + * @returns {string} + */ function formatParseFailure(error) { // Honour error.code when set (e.g. loadConfigFile tags ENOENT and // YAML/JSON parse failures with E_INPUT_INVALID — those are user // input problems, not CLI option-parsing problems). Fall back to // E_INVALID_OPTION only when no code is set, which is the // parseArgs-raised case for genuinely bad flags. - const code = typeof error?.code === 'string' ? error.code : 'E_INVALID_OPTION'; - const message = typeof error?.message === 'string' ? error.message : String(error); + const declared = field(error, 'code'); + const detail = field(error, 'message'); + const code = typeof declared === 'string' ? declared : 'E_INVALID_OPTION'; + const message = typeof detail === 'string' ? detail : String(error); return `${c.bold(c.red('Error'))} ${c.red(`[${code}]`)}\n ${message}`; } +/** + * @param {unknown} error + * @returns {string} + */ function formatFailure(error) { - if (typeof error?.code === 'string') { + const code = field(error, 'code'); + const message = field(error, 'message'); + + if (typeof code === 'string') { const lines = [ - `${c.bold(c.red('Error'))} ${c.red(`[${error.code}]`)}`, - ` ${error.message}`, + `${c.bold(c.red('Error'))} ${c.red(`[${code}]`)}`, + ` ${message}`, ]; - const errorPath = error.path ?? error.warnings?.[0]?.path; + const warnings = field(error, 'warnings'); + const firstWarning = Array.isArray(warnings) ? warnings[0] : undefined; + const errorPath = field(error, 'path') ?? field(firstWarning, 'path'); if (errorPath) { lines.push(` ${c.dim(`in: ${errorPath}`)}`); } return lines.join('\n'); } - if (typeof error?.message === 'string') { - return `${c.bold(c.red('Error'))} ${c.red('[E_UNEXPECTED]')}\n ${error.message}`; + if (typeof message === 'string') { + return `${c.bold(c.red('Error'))} ${c.red('[E_UNEXPECTED]')}\n ${message}`; } return `${c.bold(c.red('Error'))} ${c.red('[E_UNEXPECTED]')}\n ${String(error)}`; } @@ -373,6 +398,6 @@ function formatFailure(error) { // Rejection]" multi-line stack dump. Today the inner paths all catch // their own failures; this is the last-line guard. main(process.argv.slice(2)).catch(err => { - process.stderr.write(`openapi-ng: ${err?.message ?? err}\n`); + process.stderr.write(`openapi-ng: ${field(err, 'message') ?? err}\n`); process.exitCode = 1; }); diff --git a/index.d.ts b/index.d.ts index 3e84296..4cd9535 100644 --- a/index.d.ts +++ b/index.d.ts @@ -115,10 +115,11 @@ export interface GeneratorDiagnostic { * Explicit decoder selection. Skips both extension-based detection and * the JSON-then-YAML sniff fallback. Honoured only with `input_contents`. */ -export declare const enum InputFormat { - Json = 'json', - Yaml = 'yaml' -} +export type InputFormat = 'json' | 'yaml'; +export declare const InputFormat: { + readonly Json: 'json'; + readonly Yaml: 'yaml'; +}; /** * One caller-declared mapped type: replace the generated declaration for @@ -180,12 +181,13 @@ export interface NamingValue { } /** How a response body is decoded, named as the JS runtime names it. */ -export declare const enum ResponseType { - Json = 'json', - Blob = 'blob', - Text = 'text', - ArrayBuffer = 'arrayBuffer' -} +export type ResponseType = 'json' | 'blob' | 'text' | 'arrayBuffer'; +export declare const ResponseType: { + readonly Json: 'json'; + readonly Blob: 'blob'; + readonly Text: 'text'; + readonly ArrayBuffer: 'arrayBuffer'; +}; /** * Overrides the response kind decoded for one content type. diff --git a/lib/browser.js b/lib/browser.js index b8b97eb..f8d4c3e 100644 --- a/lib/browser.js +++ b/lib/browser.js @@ -9,9 +9,30 @@ const { GenerateError } = require('./generate-error.js'); const { prepareOptions, unwrapOutcome, upgradeError } = require('./wrapper-core.js'); +const { field } = require('./diagnostic.js'); const WASI_PACKAGE = '@avsystem/openapi-ng-wasm32-wasi'; +/** + * The WebAssembly binding's only export these wrappers use. + * + * @typedef {{ generateNative: (options: unknown) => + * import('./wrapper-core.js').GenerateOutcome }} WasiBinding + */ + +/** @param {unknown} value @returns {value is WasiBinding} */ +function isWasiBinding(value) { + return ( + typeof value === 'object' && + value !== null && + typeof (/** @type {{ generateNative?: unknown }} */ (value).generateNative) === 'function' + ); +} + +/** + * @param {string} message + * @returns {import('../index.js').GenerateError} + */ function invalidOption(message) { return new GenerateError({ code: 'E_INVALID_OPTION', @@ -23,6 +44,10 @@ function invalidOption(message) { // No filesystem and no node:net in the browser: the spec arrives as // `inputContents`, artifacts leave as `result.artifacts`. +/** + * @param {import('../index.js').GenerateOptions} options + * @returns {void} + */ function rejectPathOptions(options) { if (options === null || typeof options !== 'object') return; if (options.inputPath !== undefined) { @@ -37,20 +62,27 @@ function rejectPathOptions(options) { } } +/** @returns {never} */ function unreachableFetch() { throw new Error( 'openapi-ng: URL inputs are rejected before fetch in the browser entry', ); } +/** + * @param {unknown} cause + * @returns {import('../index.js').GenerateError} + */ function unsupportedRuntime(cause) { + const message = field(cause, 'message'); + const reason = message ? String(message) : String(cause); const err = new GenerateError({ code: 'E_UNSUPPORTED_RUNTIME', message: `openapi-ng could not load its WebAssembly binding (${WASI_PACKAGE}). ` + `Install that package next to @avsystem/openapi-ng and serve the page with ` + `Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp. ` + - `Cause: ${cause && cause.message ? cause.message : String(cause)}`, + `Cause: ${reason}`, warnings: [], }); err.cause = cause; @@ -59,18 +91,24 @@ function unsupportedRuntime(cause) { // `loadBinding` resolves to the WASI module namespace (ESM loader) or a // CommonJS `module.exports` under `default`; both expose `generateNative`. +/** + * @param {() => Promise} loadBinding Resolves to the WASI module + * namespace, or to a CommonJS `module.exports` under `default`. + * @returns {(options: import('../index.js').GenerateOptions) => + * Promise} + */ function createGenerate(loadBinding) { + /** @type {Promise | undefined} */ let bindingPromise; const load = () => { if (!bindingPromise) { bindingPromise = loadBinding() .then(mod => { - const binding = - mod && typeof mod.generateNative === 'function' ? mod : mod && mod.default; - if (!binding || typeof binding.generateNative !== 'function') { - // Plain Error: the catch below turns it into E_UNSUPPORTED_RUNTIME - // and clears the cached promise, so a bad shape and a failed load - // surface identically. + const namespace = /** @type {{ default?: unknown }} */ (mod); + const binding = isWasiBinding(mod) ? mod : namespace?.default; + if (!isWasiBinding(binding)) { + // A plain Error, so the catch below turns a bad shape into the + // same E_UNSUPPORTED_RUNTIME a failed load produces. throw new Error('module does not export generateNative'); } return binding; @@ -83,6 +121,10 @@ function createGenerate(loadBinding) { return bindingPromise; }; + /** + * @param {import('../index.js').GenerateOptions} options + * @returns {Promise} + */ return async function generate(options) { rejectPathOptions(options); const prepared = await prepareOptions(options, unreachableFetch); diff --git a/lib/config.js b/lib/config.js index 7c86afe..2c87681 100644 --- a/lib/config.js +++ b/lib/config.js @@ -1,8 +1,15 @@ 'use strict'; -// Identity helper so JS/TS configs can opt into TypeScript inference via -// import { defineConfig } from '@avsystem/openapi-ng/config'; -// Returns the argument unchanged. Has no runtime behaviour. +/** + * Returns `config` unchanged, so a JS or TS config file can opt into + * inference: + * + * import { defineConfig } from '@avsystem/openapi-ng/config'; + * + * @template {import('../index.js').Config} T + * @param {T} config + * @returns {T} + */ function defineConfig(config) { return config; } diff --git a/lib/diagnostic.js b/lib/diagnostic.js new file mode 100644 index 0000000..a20b55e --- /dev/null +++ b/lib/diagnostic.js @@ -0,0 +1,45 @@ +'use strict'; + +// Errors that carry a diagnostic code, and reading fields off values that +// may not. +// +// Every entry point catches values it did not create — a rejected dynamic +// import, whatever a `fetch` implementation or a config module threw — and +// reads `name`, `message` or `code` off them. Both halves of that live +// here so the CLI, the wrapper and the fetch path agree. + +/** + * The value at `key`, or `undefined` when `value` holds no properties. + * + * Functions count: a caught value can be one, and it carries a `name`. + * + * @param {unknown} value + * @param {string} key + * @returns {unknown} + */ +function field(value, key) { + if (value === null || (typeof value !== 'object' && typeof value !== 'function')) { + return undefined; + } + return /** @type {{ [key: string]: unknown }} */ (value)[key]; +} + +/** + * An `Error` carrying the code the CLI prints and consumers route on. + * + * @typedef {Error & { code: string }} CodedError + */ + +/** + * An error the caller's input caused, as opposed to a bug. + * + * @param {string} message + * @returns {CodedError} + */ +function inputError(message) { + const error = /** @type {CodedError} */ (new Error(message)); + error.code = 'E_INPUT_INVALID'; + return error; +} + +module.exports = { field, inputError }; diff --git a/lib/fetch-input.js b/lib/fetch-input.js index 313c4f9..4ed80ef 100644 --- a/lib/fetch-input.js +++ b/lib/fetch-input.js @@ -2,6 +2,19 @@ const net = require('node:net'); +const { field, inputError } = require('./diagnostic.js'); + +/** + * A spec fetched over https, and what its headers said about the format. + * + * @typedef {object} FetchedInput + * @property {string} contents + * @property {string | null} contentType + * @property {string} finalUrl URL of the last hop, after any redirects. + * @property {'json' | 'yaml' | null} format `null` when neither the + * content type nor the URL path names one. + */ + const DEFAULT_MAX_BYTES = 16 * 1024 * 1024; const DEFAULT_TIMEOUT_MS = 30_000; const MAX_REDIRECTS = 5; @@ -9,30 +22,41 @@ const MAX_REDIRECTS = 5; // Accept https-or-http; the scheme check itself runs inside fetchInput // where the http case turns into a typed error. This predicate just // decides "is this a URL or a path". +/** + * @param {unknown} value + * @returns {boolean} + */ function isUrl(value) { if (typeof value !== 'string') return false; return /^https?:\/\//i.test(value); } +/** + * @param {string} value + * @returns {boolean} + */ function isHttpsUrl(value) { return /^https:\/\//i.test(value); } -function inputError(message) { - const e = new Error(message); - e.code = 'E_INPUT_INVALID'; - return e; -} - // Tolerate the browser/WASI entry where `process` may not exist. The // surrounding logic (size cap default, timeout default, the new // OPENAPI_NG_ALLOW_PRIVATE_HOSTS opt-out) all assume read-or-fall-back // semantics, so an undefined `process` collapses to the default branch. +/** + * @param {string} name + * @returns {string | undefined} + */ function safeEnv(name) { if (typeof process === 'undefined' || !process.env) return undefined; return process.env[name]; } +/** + * @param {string} name + * @param {number} defaultValue + * @returns {number} + */ function envInt(name, defaultValue) { const raw = safeEnv(name); if (raw === undefined) return defaultValue; @@ -71,10 +95,16 @@ const BLOCKED_HOSTS = (() => { // `node:dns/promises` so a runtime without DNS (e.g. some sandboxes) // can still parse this module — the lookup only fires if a non-IP host // is being resolved AND the SSRF guard is active. +/** @typedef {(host: string, options: { all: true }) => Promise>} DnsLookup */ + +/** @type {DnsLookup | null} */ let _testDnsLookup = null; + +/** @param {DnsLookup | null} impl */ function __setDnsLookupForTest(impl) { _testDnsLookup = impl; } +/** @returns {DnsLookup} */ function _resolveDnsLookup() { if (_testDnsLookup !== null) return _testDnsLookup; // Lazy: only require dns when we actually need to resolve a name. @@ -87,6 +117,13 @@ function _resolveDnsLookup() { // from a public host to 169.254.169.254" loophole — a CNAME or 302 // chain that starts public but lands on metadata would otherwise slip // past a one-shot check at entry. +/** + * Fails when any address `urlStr`'s host resolves to is private, + * loopback or link-local. Re-checked per redirect hop. + * + * @param {string} urlStr + * @returns {Promise} + */ async function assertPublicHost(urlStr) { if (safeEnv('OPENAPI_NG_ALLOW_PRIVATE_HOSTS') === '1') return; const { hostname } = new URL(urlStr); @@ -112,9 +149,13 @@ async function assertPublicHost(urlStr) { // Parse a media type. Returns { type, subtype, suffix } or null. // "application/openapi+yaml; charset=utf-8" -> { type: 'application', // subtype: 'openapi', suffix: 'yaml' }. +/** + * @param {string | null} ct + * @returns {{ type: string, subtype: string, suffix: string | null } | null} + */ function parseMediaType(ct) { if (typeof ct !== 'string' || ct.length === 0) return null; - const bare = ct.split(';', 1)[0].trim().toLowerCase(); + const bare = (ct.split(';', 1)[0] ?? '').trim().toLowerCase(); const slash = bare.indexOf('/'); if (slash === -1) return null; const type = bare.slice(0, slash); @@ -130,6 +171,10 @@ function parseMediaType(ct) { }; } +/** + * @param {string | null} ct + * @returns {'json' | 'yaml' | null} + */ function formatFromContentType(ct) { const mt = parseMediaType(ct); if (mt === null) return null; @@ -141,6 +186,10 @@ function formatFromContentType(ct) { return null; } +/** + * @param {string} urlStr + * @returns {'json' | 'yaml' | null} + */ function formatFromUrlPath(urlStr) { try { const u = new URL(urlStr); @@ -159,14 +208,36 @@ const FOLLOWED_STATUSES = new Set([301, 302, 303, 307, 308]); // through `fetchInput` without surfacing `fetchImpl` as a public option. // The setter mutates a module-level slot consulted by `_resolveFetchImpl`, // which is the new default when `fetchImpl` is not passed explicitly. +/** + * The subset of `fetch` this module calls. Narrower than the global on + * purpose: a test stub only has to accept what is actually passed. + * + * @typedef {( + * url: string, + * init?: { redirect?: RequestRedirect, signal?: AbortSignal }, + * ) => Promise} FetchImpl + */ + +/** @type {FetchImpl | null} */ let _testFetchImpl = null; + +/** @param {FetchImpl | null} impl */ function __setFetchImplForTest(impl) { _testFetchImpl = impl; } +/** @returns {FetchImpl} */ function _resolveFetchImpl() { return _testFetchImpl ?? globalThis.fetch; } +/** + * Fetches a spec over https, following redirects and refusing a private + * host at every hop. + * + * @param {string} url + * @param {{ fetchImpl?: FetchImpl, maxBytes?: number, timeoutMs?: number }} [options] + * @returns {Promise} + */ async function fetchInput(url, options = {}) { const { fetchImpl = _resolveFetchImpl(), @@ -192,11 +263,16 @@ async function fetchInput(url, options = {}) { try { raw = await fetchImpl(currentUrl, { redirect: 'manual', signal }); } catch (err) { - if (err && (err.name === 'TimeoutError' || err.name === 'AbortError')) { + const name = field(err, 'name'); + if (name === 'TimeoutError' || name === 'AbortError') { throw inputError(`Fetch timed out after ${timeoutMs}ms`); } - const cause = err?.cause; - const reason = cause?.code ?? cause?.message ?? err?.message ?? String(err); + const cause = field(err, 'cause'); + const reason = + field(cause, 'code') ?? + field(cause, 'message') ?? + field(err, 'message') ?? + String(err); throw inputError(`Network fetch failed: ${reason}`); } @@ -229,6 +305,12 @@ async function fetchInput(url, options = {}) { break; } + // The loop above either assigns, redirects, or throws; every hop is + // accounted for, so this is unreachable. + if (response === undefined) { + throw inputError('Fetch produced no response'); + } + // Size cap stage 1: Content-Length pre-check. const cl = response.headers.get('content-length'); if (cl !== null) { diff --git a/lib/generate-error.js b/lib/generate-error.js index 19bb5e6..2659d59 100644 --- a/lib/generate-error.js +++ b/lib/generate-error.js @@ -2,7 +2,9 @@ const { marker: MARKER } = require('./error-marker.json'); +/** Thrown by `generate` on a fatal diagnostic. */ class GenerateError extends Error { + /** @param {Partial} [payload] */ constructor(payload) { super(payload?.message ?? 'openapi-ng: generation failed'); this.name = 'GenerateError'; @@ -17,8 +19,15 @@ class GenerateError extends Error { // inside the realm where this module was loaded; the sentinel own- // property survives the realm boundary, so consumers crossing realms // should use `GenerateError.isGenerateError(err)` instead. + /** + * @param {unknown} value + * @returns {value is import('../index.js').GenerateError} + */ static isGenerateError(value) { - return Boolean(value) && typeof value === 'object' && value[MARKER] === true; + if (typeof value !== 'object' || value === null) return false; + // The sentinel is a non-enumerable own property, so reading it needs + // the one cast this file makes at its `unknown` boundary. + return /** @type {{ [key: string]: unknown }} */ (value)[MARKER] === true; } } diff --git a/lib/index.js b/lib/index.js index f7fdfe0..d9b8a6e 100644 --- a/lib/index.js +++ b/lib/index.js @@ -18,11 +18,20 @@ if (process.env.OPENAPI_NG_DISABLE_NATIVE_FOR_TEST === '1') { // reuse them; this file is the Node-specific seam that binds them to // `../native.js`. +/** + * The native binding, whose only export these wrappers use. + * + * @type {{ generateNative: (options: unknown) => import('./wrapper-core.js').GenerateOutcome }} + */ const native = require('../native.js'); const { GenerateError } = require('./generate-error.js'); const { fetchInput } = require('./fetch-input.js'); const { prepareOptions, unwrapOutcome, upgradeError } = require('./wrapper-core.js'); +/** + * @param {import('../index.js').GenerateOptions} options + * @returns {Promise} + */ async function generate(options) { const prepared = await prepareOptions(options, fetchInput); let outcome; diff --git a/lib/wrapper-core.js b/lib/wrapper-core.js index cfa1e4a..8a4b256 100644 --- a/lib/wrapper-core.js +++ b/lib/wrapper-core.js @@ -6,12 +6,51 @@ // thin and to make the pre-NAPI option surface independently testable. const { GenerateError } = require('./generate-error.js'); +const { field } = require('./diagnostic.js'); + +/** + * What the native export returns: exactly one field is set. Not part of + * the published surface, so it is declared here rather than imported. + * + * @typedef {object} GenerateOutcome + * @property {import('../index.js').GenerateResult} [result] + * @property {import('../index.js').GenerateErrorPayload} [error] + */ + +/** @typedef {import('../index.js').GenerateOptions} GenerateOptions */ + +/** + * A chain item as a caller may write it. + * + * Wider than the published `NamingRule` in one place: `parse` also + * accepts an already-split `{ source, flags }`, which is what reaches + * here when a caller unpacked the RegExp itself. + * + * @typedef {object} NamingEntryInput + * @property {string} [from] + * @property {RegExp | { source: string, flags: string }} [parse] + * @property {string} [format] + * @property {import('../index.js').Case} [case] + */ + +/** @typedef {string | NamingEntryInput} NamingItemInput */ + +/** + * Options after `prepareOptions`. `naming` is the lowered boundary shape + * once a caller supplied one; the declared shape stays in the union + * because an absent key keeps its input type. + * + * @typedef {Omit & { + * naming?: import('../index.js').NamingOptions | import('../index.js').NamingConfig + * }} PreparedOptions + */ // Frozen allow-list of recognised option keys. The native binding silently // ignores anything else; surfacing unknown keys here means typos // (`inputpath:` → undefined) fail fast with a typed `GenerateError` // instead of producing confusing downstream diagnostics. Keep in sync // with `GenerateOptions` in `src/bindings.rs`. +/** @type {ReadonlySet} */ const GENERATE_OPTION_KEYS = Object.freeze( new Set([ 'inputPath', @@ -31,11 +70,13 @@ const GENERATE_OPTION_KEYS = Object.freeze( // programmatic API are independent boundaries, each performing its own // entry-level validation. Both reflect the same truth declared in // `EmitTarget` (see `index.d.ts`); keep them in sync. +/** @type {ReadonlySet} */ const VALID_EMIT = Object.freeze(new Set(['models', 'angular'])); // The five case transformations supported by naming rules (see // `docs/naming-spec.md`). Mirrored on the Rust side; both must accept // the same lowercase strings. +/** @type {ReadonlySet} */ const VALID_CASES = Object.freeze( new Set(['camel', 'pascal', 'snake', 'kebab', 'constant']), ); @@ -45,10 +86,19 @@ const VALID_CASES = Object.freeze( // inputPath })` without an explicit `emit` list gets the same artifacts as // `openapi-ng generate -i ...`. Frozen so a consumer mutating the // returned options object can't corrupt the next call's default. +/** @type {readonly import('../index.js').EmitTarget[]} */ const DEFAULT_EMIT = Object.freeze(['models', 'angular']); -// Lower one chain item (either a string or a Rule-shaped object) into -// the `{ string }` or `{ rule }` shape the Rust side expects. +/** + * Lowers one chain item into the exclusive `{ string }` or `{ rule }` + * shape the boundary carries. + * + * The runtime guards stay for a JS caller who ignores the declared type. + * + * @param {NamingItemInput} entry + * @param {string} path Config path, for the failure message. + * @returns {import('../index.js').NamingChainItem} + */ function normalizeNamingEntry(entry, path) { if (typeof entry === 'string') { return { string: entry }; @@ -98,9 +148,14 @@ function normalizeNamingEntry(entry, path) { }; } -// Lower the top-level `methodName` or `group` value into the NamingValue -// shape: { string } | { rule } | { chain: [...] }. Returns undefined -// when the input is undefined (keeps the option absent on the Rust side). +/** + * Lowers one naming key's value. Returns `undefined` for an absent value, + * which keeps the option absent at the boundary. + * + * @param {NamingItemInput | NamingItemInput[] | undefined} value + * @param {string} key + * @returns {import('../index.js').NamingValue | undefined} + */ function normalizeNamingValue(value, key) { if (value === undefined) return undefined; if (typeof value === 'string') { @@ -111,13 +166,17 @@ function normalizeNamingValue(value, key) { chain: value.map((item, i) => normalizeNamingEntry(item, `${key}[${i}]`)), }; } - // Single rule object: lower it as a chain item, then promote to a - // top-level `{ rule }` value. + // A single rule lowers as a chain item, then promotes to `{ rule }`. const entry = normalizeNamingEntry(value, key); return { rule: entry.rule }; } -// Turn the native `{ result, error }` union into return-or-throw. +/** + * Turns the native union into return-or-throw. + * + * @param {GenerateOutcome | null | undefined} outcome + * @returns {import('../index.js').GenerateResult} + */ function unwrapOutcome(outcome) { if (outcome && outcome.error) { throw new GenerateError(outcome.error); @@ -132,19 +191,34 @@ function unwrapOutcome(outcome) { return outcome.result; } -// Catch-all for anything still thrown across the binding boundary -// (loader failures, argument marshalling). Typed errors pass through. +/** + * Wraps anything else thrown across the binding boundary — a loader + * failure, an argument-marshalling error. A typed failure passes through. + * + * @param {unknown} err + * @returns {import('../index.js').GenerateError} + */ function upgradeError(err) { if (GenerateError.isGenerateError(err)) return err; + const message = field(err, 'message'); const upgraded = new GenerateError({ code: 'E_UNEXPECTED', - message: err && err.message ? err.message : String(err), + message: message ? String(message) : String(err), warnings: [], }); upgraded.cause = err; return upgraded; } +/** + * Fails with a typed `GenerateError` on an unknown key or a wrong shape. + * + * NAPI would reject a wrong type too, but with a generic "Failed to + * convert"; failing here names the option and, where it helps, the value. + * + * @param {GenerateOptions} options + * @returns {void} + */ function validateGenerateOptions(options) { if (options === null || typeof options !== 'object') { return; @@ -279,9 +353,13 @@ function validateGenerateOptions(options) { } } -// Compute the lowered `naming` value the binding expects, without -// touching the caller's object. Returns undefined when `options.naming` -// is absent, so the caller can omit the key entirely. +/** + * Lowers `options.naming` without touching the caller's object. Returns + * `undefined` when the key is absent, so it can stay absent. + * + * @param {GenerateOptions | null | undefined} options + * @returns {import('../index.js').NamingOptions | undefined} + */ function normalizeNaming(options) { if (options == null || typeof options !== 'object') return undefined; if (options.naming === undefined) return undefined; @@ -291,28 +369,28 @@ function normalizeNaming(options) { }; } -// Normalise + validate options for both entries. Applies the CLI-parity -// emit default, transparently fetches https URLs into inputContents, and -// runs the shape validator. `fetchInputFn` is injected so the browser -// entry can pass its own fetch implementation if needed; the Node entry -// passes `lib/fetch-input.js`'s `fetchInput`. +/** + * Normalises and validates the options both entries pass to the binding: + * applies the CLI-parity `emit` default, rewrites an `https` input into + * `inputContents`, and runs the shape validator. + * + * @param {GenerateOptions} options + * @param {(url: string) => Promise} fetchInputFn + * Injected so the browser entry can refuse the fetch outright. + * @returns {Promise} + */ async function prepareOptions(options, fetchInputFn) { - // Apply the CLI-parity default BEFORE validation so the validator and - // the binding boundary both see a populated emit set. If `options` is - // anything other than an object, leave it untouched and let - // `validateGenerateOptions` (a no-op for non-objects) defer to the - // binding's own type rejection. + // The default lands before validation, so the validator and the + // boundary both see a populated emit set. A non-object passes through + // for the binding's own type rejection. const normalized = options != null && typeof options === 'object' && options.emit === undefined ? { ...options, emit: [...DEFAULT_EMIT] } : options; - // URL branch: if inputPath is a URL string, fetch and rewrite into - // the inputContents form before validation. The wrapper-side - // validator treats inputContents as the operative input from here on. - // The http:// case is rejected inside fetchInput's scheme check — - // duplicating the check here would just split the error message - // surface across two layers. + // An `https` input is fetched and rewritten into `inputContents` + // before validation, which treats that as the operative input. The + // `http` case is refused inside `fetchInputFn`. if ( normalized != null && typeof normalized === 'object' && diff --git a/package.json b/package.json index 53a1388..6791e7a 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "lib/config.js", "lib/fetch-input.js", "lib/generate-error.js", + "lib/diagnostic.js", "lib/wrapper-core.js", "lib/error-marker.json", "index.d.ts", @@ -75,7 +76,10 @@ "format:toml": "taplo format", "format:rs": "cargo fmt", "lint": "oxlint", - "typecheck": "tsc -p scripts/tsconfig.json", + "typecheck": "run-p typecheck:scripts typecheck:runtime typecheck:test", + "typecheck:scripts": "tsc -p scripts/tsconfig.json", + "typecheck:runtime": "tsc -p tsconfig.runtime.json", + "typecheck:test": "tsc -p __test__/tsconfig.json", "prepublishOnly": "bun scripts/check-version-not-placeholder.ts && napi prepublish -t npm", "regen-snapshots": "bun scripts/regen-snapshots.ts", "test": "ava", diff --git a/scripts/patch-types.ts b/scripts/patch-types.ts index f9ea988..ea35b88 100644 --- a/scripts/patch-types.ts +++ b/scripts/patch-types.ts @@ -119,6 +119,9 @@ const NARROWED_DIAGNOSTIC = [ ] as const; const EMIT_TARGET_UNION = "export type EmitTarget = 'models' | 'angular';"; +const INPUT_FORMAT_UNION = "export type InputFormat = 'json' | 'yaml';"; +const RESPONSE_TYPE_UNION = + "export type ResponseType = 'json' | 'blob' | 'text' | 'arrayBuffer';"; // `[^*]|\*(?!/)` rather than `[\s\S]*?` so a lazy match cannot run past this // declaration's own `*/` and swallow the next block. @@ -145,6 +148,35 @@ const dtsPatches: readonly Patch[] = [ source => source.includes(EMIT_TARGET_UNION), ), + // `InputFormat` carries the same const-enum problem as `EmitTarget`. + rewritePattern( + 'InputFormat const-enum removal', + /export declare const enum InputFormat \{\s*Json = 'json',\s*Yaml = 'yaml'\s*\}/, + [ + INPUT_FORMAT_UNION, + 'export declare const InputFormat: {', + " readonly Json: 'json';", + " readonly Yaml: 'yaml';", + '};', + ].join('\n'), + source => source.includes(INPUT_FORMAT_UNION), + ), + + rewritePattern( + 'ResponseType const-enum removal', + /export declare const enum ResponseType \{\s*Json = 'json',\s*Blob = 'blob',\s*Text = 'text',\s*ArrayBuffer = 'arrayBuffer'\s*\}/, + [ + RESPONSE_TYPE_UNION, + 'export declare const ResponseType: {', + " readonly Json: 'json';", + " readonly Blob: 'blob';", + " readonly Text: 'text';", + " readonly ArrayBuffer: 'arrayBuffer';", + '};', + ].join('\n'), + source => source.includes(RESPONSE_TYPE_UNION), + ), + // The wrapper defaults `emit` before the boundary, so a consumer may // omit it. rewrite('optional emit', 'emit: Array', 'emit?: Array'), diff --git a/tsconfig.runtime.json b/tsconfig.runtime.json new file mode 100644 index 0000000..cd0f066 --- /dev/null +++ b/tsconfig.runtime.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "lib": [ + "ES2022", + "DOM" + ], + "allowJs": true, + "checkJs": true, + "noEmit": true, + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "forceConsistentCasingInFileNames": true, + "types": [ + "node" + ], + "resolveJsonModule": true + }, + "include": [ + "lib/**/*.js", + "bin/**/*.js" + ] +} From e60917d1e213583f0daa9e85869831b4101726a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andrzej=20=C5=9Awi=C4=99tek?= Date: Tue, 8 Sep 2026 18:46:19 +0200 Subject: [PATCH 06/11] Trimmed every code comment to its contract --- __test__/cli.spec.ts | 7 +-- __test__/generate.snapshot.spec.ts | 25 +++------ __test__/generate.spec.ts | 42 ++++---------- bin/lib/parse.js | 13 ++--- bin/openapi-ng.js | 19 ++----- index.d.ts | 38 ++++--------- lib/browser.js | 20 +++---- lib/diagnostic.js | 14 ++--- lib/fetch-input.js | 35 ++++-------- lib/generate-error.js | 8 +-- lib/index.js | 25 +++------ lib/wrapper-core.js | 34 ++++-------- scripts/check-version-not-placeholder.ts | 3 +- scripts/lib/engine.ts | 9 +-- scripts/lib/snapshot-layout.ts | 21 +++---- scripts/patch-types.ts | 59 ++++++++------------ scripts/regen-snapshots.ts | 19 ++----- src/bindings.rs | 44 +++++---------- src/emit/angular/request.rs | 54 +++++------------- src/emit/angular/service.rs | 3 +- src/emit/mod.rs | 58 +++----------------- src/emit/model/emit_ts_models.rs | 6 +- src/emit/ts/imports.rs | 3 +- src/emit/ts_tests.rs | 1 - src/error.rs | 26 +++------ src/ir/canonical.rs | 35 ++++-------- src/ir/normalize/operations/form.rs | 36 ++++-------- src/ir/normalize/schema/mod.rs | 5 +- src/ir/normalize/semantic.rs | 46 ++++++---------- src/ir/schema.rs | 43 ++++----------- src/parse/input.rs | 70 ++++-------------------- src/parse/policy.rs | 4 +- src/pipeline.rs | 26 +++------ src/plan/artifact_plan.rs | 31 ++++------- src/plan/naming/fixed.rs | 32 +---------- src/plan/services/body.rs | 20 +++---- 36 files changed, 266 insertions(+), 668 deletions(-) diff --git a/__test__/cli.spec.ts b/__test__/cli.spec.ts index 1d13669..5ab8d9e 100644 --- a/__test__/cli.spec.ts +++ b/__test__/cli.spec.ts @@ -181,10 +181,7 @@ test('cli generate writes 3 artifacts for fixture without operations', t => { // ── Verbose: warnings ────────────────────────────────────────────────────── test('cli generate suppresses warnings without --verbose', t => { - // cookie-param emits a non-fatal warning (cookies aren't surfaced in the - // generated service contract — browsers manage cookies via the cookie - // store). header-param used to share this behaviour but headers are now - // first-class. + // cookie-param warns: the generated contract does not surface cookies. const result = runCli(['generate', '--input', fixture('cookie-param.openapi.yaml')]); t.is(result.status, 0); t.is(result.stderr, ''); @@ -321,8 +318,6 @@ test('cli with no args prints help to stdout and exits 2', t => { }); test('cli --help still exits 0', t => { - // Pin the existing behaviour so the "no args" change does not bleed - // into the explicit-help path. const result = runCli(['--help']); t.is(result.status, 0); }); diff --git a/__test__/generate.snapshot.spec.ts b/__test__/generate.snapshot.spec.ts index 2d90bbb..e6fb2aa 100644 --- a/__test__/generate.snapshot.spec.ts +++ b/__test__/generate.snapshot.spec.ts @@ -4,8 +4,8 @@ import path from 'node:path'; import { execFileSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; -// Through the wrapper, so a caught failure is a real `GenerateError` and -// the snapshot can pin its `path` and `warnings`. +// Through the wrapper: a caught failure is a real `GenerateError`, whose +// `path` and `warnings` the snapshots pin. import { generate, isGenerateError } from '../scripts/lib/engine.ts'; import type { GenerateOptions } from '../scripts/lib/engine.ts'; // Every fixture list, the banner regex and the static-template set come @@ -159,26 +159,19 @@ test('generate emits stable static-template artifacts (rest.model.ts, rest.util. t.deepEqual(staticTemplateArtifacts(baseline), hydrateStaticTemplate()); }); -// Compile gate: write every success-snapshot's artifacts (plus the static -// templates) to a temp project tree and run `tsc --noEmit` over them. -// Catches regressions where snapshots stay textually stable but the emitted -// TS no longer compiles — e.g. an Angular service that references an -// un-imported response type. Lives in this file (next to the snapshot -// loader) so the inputs the gate type-checks are exactly the snapshots -// committed to disk, not a re-generation that might mask drift. +// Compile gate over the snapshots committed to disk: catches emitted TS +// that stays textually stable but stops compiling, such as an Angular +// service referencing an un-imported response type. test('snapshot artifacts type-check under tsc --noEmit', t => { const repoNodeModules = path.join(repoRoot, 'node_modules'); if (!fs.existsSync(path.join(repoNodeModules, 'typescript', 'bin', 'tsc'))) { - t.fail('node_modules/typescript not installed — run `pnpm install` first'); + t.fail('node_modules/typescript not installed — run `bun install` first'); return; } - // Co-locate the project tree with the existing angular-consumer fixture - // so module resolution traverses up to repo node_modules (same path that - // tsconfig.json's "moduleResolution": "bundler" relies on). Live as a - // sibling of `generated/` rather than inside it: generate.spec.ts's - // reset helper recursively wipes `generated/` and would race with this - // file under AVA's per-file parallelism. + // Under the angular-consumer fixture, so module resolution reaches the + // repo's node_modules; a sibling of `generated/`, which + // generate.spec.ts's reset helper wipes. const compileRoot = path.join( repoRoot, '__test__', diff --git a/__test__/generate.spec.ts b/__test__/generate.spec.ts index 9bddb81..d115588 100644 --- a/__test__/generate.spec.ts +++ b/__test__/generate.spec.ts @@ -37,23 +37,11 @@ const fixture = (name: string) => path.join('test', 'fixtures', name); // serially by default but the order between tests in the same // file is implementation-defined — never assume a clean state. // -// 2. The snapshot-suite tsc gate -// (`__test__/generate.snapshot.spec.ts`, "snapshot artifacts -// type-check under tsc --noEmit") writes into a SIBLING subtree -// `__test__/angular-consumer/__snapshot_compile__/` — outside the -// shared `generated/` tree — and cleans it on every run. Living -// next to `generated/` rather than inside it is deliberate: this -// reset helper recursively wipes `generated/`, and under AVA's -// per-file parallelism the two files run concurrently. A future -// test that needs its own preserved tree across runs should -// likewise pick a NEW sibling directory next to `generated/` (e.g. -// `__test__/angular-consumer/generated-/`) rather than -// stash files inside the shared `generated/` tree — the reset -// helper wipes the entire shared directory unconditionally and -// collisions there are silent and hard to debug. The matching -// tsconfig should live next to the existing -// `tsconfig..json` files and `include` only the new -// sibling subtree. +// 2. The snapshot-suite tsc gate writes into the sibling subtree +// `__test__/angular-consumer/__snapshot_compile__/` and cleans it on +// every run. The reset helper below wipes `generated/` whole, and +// AVA runs the two files concurrently, so a tree that must survive +// belongs in its own sibling directory with its own tsconfig. const angularConsumerGeneratedDir = path.join(__dirname, 'angular-consumer', 'generated'); function resetAngularConsumerGeneratedDir() { @@ -109,10 +97,9 @@ const expectedModelSource = [ '', ].join('\n'); -// Paths echoed back by generate() are normalized to forward slash on every -// platform (src/pipeline.rs ~L80 replaces '\\' → '/'), so assert against the -// normalized form rather than path.join, which would produce backslashes on -// Windows. +// Paths echoed back by generate() are forward-slashed on every platform, +// which is why these assert against the normalized form and not +// `path.join`. const unsupportedSemanticDiagnostic = { code: 'E_UNSUPPORTED_SEMANTIC', severity: 'error', @@ -1667,11 +1654,8 @@ test.serial( }, ); -// Three concurrent generate() calls against distinct temp dirs, each -// pointed at the same on-disk spec. Catches mutable-state regressions in -// `prepareOptions` (the options object is no longer mutated; see the -// related non-mutation fix in lib/wrapper-core.js) and any future caching -// layer that might leak across simultaneous invocations. +// Three concurrent calls against distinct temp dirs and one on-disk +// spec: catches shared mutable state in `prepareOptions` or below it. test('generate is safe to run concurrently across distinct outputs', async t => { const baseOptions = { inputPath: fixture('petstore-minimal.openapi.yaml'), @@ -1689,14 +1673,10 @@ test('generate is safe to run concurrently across distinct outputs', async t => generate({ ...baseOptions, outputPath: dirC }), ]); - // The shared `naming` object must be untouched after concurrent - // calls — `prepareOptions` builds a normalized naming via spread - // instead of writing back to the caller's input. + // `prepareOptions` spreads rather than writing back. t.is(baseOptions.naming, sharedNaming); t.deepEqual(baseOptions.naming, { methodName: '{operationId}' }); - // Every call returns the same artifact set (deterministic) and - // every output directory contains the same file list. const namesA = a.artifacts.map(art => art.path).sort(); const namesB = b.artifacts.map(art => art.path).sort(); const namesC = c.artifacts.map(art => art.path).sort(); diff --git a/bin/lib/parse.js b/bin/lib/parse.js index 6b597ce..1d90cf2 100644 --- a/bin/lib/parse.js +++ b/bin/lib/parse.js @@ -1,10 +1,5 @@ -// CLI argument and config parsing helpers extracted from openapi-ng.js. -// Kept as a separate module so they are unit-testable without spawning -// a subprocess and without coupling to the runtime/output surface. -// -// The exported records mirror the `MappedType` shape on the NAPI -// boundary (`schema/import/type/alias`) one-to-one — the CLI does not -// translate between naming worlds. +// Argument and config parsing for the CLI. The exported records mirror +// the NAPI `MappedType` shape one-to-one. const fs = require('node:fs'); const path = require('node:path'); @@ -62,8 +57,8 @@ const DEFAULT_EMIT = Object.freeze(['models', 'angular']); const VALID_INIT_FORMATS = Object.freeze(new Set(['yaml', 'json', 'ts', 'js'])); -// Validate that argv[i + 1] is a real value, not the next flag or end-of-args. -// Without this, `--config --input spec.yaml` silently consumes `--input` as +// Rejects a flag or end-of-args in the value position: without this, +// `--config --input spec.yaml` consumes `--input` as // the config path, leaving the user staring at a config-not-found error // without ever seeing their `--input` argument honoured. Treat any token // starting with `-` (long `--foo` or short `-f`) as a flag, never a value. diff --git a/bin/openapi-ng.js b/bin/openapi-ng.js index 3a48df7..8bc6c2a 100755 --- a/bin/openapi-ng.js +++ b/bin/openapi-ng.js @@ -1,11 +1,7 @@ #!/usr/bin/env node -// Resolve through the wrapper so caught errors are `GenerateError` -// instances (the CLI formatter doesn't depend on `instanceof`, but -// consumers debugging via `node --inspect` see a consistent shape). -// NOTE: do NOT require('../lib/index.js') at module top — that would load -// the native binding on every invocation, including --help and --version. -// Use loadLibrary() inside the generate handler instead. +// Loaded inside the generate handler, not at module top: requiring the +// wrapper loads the native binding, which --help and --version must not. function loadLibrary() { return require('../lib/index.js'); } @@ -21,7 +17,7 @@ const { parseArgs, } = require('./lib/parse.js'); -// Minimal ANSI styler — emit colour only when stdout is a TTY and NO_COLOR is unset +// Colour only when stdout is a TTY and NO_COLOR is unset. const USE_COLOR = process.stdout.isTTY === true && !process.env.NO_COLOR; /** @param {number} code @returns {(text: unknown) => string} */ const wrap = code => @@ -281,8 +277,7 @@ async function main(argv) { } else { printUsage(); } - // Bare `openapi-ng` (no subcommand) is a usage error — exit 2 so CI - // scripts can catch a missing command. Explicit `--help` keeps exit 0. + // Bare `openapi-ng` exits 2; an explicit `--help` exits 0. if (parsed.explicit === false) { process.exitCode = 2; } @@ -300,7 +295,6 @@ async function main(argv) { return; } - // Load config file for generate command let fileConfig = {}; try { const configFilePath = parsed.configPath ?? discoverConfigPath(process.cwd()); @@ -313,7 +307,6 @@ async function main(argv) { return; } - // Generate command let merged; try { merged = mergeConfig(fileConfig, parsed); @@ -328,9 +321,7 @@ async function main(argv) { try { const { generate } = loadLibrary(); - // Pass the user-provided inputPath verbatim. Relativisation of - // absolute paths inside CWD (for the generated-artifact banner) is - // owned by the Rust side in `render_generated_banner`, so the CLI + // Verbatim: `render_generated_banner` owns relativisation, so the CLI // and programmatic consumers (`generate({ inputPath: '/abs/...' })`) // get the same banner-path hygiene without duplicated logic. const result = await generate({ diff --git a/index.d.ts b/index.d.ts index 4cd9535..b393ef8 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,9 +1,6 @@ /* auto-generated by NAPI-RS */ /* eslint-disable */ -/** - * Per-target emit selection. The `emit` option is the set of artifact - * families to produce; each entry maps to one or more files. - */ +/** Set of artifact families to produce; each entry maps to one or more files. */ export type EmitTarget = 'models' | 'angular'; export declare const EmitTarget: { readonly Models: 'models'; @@ -20,14 +17,9 @@ export interface GeneratedArtifact { } /** - * Payload returned inside `GenerateOutcome.error`. The JS wrapper - * constructs a `GenerateError` (a real JS class that extends Error) - * from these fields, so consumers can `instanceof GenerateError` and - * read `code/subcode/message/path/warnings`. - * - * The fatal sits at the top level (`code/subcode/message/path`); pre-fatal - * warnings ride in `warnings`. `subcode` is set for `PolicyViolation` - * codes; it is `null` for every other category. + * Payload returned inside `GenerateOutcome.error`, which the JS wrapper + * turns into a `GenerateError`. The fatal sits at the top level; + * pre-fatal warnings ride in `warnings`. */ export interface GenerateErrorPayload { code: DiagnosticCode @@ -46,13 +38,11 @@ export interface GenerateOptions { /** * Raw spec source. When set, `display_path` is required and the * 16 MiB byte cap applies to `input_contents.as_bytes().len()`. - * JS wrapper fills this in for URL inputs. */ inputContents?: string /** - * Banner / diagnostic display string. Required when `input_contents` - * is set; ignored when `input_path` is set (the existing path - * normalisation runs in that case). + * Banner and diagnostic display string. Required with + * `input_contents`, ignored with `input_path`. */ displayPath?: string /** @@ -68,9 +58,8 @@ export interface GenerateOptions { emit?: Array mappedTypes?: Array /** - * Per-content-type override of the generated response-decoding kind - * (`json | blob | text | arrayBuffer`). Read by the normalize stage - * when picking how a successful response body is decoded. + * Per-content-type override of the response-decoding kind + * (`json | blob | text | arrayBuffer`). */ responseTypeMapping?: Array naming?: NamingConfig @@ -163,20 +152,15 @@ export interface NamingRuleEntry { } /** - * A string shorthand, a single rule, or a chain of either. - * - * NAPI has no sum type, so the variants are exclusive fields: exactly - * one must be set, which `plan::naming::lower` enforces. + * A string shorthand, a single rule, or a chain of either. Exactly one + * field must be set; `plan::naming::lower` enforces that. */ export interface NamingValue { /** `{ string: '...' }` — bare format-string shorthand. */ string?: string /** `{ rule: { ... } }` — a single Rule. */ rule?: NamingRuleEntry - /** - * `{ chain: [...] }` — a sequence; each item is an exclusive - * `{ string }` or `{ rule }`. - */ + /** `{ chain: [...] }` — a sequence of `{ string }` or `{ rule }`. */ chain?: Array } diff --git a/lib/browser.js b/lib/browser.js index f8d4c3e..2362460 100644 --- a/lib/browser.js +++ b/lib/browser.js @@ -1,11 +1,9 @@ 'use strict'; -// Browser entry. Options go through the same normalisation as the Node -// entry; generation runs in the WebAssembly binding published as -// `@avsystem/openapi-ng-wasm32-wasi`, whose `browser` field points at -// napi-rs's WASI browser loader. The page must be cross-origin isolated -// (COOP `same-origin`, COEP `require-corp`) because the binding uses -// shared memory. +// Browser entry: the same option normalisation as the Node entry, over +// the WebAssembly binding in `@avsystem/openapi-ng-wasm32-wasi`. The +// binding uses shared memory, so the page must be cross-origin isolated +// (COOP `same-origin`, COEP `require-corp`). const { GenerateError } = require('./generate-error.js'); const { prepareOptions, unwrapOutcome, upgradeError } = require('./wrapper-core.js'); @@ -42,8 +40,8 @@ function invalidOption(message) { }); } -// No filesystem and no node:net in the browser: the spec arrives as -// `inputContents`, artifacts leave as `result.artifacts`. +// No filesystem in the browser: the spec arrives as `inputContents` and +// artifacts leave as `result.artifacts`. /** * @param {import('../index.js').GenerateOptions} options * @returns {void} @@ -89,8 +87,6 @@ function unsupportedRuntime(cause) { return err; } -// `loadBinding` resolves to the WASI module namespace (ESM loader) or a -// CommonJS `module.exports` under `default`; both expose `generateNative`. /** * @param {() => Promise} loadBinding Resolves to the WASI module * namespace, or to a CommonJS `module.exports` under `default`. @@ -107,8 +103,8 @@ function createGenerate(loadBinding) { const namespace = /** @type {{ default?: unknown }} */ (mod); const binding = isWasiBinding(mod) ? mod : namespace?.default; if (!isWasiBinding(binding)) { - // A plain Error, so the catch below turns a bad shape into the - // same E_UNSUPPORTED_RUNTIME a failed load produces. + // The catch below turns this into the E_UNSUPPORTED_RUNTIME a + // failed load produces. throw new Error('module does not export generateNative'); } return binding; diff --git a/lib/diagnostic.js b/lib/diagnostic.js index a20b55e..4f84303 100644 --- a/lib/diagnostic.js +++ b/lib/diagnostic.js @@ -1,17 +1,11 @@ 'use strict'; -// Errors that carry a diagnostic code, and reading fields off values that -// may not. -// -// Every entry point catches values it did not create — a rejected dynamic -// import, whatever a `fetch` implementation or a config module threw — and -// reads `name`, `message` or `code` off them. Both halves of that live -// here so the CLI, the wrapper and the fetch path agree. +// Errors carrying a diagnostic code, and field reads for caught values +// that may carry nothing at all. /** * The value at `key`, or `undefined` when `value` holds no properties. - * - * Functions count: a caught value can be one, and it carries a `name`. + * A function counts as holding properties. * * @param {unknown} value * @param {string} key @@ -31,7 +25,7 @@ function field(value, key) { */ /** - * An error the caller's input caused, as opposed to a bug. + * An error the caller's input caused. * * @param {string} message * @returns {CodedError} diff --git a/lib/fetch-input.js b/lib/fetch-input.js index 4ed80ef..7b79aa8 100644 --- a/lib/fetch-input.js +++ b/lib/fetch-input.js @@ -19,9 +19,8 @@ const DEFAULT_MAX_BYTES = 16 * 1024 * 1024; const DEFAULT_TIMEOUT_MS = 30_000; const MAX_REDIRECTS = 5; -// Accept https-or-http; the scheme check itself runs inside fetchInput -// where the http case turns into a typed error. This predicate just -// decides "is this a URL or a path". +// Matches https and http alike: `fetchInput` turns the http case into a +// typed error. This decides only whether the input is a URL or a path. /** * @param {unknown} value * @returns {boolean} @@ -39,10 +38,7 @@ function isHttpsUrl(value) { return /^https:\/\//i.test(value); } -// Tolerate the browser/WASI entry where `process` may not exist. The -// surrounding logic (size cap default, timeout default, the new -// OPENAPI_NG_ALLOW_PRIVATE_HOSTS opt-out) all assume read-or-fall-back -// semantics, so an undefined `process` collapses to the default branch. +// An undefined `process` — the browser entry — reads as an absent value. /** * @param {string} name * @returns {string | undefined} @@ -65,11 +61,9 @@ function envInt(name, defaultValue) { return n; } -// IPv4 + IPv6 ranges we refuse to resolve to. The list covers cloud -// metadata services (`169.254.169.254`), RFC1918 LAN ranges, loopback, -// link-local, CGNAT, ULA, and IPv4-mapped IPv6 — every shape that lets -// a user-supplied URL escape into private infrastructure. Built once -// per process (module load), not per-request, so the BlockList is hot. +// Ranges a fetched URL may not resolve to: cloud metadata +// (`169.254.169.254`), RFC1918, loopback, link-local, CGNAT, ULA and +// IPv4-mapped IPv6. Built once per process. const BLOCKED_HOSTS = (() => { const list = new net.BlockList(); list.addSubnet('127.0.0.0', 8, 'ipv4'); // loopback @@ -82,19 +76,15 @@ const BLOCKED_HOSTS = (() => { list.addAddress('::1', 'ipv6'); list.addSubnet('fc00::', 7, 'ipv6'); // ULA list.addSubnet('fe80::', 10, 'ipv6'); // link-local - // node:net's BlockList performs dual-stack matching automatically: - // checking a literal `::ffff:127.0.0.1` as ipv6 hits the loopback - // subnet added above. Adding an explicit `::ffff:0.0.0.0/96` range - // would also match every public IPv4 (1.1.1.1, …) via the same - // mapping, so it's intentionally omitted. + // `BlockList` matches dual-stack on its own: `::ffff:127.0.0.1` hits + // the loopback subnet above. An explicit `::ffff:0.0.0.0/96` range + // would match every public IPv4 through the same mapping. return list; })(); -// DNS lookup is reachable via an injection slot so the Ava test suite -// can stub it without spinning up a real resolver. Default lazily loads -// `node:dns/promises` so a runtime without DNS (e.g. some sandboxes) -// can still parse this module — the lookup only fires if a non-IP host -// is being resolved AND the SSRF guard is active. +// Injectable, and lazy: a runtime without `node:dns/promises` can still +// load this module, since the lookup fires only for a non-IP host under +// an active SSRF guard. /** @typedef {(host: string, options: { all: true }) => Promise>} DnsLookup */ /** @type {DnsLookup | null} */ @@ -107,7 +97,6 @@ function __setDnsLookupForTest(impl) { /** @returns {DnsLookup} */ function _resolveDnsLookup() { if (_testDnsLookup !== null) return _testDnsLookup; - // Lazy: only require dns when we actually need to resolve a name. const dns = require('node:dns/promises'); return (host, options) => dns.lookup(host, options); } diff --git a/lib/generate-error.js b/lib/generate-error.js index 2659d59..a22a8fd 100644 --- a/lib/generate-error.js +++ b/lib/generate-error.js @@ -15,18 +15,14 @@ class GenerateError extends Error { Object.defineProperty(this, MARKER, { value: true, enumerable: false }); } - // Cross-realm-safe predicate. `instanceof GenerateError` only works - // inside the realm where this module was loaded; the sentinel own- - // property survives the realm boundary, so consumers crossing realms - // should use `GenerateError.isGenerateError(err)` instead. + // Cross-realm-safe, unlike `instanceof GenerateError`: the sentinel is + // an own property and survives the realm boundary. /** * @param {unknown} value * @returns {value is import('../index.js').GenerateError} */ static isGenerateError(value) { if (typeof value !== 'object' || value === null) return false; - // The sentinel is a non-enumerable own property, so reading it needs - // the one cast this file makes at its `unknown` boundary. return /** @type {{ [key: string]: unknown }} */ (value)[MARKER] === true; } } diff --git a/lib/index.js b/lib/index.js index d9b8a6e..8c47ce3 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1,22 +1,15 @@ 'use strict'; -// Test affordance: set OPENAPI_NG_DISABLE_NATIVE_FOR_TEST=1 to prevent the -// native binding from loading. Used to verify lazy-load behaviour in CLI tests. +// OPENAPI_NG_DISABLE_NATIVE_FOR_TEST=1 prevents the native binding from +// loading. if (process.env.OPENAPI_NG_DISABLE_NATIVE_FOR_TEST === '1') { throw new Error('native binding load is disabled for this test'); } -// Node entry point. The native binding sits at ../native.js (auto-generated -// by napi-rs). The wrapper throws `GenerateError` built from the native -// `{ result, error }` union; the native binding never throws on its own. -// -// The native binding is NOT a published entry point — `package.json#main` -// only exposes `lib/index.js`, so consumers can never bypass this wrapper. -// -// Shared option normalisation, validation, and URL-fetch ergonomics live -// in `lib/wrapper-core.js` so the browser/WASI entry (`browser.js`) can -// reuse them; this file is the Node-specific seam that binds them to -// `../native.js`. +// Node entry point, and the only published one: it binds +// `lib/wrapper-core.js` to the napi-rs binding at `../native.js`. The +// binding returns a `{ result, error }` union and never throws; this +// wrapper raises `GenerateError` from the error arm. /** * The native binding, whose only export these wrappers use. @@ -43,10 +36,8 @@ async function generate(options) { return unwrapOutcome(outcome); } -// Frozen runtime shape for `EmitTarget`. Mirrors the ambient const -// declared in `index.d.ts` and matches the `browser.js` entry, so the -// surface a consumer destructures is identical across both runtimes and -// no longer depends on napi-rs's string-enum machinery. +// Runtime shape for `EmitTarget`, mirroring `index.d.ts` and the +// `browser.js` entry. const EmitTarget = Object.freeze({ Models: 'models', Angular: 'angular', diff --git a/lib/wrapper-core.js b/lib/wrapper-core.js index 8a4b256..588d53a 100644 --- a/lib/wrapper-core.js +++ b/lib/wrapper-core.js @@ -1,16 +1,13 @@ 'use strict'; -// Wrapper logic for the Node entry (`lib/index.js`): option normalisation -// and validation, URL-fetch ergonomics, and error upgrade around the native -// binding. Lives here rather than inline in `lib/index.js` to keep the entry -// thin and to make the pre-NAPI option surface independently testable. +// Option normalisation and validation, URL-fetch ergonomics, and error +// upgrade, shared by the Node and browser entries. const { GenerateError } = require('./generate-error.js'); const { field } = require('./diagnostic.js'); /** - * What the native export returns: exactly one field is set. Not part of - * the published surface, so it is declared here rather than imported. + * What the native export returns: exactly one field is set. * * @typedef {object} GenerateOutcome * @property {import('../index.js').GenerateResult} [result] @@ -20,11 +17,8 @@ const { field } = require('./diagnostic.js'); /** @typedef {import('../index.js').GenerateOptions} GenerateOptions */ /** - * A chain item as a caller may write it. - * - * Wider than the published `NamingRule` in one place: `parse` also - * accepts an already-split `{ source, flags }`, which is what reaches - * here when a caller unpacked the RegExp itself. + * A chain item as a caller may write it. Wider than the published + * `NamingRule`: `parse` also accepts an already-split `{ source, flags }`. * * @typedef {object} NamingEntryInput * @property {string} [from] @@ -37,19 +31,16 @@ const { field } = require('./diagnostic.js'); /** * Options after `prepareOptions`. `naming` is the lowered boundary shape - * once a caller supplied one; the declared shape stays in the union - * because an absent key keeps its input type. + * once a caller supplied one, and the declared shape stays in the union + * for the absent case. * * @typedef {Omit & { * naming?: import('../index.js').NamingOptions | import('../index.js').NamingConfig * }} PreparedOptions */ -// Frozen allow-list of recognised option keys. The native binding silently -// ignores anything else; surfacing unknown keys here means typos -// (`inputpath:` → undefined) fail fast with a typed `GenerateError` -// instead of producing confusing downstream diagnostics. Keep in sync -// with `GenerateOptions` in `src/bindings.rs`. +// Recognised option keys, which the native binding would otherwise +// ignore silently. Mirrors `GenerateOptions` in `src/bindings.rs`. /** @type {ReadonlySet} */ const GENERATE_OPTION_KEYS = Object.freeze( new Set([ @@ -65,11 +56,8 @@ const GENERATE_OPTION_KEYS = Object.freeze( ]), ); -// Frozen allow-list of recognised `EmitTarget` runtime values. Duplicates -// `VALID_EMIT_TARGETS` in `bin/lib/parse.js` on purpose: the CLI and the -// programmatic API are independent boundaries, each performing its own -// entry-level validation. Both reflect the same truth declared in -// `EmitTarget` (see `index.d.ts`); keep them in sync. +// Recognised `EmitTarget` values. The CLI validates its own copy in +// `bin/lib/parse.js`; both mirror `EmitTarget` in `index.d.ts`. /** @type {ReadonlySet} */ const VALID_EMIT = Object.freeze(new Set(['models', 'angular'])); diff --git a/scripts/check-version-not-placeholder.ts b/scripts/check-version-not-placeholder.ts index 795af2f..065bd0f 100644 --- a/scripts/check-version-not-placeholder.ts +++ b/scripts/check-version-not-placeholder.ts @@ -1,6 +1,5 @@ #!/usr/bin/env bun -// Refuses to publish while package.json still carries the placeholder -// version, which would claim 0.0.0 on the registry. +// Refuses to publish while package.json still carries version 0.0.0. import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; diff --git a/scripts/lib/engine.ts b/scripts/lib/engine.ts index 6e1638d..f921a4f 100644 --- a/scripts/lib/engine.ts +++ b/scripts/lib/engine.ts @@ -1,9 +1,6 @@ -// Loads the generator from the local build, so a script run exercises the -// tree it was invoked in. -// -// `lib/index.js` is untyped CommonJS implementing the surface the -// repository's `index.d.ts` declares. The shape check below turns a -// renamed or missing export into an error naming it, at load time. +// Loads the generator from the local build. `lib/index.js` is CommonJS, +// so the shape check below turns a renamed or missing export into an +// error naming it, at load time. import { createRequire } from 'node:module'; diff --git a/scripts/lib/snapshot-layout.ts b/scripts/lib/snapshot-layout.ts index 3b60f05..2a8ac62 100644 --- a/scripts/lib/snapshot-layout.ts +++ b/scripts/lib/snapshot-layout.ts @@ -1,25 +1,18 @@ -// The snapshot suite's single source of truth: which fixtures are pinned, -// what each one proves, and how the files are laid out. -// -// Read by the regenerator (scripts/regen-snapshots.ts) and by the reader -// (__test__/generate.snapshot.spec.ts), so neither can drift from the -// other or from test/fixtures/. +// Which fixtures are pinned, what each one proves, and how the files are +// laid out. Read by both scripts/regen-snapshots.ts and +// __test__/generate.snapshot.spec.ts. import path from 'node:path'; import type { GenerateOptions } from '../../index.js'; -/** - * The do-not-edit banner, stripped from every stored artifact so a - * snapshot survives a version bump. - */ +/** The do-not-edit banner, stripped from every stored artifact. */ export const BANNER_RE = /^\/\/ Generated by openapi-ng v[^\n]*\n\/\/ Source: [^\n]*\n\/\/ DO NOT EDIT[^\n]*\n\n/u; /** - * Artifacts byte-identical across every success fixture. Each per-fixture - * directory omits them; their bodies are stored once under - * `static-template/`. + * Artifacts byte-identical across every success fixture, stored once + * under `static-template/` and omitted from each fixture's directory. */ export const STATIC_TEMPLATE_PATHS: ReadonlySet = new Set([ 'rest.model.ts', @@ -175,6 +168,6 @@ export const FAILURE_FIXTURES: readonly FailureFixture[] = [ /** * Fixtures with no snapshot. `malformed.yaml`'s wording follows the YAML - * parser's own line and column output, which the spec asserts by regex. + * parser's line and column output; the spec asserts it by regex. */ export const UNSNAPSHOTTED: readonly string[] = ['malformed.yaml']; diff --git a/scripts/patch-types.ts b/scripts/patch-types.ts index ea35b88..ee1c077 100644 --- a/scripts/patch-types.ts +++ b/scripts/patch-types.ts @@ -1,12 +1,7 @@ #!/usr/bin/env bun -// Post-processes what `napi build` generates, so the published surface is -// the one consumers should see. -// -// A patch that no longer matches fails the build naming itself, so a -// change in NAPI-RS output cannot publish an unpatched surface. Every -// patch is idempotent: a rerun on a patched tree is a no-op. -// -// Runs from the `postbuild` / `postbuild:debug` scripts. +// Post-processes what `napi build` generates into the published surface. +// A patch that no longer matches fails the build naming itself. Every +// patch is idempotent. Runs from `postbuild` and `postbuild:debug`. import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; @@ -76,10 +71,7 @@ function rewritePattern( }; } -/** - * Scopes literal substitutions to the body of one named interface, so a - * coincidental `code: string` elsewhere can never be caught by the patch. - */ +/** Scopes literal substitutions to the body of one named interface. */ function withinInterface( name: string, interfaceName: string, @@ -109,9 +101,8 @@ function withinInterface( }; } -// Narrow the opaque strings NAPI emits to the named unions consumers can -// switch on exhaustively. The same field shapes appear in more than one -// interface, so each set is scoped to its own block. +// Narrows the opaque strings NAPI emits to named unions, per interface: +// the same field shapes appear in more than one of them. const NARROWED_DIAGNOSTIC = [ [' code: string', ' code: DiagnosticCode'], [' subcode?: string', ' subcode: DiagnosticSubcode | null'], @@ -123,18 +114,17 @@ const INPUT_FORMAT_UNION = "export type InputFormat = 'json' | 'yaml';"; const RESPONSE_TYPE_UNION = "export type ResponseType = 'json' | 'blob' | 'text' | 'arrayBuffer';"; -// `[^*]|\*(?!/)` rather than `[\s\S]*?` so a lazy match cannot run past this -// declaration's own `*/` and swallow the next block. +// `[^*]|\*(?!/)`, not `[\s\S]*?`: a lazy match runs past this +// declaration's own `*/` and swallows the next block. const LEADING_DOC = '(?:^/\\*\\*(?:[^*]|\\*(?!/))*\\*/\\n)?'; const dtsPatches: readonly Patch[] = [ withinInterface('diagnostic narrowing', 'GeneratorDiagnostic', NARROWED_DIAGNOSTIC), withinInterface('error-payload narrowing', 'GenerateErrorPayload', NARROWED_DIAGNOSTIC.slice(0, 2)), - // A `const enum` in a published .d.ts breaks consumers compiling under - // isolatedModules / verbatimModuleSyntax (Vite, esbuild, Bun, TS 5+ - // defaults). The union plus an ambient const keeps `EmitTarget.Models` - // working while staying importable from a single-file transpile. + // A `const enum` in a published .d.ts is unimportable under + // isolatedModules. The union plus an ambient const keeps + // `EmitTarget.Models` working under a single-file transpile. rewritePattern( 'EmitTarget const-enum removal', /export declare const enum EmitTarget \{\s*Models = 'models',\s*Angular = 'angular'\s*\}/, @@ -148,7 +138,7 @@ const dtsPatches: readonly Patch[] = [ source => source.includes(EMIT_TARGET_UNION), ), - // `InputFormat` carries the same const-enum problem as `EmitTarget`. + // Same const-enum problem as `EmitTarget`. rewritePattern( 'InputFormat const-enum removal', /export declare const enum InputFormat \{\s*Json = 'json',\s*Yaml = 'yaml'\s*\}/, @@ -177,28 +167,26 @@ const dtsPatches: readonly Patch[] = [ source => source.includes(RESPONSE_TYPE_UNION), ), - // The wrapper defaults `emit` before the boundary, so a consumer may - // omit it. + // The wrapper defaults `emit` before the boundary. rewrite('optional emit', 'emit: Array', 'emit?: Array'), - // `inputPath` is optional because a caller may pass `inputContents` - // instead; the two are validated mutually exclusive at runtime. + // A caller may pass `inputContents` instead; the two are mutually + // exclusive at runtime. rewrite('optional inputPath', 'inputPath: string', 'inputPath?: string'), - // A JS `RegExp` cannot cross the NAPI boundary, so Rust declares the - // `{ source, flags }` wire shape the wrapper unpacks into. + // A JS `RegExp` cannot cross the NAPI boundary: Rust declares the + // `{ source, flags }` shape the wrapper unpacks into. rewrite('friendly naming type', 'naming?: NamingOptions', 'naming?: NamingConfig'), - // The native export and its result union are wrapper-internal; the - // hand-authored tail declares `generate` instead. + // Wrapper-internal; the hand-authored tail declares `generate`. { name: 'native-export stripping', apply: source => { const stripped = source .replace(new RegExp(`${LEADING_DOC}^export declare function generateNative\\([^\\n]*\\n`, 'm'), '') .replace(new RegExp(`${LEADING_DOC}^export interface GenerateOutcome \\{[\\s\\S]*?^\\}\\n`, 'm'), ''); - // Scoped to the declaration forms: GenerateErrorPayload's own doc - // comment legitimately mentions `GenerateOutcome.error` in prose. + // Scoped to the declaration forms: `GenerateErrorPayload`'s doc + // comment mentions `GenerateOutcome.error` in prose. if (/^export (?:declare function generateNative|interface GenerateOutcome)\b/m.test(stripped)) { throw new DriftError('native-export stripping', 'a declaration survived'); } @@ -207,7 +195,7 @@ const dtsPatches: readonly Patch[] = [ }, ]; -/** Marks where the hand-authored tail begins, so reruns stay idempotent. */ +/** Marks where the hand-authored tail begins. */ const TAIL_MARKER = '\n// Hand-authored tail'; function patchTypes(): void { @@ -237,9 +225,8 @@ const SUPPORTED_PLATFORMS = [ ]; /** - * Injects a platform-specific load error ahead of NAPI-RS's generic one, so - * a consumer on an unsupported platform is told which platforms ship a - * binary and that a WebAssembly fallback exists. + * Injects a load error ahead of NAPI-RS's generic one, naming the + * platforms that ship a binary and the WebAssembly fallback. */ function patchNativeLoader(): void { const source = readFileSync(nativePath, 'utf8'); diff --git a/scripts/regen-snapshots.ts b/scripts/regen-snapshots.ts index f06142c..659d070 100644 --- a/scripts/regen-snapshots.ts +++ b/scripts/regen-snapshots.ts @@ -5,18 +5,12 @@ // Storage layout: // .success.json summary, diagnostics, and a path-only artifact // list — no inline contents -// / each artifact's body as a sibling file, so a -// PR diff reads as TypeScript rather than as -// JSON-escaped strings +// / each artifact's body as a sibling file // static-template.json the path-only list for the Angular support -// static-template/ files, whose bodies are identical across -// every success fixture and so stored once +// static-template/ bodies identical across every fixture // -// Every fixture in test/fixtures/ must appear in exactly one of the three -// sets in scripts/lib/snapshot-layout.ts; the script fails on one that -// appears in none. -// -// Run with: bun run regen-snapshots +// Every fixture in test/fixtures/ must appear in one of the three sets in +// scripts/lib/snapshot-layout.ts. Run with: bun run regen-snapshots import fs from 'node:fs'; import path from 'node:path'; @@ -40,10 +34,7 @@ const snapshots = snapshotDir(repoRoot); const staticTemplateDir = path.join(snapshots, 'static-template'); const staticTemplateIndex = path.join(snapshots, 'static-template.json'); -/** - * Fails when a fixture on disk appears in none of the three sets, so a new - * fixture must be classified rather than silently ignored. - */ +/** Fails when a fixture on disk appears in none of the three sets. */ function assertEveryFixtureIsClassified(): void { const classified = new Set([ ...SUCCESS_FIXTURES.map(entry => entry.fixture), diff --git a/src/bindings.rs b/src/bindings.rs index ee4df97..bf1d1a5 100644 --- a/src/bindings.rs +++ b/src/bindings.rs @@ -7,8 +7,7 @@ use crate::{ result::{GenerateSummary, GeneratedArtifact}, }; -/// Per-target emit selection. The `emit` option is the set of artifact -/// families to produce; each entry maps to one or more files. +/// Set of artifact families to produce; each entry maps to one or more files. #[napi(string_enum = "lowercase")] #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum EmitTarget { @@ -25,10 +24,8 @@ pub struct NamingOptions { pub group: Option, } -/// A string shorthand, a single rule, or a chain of either. -/// -/// NAPI has no sum type, so the variants are exclusive fields: exactly -/// one must be set, which `plan::naming::lower` enforces. +/// A string shorthand, a single rule, or a chain of either. Exactly one +/// field must be set; `plan::naming::lower` enforces that. #[napi(object)] #[derive(Clone, Debug)] pub struct NamingValue { @@ -36,8 +33,7 @@ pub struct NamingValue { pub string: Option, /// `{ rule: { ... } }` — a single Rule. pub rule: Option, - /// `{ chain: [...] }` — a sequence; each item is an exclusive - /// `{ string }` or `{ rule }`. + /// `{ chain: [...] }` — a sequence of `{ string }` or `{ rule }`. pub chain: Option>, } @@ -73,11 +69,9 @@ pub struct GenerateOptions { pub input_path: Option, /// Raw spec source. When set, `display_path` is required and the /// 16 MiB byte cap applies to `input_contents.as_bytes().len()`. - /// JS wrapper fills this in for URL inputs. pub input_contents: Option, - /// Banner / diagnostic display string. Required when `input_contents` - /// is set; ignored when `input_path` is set (the existing path - /// normalisation runs in that case). + /// Banner and diagnostic display string. Required with + /// `input_contents`, ignored with `input_path`. pub display_path: Option, /// Decoder hint. Only honoured when `input_contents` is set; combining /// it with `input_path` is a shape error. @@ -87,9 +81,8 @@ pub struct GenerateOptions { pub output_path: Option, pub emit: Vec, pub mapped_types: Option>, - /// Per-content-type override of the generated response-decoding kind - /// (`json | blob | text | arrayBuffer`). Read by the normalize stage - /// when picking how a successful response body is decoded. + /// Per-content-type override of the response-decoding kind + /// (`json | blob | text | arrayBuffer`). pub response_type_mapping: Option>, pub naming: Option, } @@ -110,14 +103,9 @@ pub struct GenerateResult { pub artifacts: Vec, } -/// Payload returned inside `GenerateOutcome.error`. The JS wrapper -/// constructs a `GenerateError` (a real JS class that extends Error) -/// from these fields, so consumers can `instanceof GenerateError` and -/// read `code/subcode/message/path/warnings`. -/// -/// The fatal sits at the top level (`code/subcode/message/path`); pre-fatal -/// warnings ride in `warnings`. `subcode` is set for `PolicyViolation` -/// codes; it is `null` for every other category. +/// Payload returned inside `GenerateOutcome.error`, which the JS wrapper +/// turns into a `GenerateError`. The fatal sits at the top level; +/// pre-fatal warnings ride in `warnings`. #[napi(object)] pub struct GenerateErrorPayload { pub code: String, @@ -128,18 +116,15 @@ pub struct GenerateErrorPayload { } /// Return shape of the native export, with exactly one field set. -/// -/// Returning the failure as data keeps the native and WASI runtimes -/// identical. #[napi(object)] pub struct GenerateOutcome { pub result: Option, pub error: Option, } -/// Project a `catch_unwind` payload into the same payload shape a typed -/// fatal produces. `&'static str` and `String` are the two common panic -/// payload types; anything else collapses to a generic message. +/// Projects a `catch_unwind` payload into the shape a typed fatal +/// produces. A payload that is neither `&'static str` nor `String` +/// collapses to a generic message. pub(crate) fn map_panic(panic: Box) -> GenerateErrorPayload { let message = panic .downcast_ref::<&'static str>() @@ -191,7 +176,6 @@ impl From for GenerateConfig { pub(crate) fn map_generate_result(value: ApplicationGenerateResult) -> GenerateResult { GenerateResult { summary: value.summary, - // A success carries warnings only. diagnostics: value .diagnostics .iter() diff --git a/src/emit/angular/request.rs b/src/emit/angular/request.rs index d0ec2a6..a56ca1c 100644 --- a/src/emit/angular/request.rs +++ b/src/emit/angular/request.rs @@ -25,8 +25,6 @@ pub(super) fn render_requestful_builder( .iter() .map(|f| f.name.as_ref()) .collect(); - // A nested body destructures as one `body`; a hoisted one destructures - // every field, which the `body:` expression then references by name. match &operation.request.body { None => {} Some(PlannedRequestBody::Nested { .. }) => destructured.push("body"), @@ -78,9 +76,7 @@ pub(super) fn render_request_interface( operation: &PlannedOperation<'_>, request_name: &TypeName, ) { - // Emitted member by member because a hoisted body mixes `SchemaType` - // with `BodyFieldType`, which `interface_block` cannot take together. - // Member order is path → query → body → headers. + // Member order: path → query → body → headers. buffer.open_block(&format!("export interface {request_name}")); for field in &operation.request.fields { @@ -139,10 +135,8 @@ pub(super) fn render_error_interface( } /// Emits the synthetic `headers` member: an inline object over the -/// operation's `in: header` parameters, optional when every one of them is. -/// -/// No member carries JSDoc: OpenAPI's Parameter Object has no -/// `deprecated` for a header. +/// operation's `in: header` parameters, optional when every one of them +/// is. No member carries JSDoc. fn render_headers_member(buffer: &mut Writer, headers: &[PlannedHeader<'_>]) { buffer.push("headers"); if headers.iter().all(|header| header.optional) { @@ -159,10 +153,7 @@ fn render_headers_member(buffer: &mut Writer, headers: &[PlannedHeader<'_>]) { } /// Writes `params: httpParams({ … }),` when the operation declares query -/// parameters. -/// -/// Emitted even when every field is optional: `httpParams` skips an -/// undefined value, so an all-undefined call yields empty params. +/// parameters, including when every one of them is optional. fn write_params_line(buffer: &mut Writer, operation: &PlannedOperation<'_>) { let mut query = operation .request @@ -192,9 +183,7 @@ fn write_body_line(buffer: &mut Writer, operation: &PlannedOperation<'_>) { return; }; match body { - // Forwarded verbatim. PlannedRequestBody::Nested { .. } => buffer.push("body: body,\n"), - // Re-assembled from the hoisted properties, restoring the wire shape. PlannedRequestBody::FlatJson { properties, .. } => { buffer.push("body: { "); for (index, property) in properties.iter().enumerate() { @@ -214,14 +203,9 @@ fn write_body_line(buffer: &mut Writer, operation: &PlannedOperation<'_>) { } } -/// Writes the IIFE that materializes a form-body payload. -/// -/// Each field is referenced by the bare identifier the outer builder -/// destructured. -/// -/// `append` takes only a string or a `Blob`, so a scalar is wrapped in -/// `String(…)` and a binary passes through. An optional field is guarded, -/// leaving its key out when the value is absent. +/// Writes the IIFE that materializes a form-body payload. A scalar field +/// is wrapped in `String(…)`, a binary passes through, and an optional +/// one is guarded so an absent value leaves its key out. fn write_form_body(buffer: &mut Writer, fields: &[PlannedFormField<'_>], kind: FormKind) { let (constructor, variable, ts_type) = match kind { FormKind::Multipart => ("new FormData()", "fd", "FormData"), @@ -261,12 +245,8 @@ fn write_form_body(buffer: &mut Writer, fields: &[PlannedFormField<'_>], kind: F } /// Writes `path` into `buffer`, expanding each `{name}` placeholder to -/// `${encodeURIComponent(name)}`. -/// -/// Braces are balanced on any path that reaches emit — normalize's -/// `validate_path_template` rejects the rest. The unmatched-`{` branch -/// emits the remainder verbatim so adversarial IR yields wrong output -/// instead of a panic across the NAPI boundary. +/// `${encodeURIComponent(name)}`. `validate_path_template` has already +/// balanced the braces; an unmatched `{` emits the remainder verbatim. fn write_path_template_into(buffer: &mut Writer, path: &str) { let mut rest = path; while let Some(open) = rest.find('{') { @@ -410,9 +390,8 @@ mod tests { #[test] fn requestful_builder_assembles_object_literal_for_flat_json_body() { - // Smart-flatten: inline JSON object bodies hoist properties to - // top-level fields. The builder re-assembles them into an object - // literal at the `body:` slot. + // Inline JSON object bodies hoist their properties to top-level + // fields, re-assembled into an object literal at the `body:` slot. let str_ty = string_ty(); let bool_ty = SchemaType::Scalar(SchemaScalar::Boolean); let op = op_with( @@ -487,8 +466,7 @@ mod tests { render_requestful_builder(&mut buf, &op, &type_name("UploadPayloadParams")); let out = buf.into_string(); - // Non-object JSON bodies have no property structure to hoist, so they - // stay nested under `body` and forward via property shorthand. + // Non-object JSON bodies stay nested under `body`. assert!(out.contains("const { body } = request;")); assert!(out.contains("body: body,")); } @@ -554,8 +532,7 @@ mod tests { assert!(out.contains("export interface CreatePetParams")); // Ref body keeps its named type nested under the literal `body` slot. assert!(out.contains("body: CreatePetPayload;")); - // Synthetic `headers` is required when any header is required, optional - // only when all headers are optional. Mixed (one required) ⇒ required. + // Synthetic `headers` is optional only when every header is. assert!(out.contains("headers: {")); // Header names with `-` are quoted via safe_property_name. assert!(out.contains("'X-Trace-Id': string;")); @@ -748,9 +725,8 @@ mod tests { render_requestful_builder(&mut buf, &op, &type_name("OpParams")); let out = buf.into_string(); - // Form fields are destructured directly from `request` (smart-flatten - // hoists them to top-level) and referenced by bare identifier in the - // FormData appends. + // Hoisted form fields destructure from `request` and are referenced + // by bare identifier in the appends. assert!(out.contains("const { status } = request;")); assert!(out.contains("const fd = new FormData();")); assert!(out.contains("fd.append('status', String(status));")); diff --git a/src/emit/angular/service.rs b/src/emit/angular/service.rs index 4492cc6..3cd6d7c 100644 --- a/src/emit/angular/service.rs +++ b/src/emit/angular/service.rs @@ -29,8 +29,7 @@ pub(crate) fn emit_service(service_plan: &ServicePlan<'_>) -> String { buffer.close_block(""); - // Each operation's interfaces follow the class, grouped so that one - // operation's declarations stay contiguous. + // One operation's declarations stay contiguous, after the class. for operation in &service_plan.operations { if operation.request_interface.is_none() && operation.error_interface.is_none() { continue; diff --git a/src/emit/mod.rs b/src/emit/mod.rs index ad01714..a2f40e5 100644 --- a/src/emit/mod.rs +++ b/src/emit/mod.rs @@ -11,22 +11,12 @@ mod ts_tests; /// Public path of the generated TypeScript model artifact. pub(crate) const MODEL_ARTIFACT_PATH: &str = "model.generated.ts"; -/// Compile-time crate version for the do-not-edit banner. Sourced from -/// Cargo.toml (the same value `package.json:3` mirrors). +/// Crate version rendered into the do-not-edit banner. const GENERATOR_VERSION: &str = env!("CARGO_PKG_VERSION"); -/// Builds the three-line banner every generated artifact opens with: -/// -/// ```text -/// // Generated by openapi-ng vX.Y.Z -/// // Source: -/// // DO NOT EDIT — regenerate with `openapi-ng generate` -/// ``` -/// -/// A `source_path` inside the working directory is relativised against -/// it, keeping the local directory layout out of a committed artifact. -/// One outside stays absolute, as does any path when the working -/// directory is unknown. +/// Three-line banner every generated artifact opens with, followed by a +/// blank line. `source_path` renders relative when it is inside the +/// working directory, absolute otherwise. pub(crate) fn render_generated_banner(source_path: &str) -> String { let display = relativise_against_cwd(source_path); format!( @@ -34,15 +24,9 @@ pub(crate) fn render_generated_banner(source_path: &str) -> String { ) } -/// Relativise an input path against the current working directory. -/// Returns the input unchanged when: -/// - the input is not absolute (already relative — caller's call), or -/// - the host cwd is unknown (e.g. CWD was deleted), or -/// - `strip_prefix` fails (path lives outside CWD). -/// -/// Uses forward slashes on the result so banner format stays -/// platform-independent and matches `pipeline::generate`'s display-path -/// normalization. +/// Path relative to the working directory, forward-slashed. Returns the +/// input unchanged when it is already relative, when the working +/// directory is unknown, or when it is not under the working directory. fn relativise_against_cwd(source_path: &str) -> String { use crate::io::host_cwd::host_cwd; use std::path::Path; @@ -84,8 +68,6 @@ mod banner_tests { assert!(banner.contains(&format!("v{expected}"))); } - /// An absolute path inside the working directory must render relative, - /// so no local directory prefix reaches a committed artifact. #[test] fn banner_relativises_paths_inside_cwd() { let cwd = std::env::current_dir().unwrap(); @@ -102,18 +84,8 @@ mod banner_tests { ); } - /// Paths outside CWD are left absolute — `strip_prefix` fails for - /// them, and rewriting them would break the read-side path passed - /// elsewhere if anything ever shared this string. Keeps the - /// "leak limited to spec outside project root" semantics that the - /// old JS function documented. #[test] fn banner_keeps_paths_outside_cwd_absolute() { - // Pick a path that's guaranteed not to be under CWD across all - // platforms the project supports. On Unix `/nonexistent-outside-cwd` - // is absolute; on Windows the leading `/` still makes it absolute - // relative to the current drive — and it won't share the CWD prefix - // unless someone runs cargo from `/`. let outside = "/nonexistent-outside-cwd/external.yaml"; let banner = render_generated_banner(outside); assert!( @@ -122,9 +94,6 @@ mod banner_tests { ); } - /// Already-relative paths must pass through untouched. The banner - /// must not turn `./spec.yaml` into an empty string or prepend `./` - /// in surprising ways. #[test] fn banner_passes_relative_paths_through() { let banner = render_generated_banner("./spec.yaml"); @@ -134,11 +103,7 @@ mod banner_tests { ); } - /// Rust-side equivalent of the banner-strip regex used by the JS - /// snapshot test (`BANNER_RE` in __test__/generate.snapshot.spec.ts and - /// scripts/regen-snapshots.mjs). Kept here so a `proptest` round-trip - /// across the full source-path input space surfaces drift between the - /// Rust banner writer and the JS strip regex. + /// Mirrors `BANNER_RE` in `scripts/lib/snapshot-layout.ts`. fn strip_banner(input: &str) -> Option<&str> { let first = input.find('\n')?; let line1 = &input[..first]; @@ -157,8 +122,6 @@ mod banner_tests { if !line3.starts_with("// DO NOT EDIT") { return None; } - // Banner ends with a blank line (\n\n), so the next byte must be a - // newline before the artifact body begins. let after_line3 = &after_line2[third + 1..]; if !after_line3.starts_with('\n') { return None; @@ -167,11 +130,6 @@ mod banner_tests { } proptest! { - /// Any non-newline source path produces a 3-line banner that - /// `strip_banner` can remove, leaving the original body intact. This - /// pins the shape of `render_generated_banner` against the strip - /// regex on the JS side: when this proptest fails, the JS regex - /// almost certainly needs to be updated too. #[test] fn render_then_strip_banner_returns_original_body( source in "[^\n]{0,64}", diff --git a/src/emit/model/emit_ts_models.rs b/src/emit/model/emit_ts_models.rs index 64bea99..fd285e5 100644 --- a/src/emit/model/emit_ts_models.rs +++ b/src/emit/model/emit_ts_models.rs @@ -95,10 +95,8 @@ fn native_binding<'a>(mapped: &'a ResolvedMappedType<'_>) -> &'a str { .unwrap_or_else(|| mapped.ty.as_ref()) } -/// True when the binding a mapped type introduces already equals the schema -/// name it replaces. The usual `import type { Y as X }` plus -/// `export type X = X;` pair would then collide on `X`, so the pair -/// collapses to a single re-export. +/// True when the binding a mapped type introduces already equals the +/// schema name it replaces, and the pair collapses to a single re-export. fn is_self_alias(mapped: &ResolvedMappedType<'_>) -> bool { native_binding(mapped) == mapped.schema } diff --git a/src/emit/ts/imports.rs b/src/emit/ts/imports.rs index 5afbbf7..6302732 100644 --- a/src/emit/ts/imports.rs +++ b/src/emit/ts/imports.rs @@ -88,8 +88,7 @@ pub(crate) fn import_line<'a>( path: &str, statement: Statement, ) { - // Buffered so the joined width can be measured before a layout is - // chosen. + // Buffered to measure the joined width before choosing a layout. let bindings: Vec> = bindings.into_iter().collect(); let names: usize = bindings.iter().map(|binding| binding.width()).sum(); let separators = bindings.len().saturating_sub(1) * ", ".len(); diff --git a/src/emit/ts_tests.rs b/src/emit/ts_tests.rs index df2be81..6001665 100644 --- a/src/emit/ts_tests.rs +++ b/src/emit/ts_tests.rs @@ -432,7 +432,6 @@ mod tests { !body.contains("*/"), "raw */ leaked into JSDoc body: {body}" ); - // The replacement should keep the description readable. assert!( s.contains("*\\/"), "expected escaped *\\/ in output, got: {s}" diff --git a/src/error.rs b/src/error.rs index fd1c75f..b12f2ea 100644 --- a/src/error.rs +++ b/src/error.rs @@ -46,13 +46,9 @@ impl DiagnosticCode { /// One diagnostic. Severity is implicit: a fatal travels as `Err`, a /// warning through [`Reporter::warning`]. /// -/// Message convention: lead with a stage-gerund subject ("Failed to -/// decode input", "Unsupported OpenAPI semantic shape", "Failed to plan -/// services"), then state the detail, then append a sentence of -/// actionable advice when one exists ("Rename the colliding parameters -/// in the OpenAPI spec.", "Check for typos in the $ref..."). `subcode` -/// is set for `PolicyViolation` to let consumers route on a kebab-case -/// sub-class without parsing the message. +/// `message` leads with a stage-gerund subject ("Failed to decode +/// input"), then the detail, then advice when there is any. `subcode` +/// is set for `PolicyViolation`. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Diagnostic { pub code: DiagnosticCode, @@ -123,10 +119,8 @@ pub struct GeneratorDiagnostic { pub path: String, } -/// Borrowed breadcrumb naming one position of a schema walk, one variant -/// per level. -/// -/// Building one is allocation-free; only [`Context::render`] allocates. +/// Borrowed breadcrumb naming one position of a schema walk. Building one +/// is allocation-free; only [`Context::render`] allocates. #[derive(Clone, Copy)] pub(crate) enum Context<'a> { /// Top-level named schema: renders as `"schema {name}"`. @@ -153,8 +147,7 @@ pub(crate) enum Context<'a> { } impl<'a> Context<'a> { - /// Render the full breadcrumb chain into a `String`. This allocates — - /// call only when actually constructing a diagnostic message. + /// Renders the full chain. Allocates. pub(crate) fn render(&self) -> String { match self { Context::Schema(name) => format!("schema {name}"), @@ -194,9 +187,8 @@ impl Reporter { Diagnostic::new(code, message, Rc::clone(&self.path)) } - /// Records a pre-fatal warning. `subcode` is a stable kebab-case tag that - /// lets consumers route on a finer class than `code` alone; pass `None` - /// when no such subdivision applies. + /// Records a pre-fatal warning. `subcode` is a stable kebab-case tag, + /// `None` when no subdivision applies. pub(crate) fn warning( &self, code: DiagnosticCode, @@ -214,8 +206,6 @@ impl Reporter { } /// Returns a `PolicyViolation` from the enclosing function. -/// -/// `$subcode` is the stable kebab-case tag consumers route on. macro_rules! bail_policy { ($reporter:expr, $subcode:expr, $($message:tt)*) => { return ::core::result::Result::Err($crate::error::Diagnostic::policy_violation( diff --git a/src/ir/canonical.rs b/src/ir/canonical.rs index 5f99563..3dc666e 100644 --- a/src/ir/canonical.rs +++ b/src/ir/canonical.rs @@ -20,8 +20,7 @@ pub(crate) struct ModelSymbol { #[derive(Clone, Debug, Default, PartialEq, Eq)] pub(crate) struct RequestDef { pub(crate) inputs: Vec, - /// `in: header` parameters, kept apart from `inputs` because they - /// travel in a different slot of the request. + /// `in: header` parameters. Travel in a different request slot to `inputs`. pub(crate) headers: Vec, pub(crate) body: Option, } @@ -53,10 +52,8 @@ pub(crate) struct RequestBodyDef { pub(crate) content: BodyContent, } -/// An operation's request-body content. -/// -/// A form variant's `body_ref` names the source schema when the body was -/// declared as a top-level `$ref`. +/// An operation's request-body content. A form variant's `body_ref` names +/// the source schema when the body was declared as a top-level `$ref`. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) enum BodyContent { Json(SchemaType), @@ -86,10 +83,8 @@ pub(crate) enum BodyFieldType { ArrayOfBinary, } -/// The HTTP methods the generator supports. -/// -/// TRACE is absent by design: it is disabled at most production gateways, -/// and a spec declaring it is rejected with its own diagnostic. +/// The HTTP methods the generator supports. A spec declaring TRACE is +/// rejected with its own diagnostic. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum HttpMethod { Get, @@ -155,9 +150,8 @@ pub(crate) struct OperationDef { pub(crate) path: String, pub(crate) request: RequestDef, pub(crate) response: Option, - /// The 4xx and 5xx responses that declare a JSON schema, sorted by - /// status ascending. A schemaless or non-JSON error response is - /// skipped rather than rejected. + /// The 4xx and 5xx responses that declare a JSON schema, by ascending + /// status. A schemaless or non-JSON error response is skipped. pub(crate) errors: Vec, /// The OpenAPI Operation's `summary` and `description`, joined by a /// blank line. @@ -174,10 +168,8 @@ pub(crate) struct ErrorResponse { pub(crate) body: SchemaType, } -/// An operation's success-response content. -/// -/// `Json(None)` is a JSON response that declares no schema. The other -/// variants carry no payload: their type is fixed by the variant. +/// An operation's success-response content. `Json(None)` is a JSON +/// response that declares no schema. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) enum ResponseContent { Json(Option), @@ -223,18 +215,12 @@ mod tests { #[test] fn http_method_rejects_trace_so_normalize_can_emit_a_targeted_diagnostic() { - // TRACE returns None here — the strict rejection with its - // remediation message lives in `normalize_operation` (the comment - // on `from_lowercase` explains why this is split). + // TRACE returns None; `normalize_operation` raises the diagnostic. assert_eq!(HttpMethod::from_lowercase("trace"), None); } #[test] fn http_method_rejects_uppercase_and_unknown_keywords() { - // `from_lowercase` is strict about casing — the caller normalises - // the method string before invoking this. Asserting the strictness - // pins the contract so a refactor doesn't silently start accepting - // mixed-case input. assert_eq!(HttpMethod::from_lowercase("GET"), None); assert_eq!(HttpMethod::from_lowercase("Get"), None); assert_eq!(HttpMethod::from_lowercase("connect"), None); @@ -292,7 +278,6 @@ mod tests { let text = ResponseContent::Text; let array_buffer = ResponseContent::ArrayBuffer; - // Json variant carries an Option; others carry no payload. assert!(matches!(json_with_schema, ResponseContent::Json(Some(_)))); assert!(matches!(json_without, ResponseContent::Json(None))); assert!(matches!(blob, ResponseContent::Blob)); diff --git a/src/ir/normalize/operations/form.rs b/src/ir/normalize/operations/form.rs index ef3b908..be61523 100644 --- a/src/ir/normalize/operations/form.rs +++ b/src/ir/normalize/operations/form.rs @@ -45,10 +45,8 @@ pub(super) enum FormKind { UrlEncoded, } -/// Why a form body or one of its fields was rejected. -/// -/// Paired with a [`FormKind`] it names the stable subcode a consumer can -/// route on without parsing the message. +/// Why a form body or one of its fields was rejected. Paired with a +/// [`FormKind`] it names the stable subcode consumers route on. #[derive(Clone, Copy)] pub(super) enum Reject { /// The declared body schema does not resolve to an object. @@ -86,16 +84,12 @@ impl FormKind { } } -/// Subcode for a binary field in a urlencoded body, which the format has -/// no encoding for. Covers the scalar and the array case alike. +/// Subcode for a binary field in a urlencoded body, scalar or array. const URLENCODED_BINARY_FIELD: &str = "urlencoded-binary-field"; -/// Flattens a form body's schema into an alphabetically sorted field list. -/// -/// The returned name is `Some` when the body was declared as a top-level -/// `$ref`, resolved through `schema_index`. `format: binary` is detected -/// only for an inline body: a `$ref` target's raw schema does not reach -/// this layer. +/// Flattens a form body's schema into an alphabetically sorted field +/// list. The returned name is `Some` when the body was declared as a +/// top-level `$ref`. `format: binary` is detected for an inline body only. pub(super) fn normalize_form_body_fields( media: &MediaType, body: FormBody<'_>, @@ -115,8 +109,8 @@ pub(super) fn normalize_form_body_fields( ) })?; - // `additionalProperties: false` and the absent case leave the field set - // closed; every other form of it leaves the body open-ended. + // Only `additionalProperties: false` and its absence leave the field + // set closed. if let Some(ap) = &raw_schema.additional_properties && !matches!(ap, AdditionalProperties::Boolean(false)) { @@ -189,8 +183,8 @@ struct RawPropertyFormat<'a> { items: Option<&'a str>, } -/// Collects the per-property `format` hints, which `SchemaType` does not -/// carry. Empty when the body is a top-level `$ref`. +/// Collects the per-property `format` hints `SchemaType` does not carry. +/// Empty when the body is a top-level `$ref`. fn collect_raw_property_formats(raw_schema: &Schema) -> BTreeMap<&str, RawPropertyFormat<'_>> { let mut lookup = BTreeMap::new(); let Some(properties) = &raw_schema.properties else { @@ -207,10 +201,8 @@ fn collect_raw_property_formats(raw_schema: &Schema) -> BTreeMap<&str, RawProper lookup } -/// Classifies one form-body property. -/// -/// Accepts a scalar, an array of scalars, a binary, and an array of -/// binaries; every other shape fails with the matching [`Reject`]. +/// Classifies one form-body property. Accepts a scalar, a binary, or an +/// array of either; every other shape fails with the matching [`Reject`]. fn classify_body_field_type( ty: &SchemaType, raw_format: RawPropertyFormat<'_>, @@ -234,7 +226,6 @@ fn classify_body_field_type( ), )), }, - // An array of binary is detected through the item's `format` hint. SchemaType::Array(inner) if matches!(inner.as_ref(), SchemaType::Scalar(SchemaScalar::String)) && raw_format.items == Some("binary") => @@ -270,8 +261,6 @@ fn classify_body_field_type( kind.label(), ), )), - // Composition, nullable, map, non-string enum and `Any` all report - // as composed. _ => Err(Diagnostic::policy_violation( reporter, kind.subcode(Reject::ComposedField), @@ -335,7 +324,6 @@ content: BodyContent::Multipart { body_ref, fields } => { assert_eq!(body_ref, None); let names: Vec<&str> = fields.iter().map(|f| f.name.as_str()).collect(); - // Sorted alphabetically. assert_eq!(names, vec!["avatar", "nickname", "status", "tagIds"]); let avatar = fields.iter().find(|f| f.name.as_str() == "avatar").unwrap(); assert_eq!(avatar.ty, BodyFieldType::Binary); diff --git a/src/ir/normalize/schema/mod.rs b/src/ir/normalize/schema/mod.rs index 9d14f83..b2ecd53 100644 --- a/src/ir/normalize/schema/mod.rs +++ b/src/ir/normalize/schema/mod.rs @@ -119,9 +119,8 @@ pub(super) fn normalize_schema( Ok(apply_nullable_flag(base, schema.nullable.unwrap_or(false))) } -/// Dispatches on the schema's shape. The caller folds in `nullable`; this is -/// the single chokepoint for the depth guard, because every shape either -/// bottoms out or descends through a [`SchemaWalk`]. +/// Dispatches on the schema's shape; the caller folds in `nullable`. The +/// single chokepoint for the depth guard. fn normalize_type(schema: &Schema, walk: SchemaWalk<'_>) -> Result { walk.check_depth()?; warn_dropped_format(schema, walk); diff --git a/src/ir/normalize/semantic.rs b/src/ir/normalize/semantic.rs index ca589d3..70d8a19 100644 --- a/src/ir/normalize/semantic.rs +++ b/src/ir/normalize/semantic.rs @@ -1,5 +1,5 @@ -//! The semantic pass that runs once schema and operation lowering are -//! done: schema sorting, discriminator narrowing and `$ref` validation. +//! Schema sorting, discriminator narrowing and `$ref` validation, run +//! once schema and operation lowering are done. use std::collections::{BTreeMap, BTreeSet}; @@ -20,8 +20,7 @@ pub(super) fn finalize(model: &mut ApiModel, reporter: &Reporter) -> Result<(), } /// Narrows each discriminated union member's discriminator property to a -/// single-value string literal, which is what lets the TypeScript compiler -/// narrow the union to a concrete member. +/// single-value string literal. /// /// Fails with `missing-discriminator-property` when a member does not /// declare the property, and `discriminator-property-must-be-string` when @@ -30,8 +29,6 @@ fn narrow_discriminator_properties( symbols: &mut [ModelSymbol], reporter: &Reporter, ) -> Result<(), Diagnostic> { - // Per member: the literal value to assign to each of its discriminator - // properties. let mut narrowings: BTreeMap, BTreeMap, Box>> = BTreeMap::new(); for symbol in symbols.iter() { if let SchemaType::Union { @@ -42,9 +39,8 @@ fn narrow_discriminator_properties( { for member in members { if let SchemaType::Ref(schema_name) = member { - // A `discriminator.mapping` entry whose target is this member - // supplies the wire value; without one it is the lowercased - // schema name. + // A `mapping` entry for this member supplies the wire value; + // without one it is the lowercased schema name. let literal_value: Box = discriminator .mapping .iter() @@ -66,8 +62,8 @@ fn narrow_discriminator_properties( return Ok(()); } - // Validate every member before mutating any of them, so a rejected - // spec leaves the model untouched. + // Validate every member before mutating any: a rejected spec leaves + // the model untouched. let by_name: BTreeMap<&str, &SchemaType> = symbols .iter() .map(|symbol| (symbol.name.as_ref(), &symbol.body)) @@ -99,8 +95,8 @@ fn narrow_discriminator_properties( } } - // Only inline objects are mutated: a `Ref` member is narrowed when the - // symbol it names is reached by this same loop. + // Only inline objects are mutated; a `Ref` member is narrowed when + // this loop reaches the symbol it names. for symbol in symbols.iter_mut() { let Some(props) = narrowings.get(&symbol.name) else { continue; @@ -145,10 +141,8 @@ const fn is_string_discriminator_shape(ty: &SchemaType) -> bool { } /// Narrows the named property to a single-value string literal, returning -/// whether it was found. -/// -/// A property reachable only through a `$ref` is left alone: it keeps its -/// declared `string` type, which still compiles but narrows only partly. +/// whether it was found. A property reachable only through a `$ref` keeps +/// its declared `string` type. fn narrow_property_in_body(body: &mut SchemaType, name: &str, literal_value: &str) -> bool { match body { SchemaType::InlineObject { properties } => { @@ -198,17 +192,14 @@ fn validate_references(document: &ApiModel, reporter: &Reporter) -> Result<(), D if let Some(body) = &operation.request.body { match &body.content { BodyContent::Json(ty) => collect_type_references(ty, &mut refs), - // A form body's fields are typed by `BodyFieldType`, which - // carries no schema reference; its `body_ref` was resolved - // against the schema index at lowering time. + // `BodyFieldType` carries no schema reference; `body_ref` was + // resolved at lowering time. BodyContent::Multipart { .. } | BodyContent::UrlEncoded { .. } => {} } } if let Some(response) = &operation.response { match response { ResponseContent::Json(Some(ty)) => collect_type_references(ty, &mut refs), - // `Json(None)` carries no schema, and the other variants carry - // no payload. ResponseContent::Json(None) | ResponseContent::Blob | ResponseContent::Text @@ -300,7 +291,6 @@ mod tests { let SchemaType::Intersection(parts) = &cat.body else { panic!("Cat body should remain Intersection"); }; - // The InlineObject part should now have kind narrowed to 'cat'. let kind_ty = parts .iter() .find_map(|part| match part { @@ -321,10 +311,8 @@ mod tests { #[test] fn validates_discriminator_via_ref_in_intersection() { - // Cat: allOf: [Animal] where only Animal declares 'kind'. Validation - // walks into the referenced Animal and finds 'kind' there — no - // missing-property diagnostic. (Mutation is partial in this shape; - // the validation pass is the security-relevant guarantee.) + // Cat: allOf: [Animal], where only Animal declares 'kind'. + // Validation walks into Animal and finds it; mutation stays partial. let mut symbols = vec![ symbol( "Animal", @@ -412,9 +400,7 @@ mod tests { #[test] fn accepts_string_literals_discriminator_property() { - // A spec that already constrains the discriminator to a single - // literal is fine — the mutation simply rewrites to the canonical - // single-value form. + // An already single-literal discriminator rewrites to the same shape. let mut symbols = vec![ symbol( "Cat", diff --git a/src/ir/schema.rs b/src/ir/schema.rs index b4a41de..657296c 100644 --- a/src/ir/schema.rs +++ b/src/ir/schema.rs @@ -5,40 +5,26 @@ pub(crate) struct SchemaProperty { pub(crate) name: Box, pub(crate) required: bool, pub(crate) ty: SchemaType, - /// Description carried over from `Schema.description` of the property's - /// schema. Emitted as a JSDoc comment above the property declaration in - /// named TypeScript interfaces. Not emitted inside inline-object types - /// (where the multi-line comment would dominate the type expression). + /// Emitted as JSDoc above the declaration in named interfaces only. pub(crate) description: Option, - /// Source property schema's OpenAPI `deprecated: true`. Surfaces as - /// `@deprecated` in the JSDoc above the property declaration in named - /// interfaces — invisible inside inline-object positions (where no - /// JSDoc is emitted) to keep parity with how `description` behaves. + /// Emitted as `@deprecated` in named interfaces only. pub(crate) deprecated: bool, } #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) enum SchemaType { - /// OpenAPI "any" schema — a schema with no constraints (no `type`, no - /// `$ref`, no composition). Renders as TS `unknown`. + /// Schema with no `type`, `$ref` or composition. Renders as `unknown`. Any, Scalar(SchemaScalar), Array(Box), Map(Box), - /// Literal-union vehicle (TS `'a' | 'b'`). Used both as a top-level - /// `ModelSymbol.body` (renders as `export type X = 'a' | 'b'`) and as - /// an anonymous in-place form inside compositions or for the synthetic - /// single-value narrowings produced by `narrow_discriminator_properties`. + /// Literal union, `'a' | 'b'`. StringLiterals { values: Vec, }, Ref(Box), - /// Type composition (`oneOf`/`anyOf`). `discriminator` is `Some(info)` - /// when this comes from an OpenAPI `oneOf` with a discriminator; the - /// semantic-finalize pass (`narrow_discriminator_properties`) reads it - /// to rewrite each member's discriminator property to a single-value - /// string literal so the TypeScript compiler can narrow the union to - /// the concrete member type. + /// `oneOf`/`anyOf`. `discriminator` is set only for a discriminated + /// `oneOf`; `narrow_discriminator_properties` consumes it. Union { members: Vec, discriminator: Option, @@ -47,21 +33,14 @@ pub(crate) enum SchemaType { InlineObject { properties: Vec, }, - /// `nullable: true` carrier. Wraps any other variant; surfaces as - /// ` | null` in TS. The single canonical representation for nullability — - /// neither `SchemaProperty` nor `Union` carry a separate `nullable` flag. + /// Wraps any other variant; renders as ` | null`. The only + /// representation of nullability in the IR. Nullable(Box), } -/// IR-side discriminator carrier. `property_name` is the OpenAPI -/// `discriminator.propertyName`. `mapping` is a pre-resolved -/// wire-value → bare schema-name map: OpenAPI mapping values may be a -/// full `#/components/schemas/X` ref or a bare name, but both shapes -/// are normalized to bare names at IR-build time so the semantic pass -/// can match against `SchemaType::Ref` payloads with a single -/// `mapping.iter().find(...)` lookup. Empty when the source spec omits -/// `mapping` — the fallback `schema_name.to_ascii_lowercase()` literal -/// then applies. +/// `mapping` holds wire value → bare schema name; `#/components/schemas/X` +/// refs are reduced to bare names when the IR is built. Empty when the +/// spec omits `mapping`, and `schema_name.to_ascii_lowercase()` applies. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct Discriminator { pub(crate) property_name: Box, diff --git a/src/parse/input.rs b/src/parse/input.rs index f39391b..0ddc80e 100644 --- a/src/parse/input.rs +++ b/src/parse/input.rs @@ -61,10 +61,7 @@ pub(crate) fn decode_openapi_input( } /// Decodes a spec supplied as source text, failing when it exceeds the -/// input byte cap. -/// -/// Without a `hint` the format is sniffed, since there is no file -/// extension to dispatch on. +/// input byte cap. Without a `hint` the format is sniffed. pub(crate) fn decode_input_contents( source: &str, hint: Option, @@ -109,8 +106,6 @@ pub(crate) fn decode_openapi_input_with_hint( .and_then(|ext| ext.to_str()) .map(str::to_ascii_lowercase); - // Both decoders' `Display` already ends in "at line X column Y", which - // every message below forwards verbatim. match extension.as_deref() { Some("json") => serde_json::from_str(source).map_err(|error| { Diagnostic::new( @@ -135,16 +130,9 @@ pub(crate) fn decode_openapi_input_with_hint( } } -/// Decodes YAML into an `OpenApiDocument`. -/// -/// Repeated mapping keys are rejected by the model's `UniqueMap` / -/// `UniqueIndexMap` fields during this single typed parse; a repeat under -/// `components.schemas` is reported with the `duplicate-schema-name` -/// subcode, and every other position keeps the decode error verbatim -/// (serde already prints the field path and the source line and column). -/// -/// The anchor-expansion guard runs only when the source contains `&`, -/// without which no alias can expand. +/// Decodes YAML into an `OpenApiDocument`. Repeated mapping keys are +/// rejected by the model's `UniqueMap` fields. The anchor-expansion +/// guard runs only when the source contains `&`. fn decode_yaml(source: &str, display_path: &Rc) -> Result { if source.contains('&') { check_anchor_expansion(source, display_path)?; @@ -178,11 +166,9 @@ fn decode_failure(message: &str, display_path: &Rc) -> Diagnostic { /// `components.schemas`. const SCHEMAS_FIELD_PATH: &str = "components.schemas"; -/// Rejects a source whose YAML aliases expand far beyond its own size. -/// -/// Measures the parsed node tree by re-serialising it, which inlines every -/// alias. A source this cannot parse or re-serialise passes, leaving the -/// typed parse to report the real error. +/// Rejects a source whose YAML aliases expand far beyond its own size, +/// measured by re-serialising the node tree. A source this cannot parse +/// passes, leaving the typed parse to report the error. fn check_anchor_expansion(source: &str, display_path: &Rc) -> Result<(), Diagnostic> { let Ok(value) = serde_yml::from_str::(source) else { return Ok(()); @@ -244,9 +230,6 @@ mod tests { let _ = fs::remove_file(path); } - // Every decode message forwards the parser's own position suffix - // verbatim. A parser upgrade that drops it fails here rather than - // silently costing spec authors the line number. #[test] fn decode_error_for_malformed_json_includes_line_and_column() { let path = PathBuf::from("spec.json"); @@ -277,8 +260,6 @@ mod tests { ); } - // Inline-source variant of the fixture test below, so the behaviour is - // pinned independently of the file on disk. #[test] fn duplicate_schema_name_in_yaml_is_diagnosed() { let yaml = r#" @@ -314,7 +295,6 @@ components: assert_eq!(err.subcode, Some("duplicate-schema-name")); } - // --- size-cap tests --- #[test] fn rejects_input_larger_than_cap() { @@ -330,7 +310,6 @@ components: fs::create_dir_all(&dir).unwrap(); let path = dir.join("huge.yaml"); - // Write 17 MiB of content so the cap fires before any parse attempt. let header = "openapi: 3.0.3\ninfo: { title: x, version: 1.0.0 }\npaths: {}\n# "; let pad_bytes = (17 * 1024 * 1024) - header.len(); let mut content = String::with_capacity(17 * 1024 * 1024); @@ -354,9 +333,6 @@ components: #[test] fn anchor_expansion_within_ratio_accepts() { - // A handful of aliases on a small anchor stays well under the default - // 50× expansion cap. Pins that legitimate anchor use is not regressed - // by the guard. let yaml = r#"openapi: 3.0.3 info: { title: modest-anchor, version: '1.0.0' } paths: {} @@ -378,12 +354,8 @@ components: #[test] fn anchor_expansion_exceeding_ratio_rejects() { - // Construct a YAML where the anchor body × alias count blows past the - // 50× ratio cap on re-serialisation. 500 A-rows × 16 aliases each × - // a ~250-byte body re-serialises into ~2 MB from a ~30 KB source - // (~70× ratio). The check is independent of the OnceLock-cached cap - // because the cap setter is `max_expansion_ratio()`; this test - // exercises the same path the cached value would. + // 500 rows × 16 aliases × ~250-byte body: ~30 KB source, ~2 MB + // re-serialised, past the 50× cap. let mut yaml = String::from( "openapi: 3.0.3\ninfo:\n title: Fanout\n version: 1.0.0\npaths: {}\ncomponents:\n schemas:\n Base: &b\n type: object\n properties:\n", ); @@ -416,9 +388,6 @@ components: ); } - // Anchor-free YAML decodes cleanly and lands every schema. Structural - // only: it does not observe whether the `&` gate skipped the - // re-serialisation, which `bun run bench` covers. #[test] fn anchor_free_yaml_decodes_successfully() { let mut yaml = String::from( @@ -429,7 +398,6 @@ components: " S{i:03}:\n type: object\n properties:\n id: {{ type: string }}\n name: {{ type: string }}\n", )); } - // Sanity-check the precondition: the source contains no anchor markers. assert!( !yaml.contains('&'), "fixture must be anchor-free to exercise the fast path", @@ -451,8 +419,6 @@ components: use super::decode_openapi_input_with_hint; use crate::bindings::InputFormat; - // File named .yaml but contents are valid JSON. With the hint we - // skip extension lookup and decode as JSON directly. let path = PathBuf::from("misnamed.yaml"); let display: Rc = Rc::from("misnamed.yaml"); let json_source = @@ -480,7 +446,7 @@ components: .expect("clock works") .as_nanos(); let path = std::env::temp_dir().join(format!("oapi-ng-noext-{nanos}")); // no extension - // Use a tab character inside a flow mapping — syntactically invalid in both JSON and YAML. + // A tab inside a flow mapping: invalid in both JSON and YAML. fs::write(&path, "{\t\"key\": [}").unwrap(); let path_str = path.to_str().expect("utf-8 path"); @@ -489,11 +455,7 @@ components: let _ = fs::remove_file(&path); let msg = &err.message; - // The "Rename" hint must still be present. assert!(msg.contains("Rename"), "missing Rename hint: {msg}"); - // The underlying parser error info should be there too — serde_yml includes - // "line" and "column" in its Display output so authors can jump to the - // offending byte without re-parsing by hand. assert!( msg.contains("line ") && msg.contains("column "), "expected line/column from parser in message: {msg}", @@ -505,8 +467,6 @@ components: use super::decode_openapi_input_with_hint; use crate::bindings::InputFormat; - // A JSON-shaped map also parses as flow-style YAML, so the source - // has to be one YAML rejects: a tab inside a flow mapping. let path = PathBuf::from("ambiguous"); let display: Rc = Rc::from("ambiguous"); let source = "{\t\"openapi\": \"3.0.3\"}"; @@ -525,9 +485,7 @@ components: use super::decode_openapi_input_with_hint; use crate::bindings::InputFormat; - // No path extension and no Content-Type — but with an explicit - // Json hint the decoder should still succeed. This is the URL-input - // shape where the JS wrapper hands us inputContents + an empty path. + // The URL-input shape: no extension, no Content-Type, explicit hint. let path = PathBuf::from(""); let display: Rc = Rc::from("https://example.com/openapi"); let source = r#"{"openapi":"3.0.3","info":{"title":"NoExt","version":"1.0.0"},"paths":{}}"#; @@ -540,8 +498,6 @@ components: fn decode_input_contents_enforces_byte_cap() { use super::decode_input_contents; - // Build a string larger than the default 16 MiB cap: 17 MiB of 'a' - // padding inside an otherwise-valid YAML header. let header = "openapi: 3.0.3\ninfo: { title: Big, version: 1.0.0 }\npaths: {}\n# "; let pad_bytes = (17 * 1024 * 1024) - header.len(); let mut content = String::with_capacity(17 * 1024 * 1024); @@ -589,14 +545,12 @@ mod proptests { proptest! { #![proptest_config(ProptestConfig { - // Keep iteration count reasonable for CI — boundary fuzzing doesn't need millions. cases: 256, ..ProptestConfig::default() })] #[test] fn read_and_decode_never_panics(bytes in proptest::collection::vec(any::(), 0..16384)) { - // Write to a unique temp file per case so concurrent property invocations don't collide. let dir = std::env::temp_dir().join(format!( "oapi-ng-prop-decode-{}-{}", std::process::id(), @@ -606,7 +560,6 @@ mod proptests { .as_nanos(), )); std::fs::create_dir_all(&dir).unwrap(); - // Pick an extension at random-ish to exercise both code paths. let ext = if bytes.len() % 2 == 0 { "yaml" } else { "json" }; let path = dir.join(format!("input.{ext}")); std::fs::write(&path, &bytes).unwrap(); @@ -616,7 +569,6 @@ mod proptests { let result = read_and_decode(path_str, &display); let _ = std::fs::remove_dir_all(&dir); - // Property: never panic. Either Ok, or Err with a typed code. if let Err(diag) = result { prop_assert!( matches!(diag.code, DiagnosticCode::InputInvalid | DiagnosticCode::PolicyViolation), diff --git a/src/parse/policy.rs b/src/parse/policy.rs index 15ebf39..b84c6b1 100644 --- a/src/parse/policy.rs +++ b/src/parse/policy.rs @@ -186,9 +186,7 @@ mod cap_tests { use super::validate_generation_policy; - // Build an OpenAPI YAML document on the fly with N empty-object schemas - // under components.schemas. Used to assert the schema-cap fires at the - // configured boundary. + // An OpenAPI document with N empty-object schemas. fn build_doc_with_schemas(n: usize) -> String { let mut s = String::from( "openapi: 3.0.3\ninfo:\n title: Bulk\n version: 1.0.0\npaths: {}\ncomponents:\n schemas:\n", diff --git a/src/pipeline.rs b/src/pipeline.rs index c7d0f1a..83bd7ae 100644 --- a/src/pipeline.rs +++ b/src/pipeline.rs @@ -34,7 +34,6 @@ pub(crate) fn build_ir( (None, Some(contents)) => { crate::parse::decode_input_contents(contents, config.input_format, display_path)? } - // `validate_generate_config` has already rejected both-or-neither. _ => { return Err(Diagnostic::new( crate::error::DiagnosticCode::InvalidOption, @@ -49,14 +48,12 @@ pub(crate) fn build_ir( } pub fn execute_generate(config: GenerateConfig) -> Result { - // Sentinel path that forces a panic, so the `catch_unwind` at the NAPI - // boundary is exercised by a real call. Present in release builds. + // Sentinel path that forces a panic, exercising the `catch_unwind` at + // the NAPI boundary. Present in release builds. if config.input_path.as_deref() == Some("__panic_for_test__") { panic!("test sentinel: forced panic"); } - // An explicit `display_path` wins; otherwise the input path with its - // separators normalised. let display_path: Rc = config.display_path.as_deref().map_or_else( || { config.input_path.as_deref().map_or_else( @@ -348,8 +345,8 @@ mod tests { }) .expect("generation succeeds"); - // The artifact list has no `errors.generated.ts` — error interfaces - // live alongside `*Params` inside the per-tag service file. + // Error interfaces live in the per-tag service file, so there is no + // `errors.generated.ts`. assert!( !result .artifacts @@ -364,22 +361,16 @@ mod tests { .find(|a| a.path == "rest/pet.rest.generated.ts") .expect("pet service emitted"); - // Per-status pairs render verbatim; numeric keys; refs to model types - // resolve through the existing model import (no extra import block). assert!(service.contents.contains("export interface UpdatePetError")); assert!(service.contents.contains("400: ValidationProblem;")); assert!(service.contents.contains("404: NotFound;")); assert!(service.contents.contains("500: {")); assert!(service.contents.contains("traceId: string;")); - // 503 declared no JSON content — silently skipped. + // 503 declared no JSON content. assert!(!service.contents.contains("503:")); - // `default` key intentionally not surfaced. assert!(!service.contents.contains("default:")); - // The same model import that already serves `*Params` also covers - // the error body refs. The nested `body: UpdatePetRequest` field - // contributes that ref, so the deduplicated, alphabetised import - // line carries it alongside the response type (`Pet`) and the - // error-body refs. + // One deduplicated, alphabetised import line carries the response + // type, the `body:` ref and the error-body refs. assert!( service .contents @@ -406,8 +397,7 @@ mod tests { }; let result = execute_generate(config).expect("inputContents pipeline must succeed"); assert_eq!(result.summary.title, "Inline Test"); - // display_path is the supplied URL verbatim — no slash-normalisation, - // no path resolution. + // display_path is the supplied URL verbatim. assert_eq!( result.summary.normalized_source_path, "https://example.com/spec.yaml", diff --git a/src/plan/artifact_plan.rs b/src/plan/artifact_plan.rs index 9a85624..6fddd71 100644 --- a/src/plan/artifact_plan.rs +++ b/src/plan/artifact_plan.rs @@ -16,9 +16,8 @@ use super::{ }; /// A [`MappedType`] whose `schema` was found in the IR, borrowed from the -/// model symbol that matched. -/// -/// Only [`validate_mapped_types_against_schemas`] constructs one. +/// model symbol that matched. Only +/// [`validate_mapped_types_against_schemas`] constructs one. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct ResolvedMappedType<'a> { pub(crate) schema: &'a str, @@ -67,10 +66,8 @@ pub(crate) struct PlannedOperation<'ir> { pub(crate) deprecated: bool, } -/// Which slot of the HTTP request a [`PlannedRequestField`] fills. -/// -/// `Body` marks a property hoisted out of an inline JSON body; a nested -/// body has no fields of this kind. +/// Which slot of the HTTP request a [`PlannedRequestField`] fills. `Body` +/// marks a property hoisted out of an inline JSON body. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum RequestFieldKind { Path, @@ -80,8 +77,8 @@ pub(crate) enum RequestFieldKind { #[derive(Debug, PartialEq, Eq)] pub(crate) struct PlannedRequestContract<'ir> { - /// Path and query parameters. A hoisted body property lives on - /// [`PlannedRequestBody::FlatJson`], not here. + /// Path and query parameters; a hoisted body property lives on + /// [`PlannedRequestBody::FlatJson`]. pub(crate) fields: Vec>, /// Header parameters, empty when the operation declares none. pub(crate) headers: Vec>, @@ -115,14 +112,10 @@ pub(crate) struct PlannedFormField<'ir> { /// How a request body is laid out on the request contract. #[derive(Debug, PartialEq, Eq)] pub(crate) enum PlannedRequestBody<'ir> { - /// A JSON body with no properties to hoist: a top-level `$ref`, or a - /// scalar, array or union. Keeps the spec author's type under one - /// `body` key. + /// A top-level `$ref`, scalar, array or union, under one `body` key. Nested { ty: &'ir SchemaType, optional: bool }, /// An inline JSON object body, its properties hoisted to top level. - /// - /// Each `optional` already folds in the envelope's `required`: under a - /// `required: false` body every property is optional. + /// Each `optional` already folds in the envelope's `required`. FlatJson { properties: Vec>, required: bool, @@ -470,8 +463,7 @@ mod tests { .expect("service plan resolves"); assert_eq!(services.len(), 2); - // Services are sorted alphabetically by class_name (AdoptionRequestRest - // sorts before PetRest), regardless of the discovery order in the spec. + // Services sort by `class_name`, not by discovery order. assert_eq!( services .iter() @@ -524,9 +516,8 @@ mod tests { #[test] fn resolve_service_plans_keeps_ref_bodies_nested_under_smart_flatten() { - // Smart-flatten preserves a body that's authored as a `$ref` even when - // that ref resolves to an `InlineObject` schema — the spec author's - // named type is the signal we honor. + // A body authored as a `$ref` stays nested even when the ref + // resolves to an `InlineObject`. let model_symbols = vec![ ModelSymbol { name: "PetId".into(), diff --git a/src/plan/naming/fixed.rs b/src/plan/naming/fixed.rs index b1dabe4..1891988 100644 --- a/src/plan/naming/fixed.rs +++ b/src/plan/naming/fixed.rs @@ -15,9 +15,6 @@ pub(crate) fn service_file_stem(group: &str) -> String { /// PascalCase name of the interface carrying an operation's path, query, /// header and body fields, e.g. `listPets` → `ListPetsParams`. -/// -/// Suffixed `Params`: a spec may already declare a schema named -/// `Request`. pub(crate) fn request_interface_name(method_name: &MethodName) -> TypeName { TypeName::new(format!( "{}Params", @@ -27,8 +24,6 @@ pub(crate) fn request_interface_name(method_name: &MethodName) -> TypeName { /// PascalCase name of the interface mapping an operation's 4xx/5xx statuses /// to their body types, e.g. `updatePet` → `UpdatePetError`. -/// -/// Read at the call site as `UpdatePetError[400]`. pub(crate) fn error_interface_name(method_name: &MethodName) -> TypeName { TypeName::new(format!( "{}Error", @@ -85,18 +80,10 @@ mod tests { ); } - // - // service_class_name and service_file_stem are pure case-conversions - // over arbitrary tag strings sourced from the spec. The example tests - // above pin representative cases; the properties below assert global - // invariants so adversarial inputs (whitespace, control chars, unicode) - // can't sneak in malformed identifiers / file stems. use proptest::prelude::*; - /// First char must satisfy TS IdentifierStart (we restrict to ASCII - /// alphabetic + `_` + `$`); subsequent chars must be IdentifierPart. - /// Matches `is_ident` in `emit::typescript`. + /// ASCII-only TS identifier shape, matching `ident::is_ident`. fn is_ts_identifier(value: &str) -> bool { let mut chars = value.chars(); let Some(first) = chars.next() else { @@ -108,10 +95,8 @@ mod tests { chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$') } - /// Result of `service_file_stem` should be kebab-case ASCII: lowercase - /// letters, digits, and hyphens, with no leading/trailing hyphen and no - /// consecutive hyphens. Returns true for the empty string (e.g. when the - /// input was all non-alphanumeric). + /// Kebab-case ASCII: no leading, trailing or repeated hyphen. The empty + /// string passes. fn is_kebab_case_ascii(value: &str) -> bool { if value.is_empty() { return true; @@ -136,11 +121,6 @@ mod tests { } proptest! { - /// `service_class_name` is only fed values that survive the - /// `tag_first_operation_grouper` policy check (tags non-empty after - /// trim, ASCII identifier-shaped). We test the policy-clean subset - /// here — alphabetic tags with optional hyphens/underscores — because - /// that's the surface the rest of the planner actually sees. #[test] fn service_class_name_emits_valid_ts_identifier_with_rest_suffix( tag in "[a-zA-Z][a-zA-Z0-9_-]{0,31}" @@ -154,12 +134,6 @@ mod tests { ); } - /// Scoped to ASCII tags because the policy layer in - /// `tag_first_operation_grouper` rejects any operation whose tag - /// would not produce a valid Angular-style file stem. Non-ASCII - /// inputs to `service_file_stem` are reachable in code but never in - /// practice — locking the kebab-case invariant on the ASCII subset - /// is what consumers actually rely on. #[test] fn service_file_stem_produces_kebab_case_or_empty_for_ascii_tags( tag in "[ -~]{0,32}" diff --git a/src/plan/services/body.rs b/src/plan/services/body.rs index 852b8da..27c9600 100644 --- a/src/plan/services/body.rs +++ b/src/plan/services/body.rs @@ -66,9 +66,8 @@ fn plan_form_fields<'ir>(fields: &'ir [BodyField]) -> Vec> } /// Fails when a hoisted body field name clashes with a path or query -/// parameter already on `fields`. -/// -/// A nested body has nothing to clash: it occupies the single `body` key. +/// parameter already on `fields`. A nested body occupies the single +/// `body` key and cannot clash. pub(super) fn check_body_field_collisions( fields: &[PlannedRequestField], body: Option<&PlannedRequestBody>, @@ -161,7 +160,6 @@ mod tests { assert!(!required, "envelope marked optional in fixture"); assert_eq!(properties.len(), 1); assert_eq!(properties[0].name.as_ref(), "status"); - // Required property under an optional envelope ⇒ field is optional. assert!(properties[0].optional); } other => panic!("expected FlatJson, got {other:?}"), @@ -349,8 +347,7 @@ mod tests { } other => panic!("expected multipart body, got {other:?}"), } - // Path/query field list stays empty in this fixture; form fields - // hoist to top-level via the body slot, not via `fields`. + // Form fields hoist through the body slot, not through `fields`. assert!(op.request.fields.is_empty()); } @@ -371,9 +368,8 @@ mod tests { #[test] fn form_field_name_collision_with_path_param_emits_field_collision() { - // Path has {fileName} and the multipart body has a `fileName` field; - // smart-flatten hoists form fields to top-level so the duplicate - // surfaces on the request interface — reject at planning time. + // `{fileName}` in the path and a `fileName` form field would + // collide on the request interface. let ir = api_model_with_form_collision(); let ctx = test_reporter(); let err = resolve_service_plans(&ir, &NamingResolver::default(), &ctx) @@ -384,10 +380,8 @@ mod tests { #[test] fn multipart_ref_body_still_flattens_fields_under_smart_rule() { - // Even when the multipart body carries a named source schema, we - // can't render the schema's name as a TS type — `BodyFieldType` - // (Blob | File, …) does not compose into the source `SchemaType`. - // So multipart bodies always flatten regardless of `body_ref`. + // Multipart always flattens: `BodyFieldType` does not compose + // into the source `SchemaType`, named schema or not. let ir = api_model_with_multipart_ref_body("UploadForm"); let ctx = test_reporter(); let services = resolve_service_plans(&ir, &NamingResolver::default(), &ctx).expect("ok"); From ee6f6ef39a21406b9554400b89a72d95154456f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andrzej=20=C5=9Awi=C4=99tek?= Date: Tue, 8 Sep 2026 18:46:19 +0200 Subject: [PATCH 07/11] Parallelised the independent awaits in the browser entry --- __test__/generate.spec.ts | 6 +++--- lib/browser.js | 6 ++++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/__test__/generate.spec.ts b/__test__/generate.spec.ts index d115588..a8baf82 100644 --- a/__test__/generate.spec.ts +++ b/__test__/generate.spec.ts @@ -1395,9 +1395,9 @@ test('generate produces byte-identical output across repeated calls (determinism // including banner). const fixtures = ['petstore-rich.openapi.yaml', 'bench-large.openapi.yaml']; for (const name of fixtures) { - const first = await generate({ inputPath: fixture(name), emit: [...DEFAULT_EMIT] }); - const second = await generate({ inputPath: fixture(name), emit: [...DEFAULT_EMIT] }); - const third = await generate({ inputPath: fixture(name), emit: [...DEFAULT_EMIT] }); + const [first, second, third] = await Promise.all( + [0, 1, 2].map(() => generate({ inputPath: fixture(name), emit: [...DEFAULT_EMIT] })), + ); t.deepEqual(first, second, `${name}: run 1 vs run 2 must be byte-identical`); t.deepEqual(second, third, `${name}: run 2 vs run 3 must be byte-identical`); } diff --git a/lib/browser.js b/lib/browser.js index 2362460..7c02bec 100644 --- a/lib/browser.js +++ b/lib/browser.js @@ -123,8 +123,10 @@ function createGenerate(loadBinding) { */ return async function generate(options) { rejectPathOptions(options); - const prepared = await prepareOptions(options, unreachableFetch); - const binding = await load(); + const [prepared, binding] = await Promise.all([ + prepareOptions(options, unreachableFetch), + load(), + ]); let outcome; try { outcome = binding.generateNative(prepared); From f89bcc1c8d9c9e816bb38136bdb843fd6ed2d965 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andrzej=20=C5=9Awi=C4=99tek?= Date: Tue, 8 Sep 2026 19:01:19 +0200 Subject: [PATCH 08/11] Replaced the value-building loops with iterator chains --- src/bindings.rs | 2 +- src/emit/angular/imports.rs | 84 ++++++------ src/emit/angular/request.rs | 160 ++++++++++++++--------- src/emit/model/emit_ts_models.rs | 37 +++--- src/emit/ts/decl.rs | 12 +- src/emit/ts/literal.rs | 26 ++-- src/emit/ts_tests.rs | 8 +- src/ident.rs | 4 + src/io/writer.rs | 2 +- src/ir/normalize/mod.rs | 6 +- src/ir/normalize/operations/form.rs | 117 +++++++++++------ src/ir/normalize/operations/mod.rs | 8 +- src/ir/normalize/operations/responses.rs | 8 +- src/ir/normalize/semantic.rs | 100 +++++++------- src/ir/schema.rs | 10 +- src/options.rs | 137 ++++++++++--------- src/parse/input.rs | 1 - src/parse/openapi_model.rs | 4 +- src/parse/policy.rs | 50 +++---- src/pipeline.rs | 6 +- src/plan/artifact_plan.rs | 4 +- src/plan/naming/config.rs | 4 +- src/plan/naming/engine.rs | 22 ++-- src/plan/naming/fixed.rs | 28 +--- src/plan/naming/parse_spec.rs | 26 ++-- src/plan/services/body.rs | 15 ++- src/plan/services/grouping.rs | 37 +++--- src/plan/services/mod.rs | 16 +-- src/result.rs | 15 ++- 29 files changed, 523 insertions(+), 426 deletions(-) diff --git a/src/bindings.rs b/src/bindings.rs index bf1d1a5..a356596 100644 --- a/src/bindings.rs +++ b/src/bindings.rs @@ -128,7 +128,7 @@ pub struct GenerateOutcome { pub(crate) fn map_panic(panic: Box) -> GenerateErrorPayload { let message = panic .downcast_ref::<&'static str>() - .map(|s| (*s).to_string()) + .map(|target| (*target).to_string()) .or_else(|| panic.downcast_ref::().cloned()) .unwrap_or_else(|| "openapi-ng: unexpected panic in native binding".to_string()); let fatal = Diagnostic { diff --git a/src/emit/angular/imports.rs b/src/emit/angular/imports.rs index c25dc77..a09d968 100644 --- a/src/emit/angular/imports.rs +++ b/src/emit/angular/imports.rs @@ -2,7 +2,7 @@ use std::collections::{BTreeMap, BTreeSet}; use crate::emit::ts::{Writer, type_import_block}; use crate::ir::canonical::ResponseContent; -use crate::ir::schema::collect_type_references; +use crate::ir::schema::{SchemaType, collect_type_references}; use crate::plan::artifact_plan::{PlannedOperation, PlannedRequestBody, RequestFieldKind}; /// Path from a generated service file to the model artifact, one @@ -21,7 +21,7 @@ pub(super) fn render_service_imports( .request .fields .iter() - .any(|f| f.kind == RequestFieldKind::Query) + .any(|field| field.kind == RequestFieldKind::Query) }); let helper_import = if uses_http_params { format!("import {{ httpParams, requestFactory }} from '{helper_import_path}';") @@ -30,50 +30,56 @@ pub(super) fn render_service_imports( }; buffer.line(&helper_import); - let mut imports: BTreeSet<&str> = BTreeSet::new(); - for operation in operations { - for field in &operation.request.fields { - collect_type_references(field.ty, &mut imports); - } - for header in &operation.request.headers { - collect_type_references(header.ty, &mut imports); - } - // A form body's fields are typed by `BodyFieldType`, which names no - // user-declared schema. - match &operation.request.body { - Some(PlannedRequestBody::Nested { ty, .. }) => { + let imports: BTreeSet<&str> = + operations + .iter() + .flat_map(operation_types) + .fold(BTreeSet::new(), |mut imports, ty| { collect_type_references(ty, &mut imports); - } - Some(PlannedRequestBody::FlatJson { properties, .. }) => { - for prop in properties { - collect_type_references(prop.ty, &mut imports); - } - } - Some(PlannedRequestBody::Multipart { .. } | PlannedRequestBody::UrlEncoded { .. }) | None => { - } - } - if let Some(response) = &operation.response { - match response { - ResponseContent::Json(Some(ty)) => { - collect_type_references(ty, &mut imports); - } - // Every other variant renders to a built-in type. - ResponseContent::Json(None) - | ResponseContent::Blob - | ResponseContent::Text - | ResponseContent::ArrayBuffer => {} - } - } - for error in operation.errors { - collect_type_references(&error.body, &mut imports); - } - } + imports + }); if !imports.is_empty() { type_import_block(buffer, &BTreeMap::from([(MODEL_IMPORT_PATH, imports)])); } } +/// Every model type an operation names. A form body and a non-JSON +/// response name none. +fn operation_types<'a>( + operation: &'a PlannedOperation<'a>, +) -> impl Iterator { + let body: Box> = match &operation.request.body { + Some(PlannedRequestBody::Nested { ty, .. }) => Box::new(std::iter::once(*ty)), + Some(PlannedRequestBody::FlatJson { properties, .. }) => { + Box::new(properties.iter().map(|property| property.ty)) + } + Some(PlannedRequestBody::Multipart { .. } | PlannedRequestBody::UrlEncoded { .. }) | None => { + Box::new(std::iter::empty()) + } + }; + let response = operation + .response + .as_ref() + .and_then(|response| match response { + ResponseContent::Json(Some(ty)) => Some(ty), + ResponseContent::Json(None) + | ResponseContent::Blob + | ResponseContent::Text + | ResponseContent::ArrayBuffer => None, + }); + + operation + .request + .fields + .iter() + .map(|field| field.ty) + .chain(operation.request.headers.iter().map(|header| header.ty)) + .chain(body) + .chain(response) + .chain(operation.errors.iter().map(|error| &error.body)) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/emit/angular/request.rs b/src/emit/angular/request.rs index a56ca1c..903d611 100644 --- a/src/emit/angular/request.rs +++ b/src/emit/angular/request.rs @@ -1,8 +1,11 @@ -use crate::emit::ts::{Position, Render, Writer, property_declaration, w, wln}; +use crate::emit::ts::{ + Doc, Member, Position, Render, Writer, interface_block, property_declaration, w, wln, +}; use crate::ident::TypeName; use crate::ir::canonical::BodyFieldType; use crate::plan::artifact_plan::{ - PlannedFormField, PlannedHeader, PlannedOperation, PlannedRequestBody, RequestFieldKind, + PlannedFormField, PlannedHeader, PlannedOperation, PlannedRequestBody, PlannedRequestContract, + PlannedRequestField, RequestFieldKind, }; /// Which runtime constructor the form-body IIFE builds. @@ -19,25 +22,10 @@ pub(super) fn render_requestful_builder( ) { buffer.open_block(&format!("(request: {interface_name}) =>")); - let mut destructured: Vec<&str> = operation - .request - .fields - .iter() - .map(|f| f.name.as_ref()) + let headers = HeaderObject(&operation.request.headers); + let destructured: Vec<&str> = request_members(&operation.request, &headers) + .map(|member| member.name) .collect(); - match &operation.request.body { - None => {} - Some(PlannedRequestBody::Nested { .. }) => destructured.push("body"), - Some(PlannedRequestBody::FlatJson { properties, .. }) => { - destructured.extend(properties.iter().map(|p| p.name.as_ref())); - } - Some(PlannedRequestBody::Multipart { fields } | PlannedRequestBody::UrlEncoded { fields }) => { - destructured.extend(fields.iter().map(|field| field.name.as_str())); - } - } - if !operation.request.headers.is_empty() { - destructured.push("headers"); - } if !destructured.is_empty() { wln!(buffer, "const {{ {} }} = request;", destructured.join(", ")); } @@ -76,39 +64,73 @@ pub(super) fn render_request_interface( operation: &PlannedOperation<'_>, request_name: &TypeName, ) { - // Member order: path → query → body → headers. - buffer.open_block(&format!("export interface {request_name}")); + let headers = HeaderObject(&operation.request.headers); + interface_block( + buffer, + request_name.as_str(), + Doc::default(), + request_members(&operation.request, &headers), + true, + ); +} - for field in &operation.request.fields { - property_declaration(buffer, field.name.as_ref(), field.optional, field.ty); - buffer.push(";\n"); - } +/// The interface's members, in emitted order: fields, body, `headers`. +fn request_members<'a>( + request: &'a PlannedRequestContract<'a>, + headers: &'a HeaderObject<'a>, +) -> impl Iterator> { + request + .fields + .iter() + .map(field_member) + .chain(body_members(request.body.as_ref())) + .chain(headers.member()) +} - match &operation.request.body { - None => {} - Some(PlannedRequestBody::Nested { ty, optional }) => { - property_declaration(buffer, "body", *optional, ty); - buffer.push(";\n"); - } - Some(PlannedRequestBody::FlatJson { properties, .. }) => { - for prop in properties { - property_declaration(buffer, prop.name.as_ref(), prop.optional, prop.ty); - buffer.push(";\n"); - } - } - Some(PlannedRequestBody::Multipart { fields } | PlannedRequestBody::UrlEncoded { fields }) => { - for form in fields { - property_declaration(buffer, form.name.as_str(), form.optional, form.ty); - buffer.push(";\n"); - } - } +fn field_member<'a>(field: &'a PlannedRequestField<'a>) -> Member<'a> { + Member { + name: field.name.as_ref(), + optional: field.optional, + ty: field.ty, + doc: Doc::default(), } +} - if !operation.request.headers.is_empty() { - render_headers_member(buffer, &operation.request.headers); +fn form_member<'a>(field: &'a PlannedFormField<'a>) -> Member<'a> { + Member { + name: field.name.as_str(), + optional: field.optional, + ty: field.ty, + doc: Doc::default(), } +} - buffer.close_block(""); +/// The members a body contributes; at most one arm is non-empty. +fn body_members<'a>(body: Option<&'a PlannedRequestBody<'a>>) -> impl Iterator> { + let nested = body.and_then(|body| match body { + PlannedRequestBody::Nested { ty, optional } => Some(Member { + name: "body", + optional: *optional, + ty: *ty, + doc: Doc::default(), + }), + _ => None, + }); + let hoisted_json = body.and_then(|body| match body { + PlannedRequestBody::FlatJson { properties, .. } => Some(properties.iter().map(field_member)), + _ => None, + }); + let hoisted_form = body.and_then(|body| match body { + PlannedRequestBody::Multipart { fields } | PlannedRequestBody::UrlEncoded { fields } => { + Some(fields.iter().map(form_member)) + } + _ => None, + }); + + nested + .into_iter() + .chain(hoisted_json.into_iter().flatten()) + .chain(hoisted_form.into_iter().flatten()) } /// Emits an operation's error interface: its body types keyed by status. @@ -125,31 +147,39 @@ pub(super) fn render_error_interface( error_name: &TypeName, ) { buffer.open_block(&format!("export interface {error_name}")); - for error in operation.errors { - buffer.push(&error.status.to_string()); - buffer.push(": "); + operation.errors.iter().for_each(|error| { + w!(buffer, "{}: ", error.status); error.body.render(buffer, Position::Standalone); buffer.push(";\n"); - } + }); buffer.close_block(""); } -/// Emits the synthetic `headers` member: an inline object over the -/// operation's `in: header` parameters, optional when every one of them -/// is. No member carries JSDoc. -fn render_headers_member(buffer: &mut Writer, headers: &[PlannedHeader<'_>]) { - buffer.push("headers"); - if headers.iter().all(|header| header.optional) { - buffer.push("?"); +/// The synthetic `headers` member's inline object type. +struct HeaderObject<'a>(&'a [PlannedHeader<'a>]); + +impl<'a> HeaderObject<'a> { + fn member(&'a self) -> Option> { + (!self.0.is_empty()).then(|| Member { + name: "headers", + optional: self.0.iter().all(|header| header.optional), + ty: self, + doc: Doc::default(), + }) } - buffer.push(": {\n"); - buffer.indent(); - for header in headers { - property_declaration(buffer, header.name.as_ref(), header.optional, header.ty); - buffer.push(";\n"); +} + +impl Render for HeaderObject<'_> { + fn render(&self, out: &mut Writer, _at: Position) { + out.push("{\n"); + out.indent(); + self.0.iter().for_each(|header| { + property_declaration(out, header.name.as_ref(), header.optional, &header.ty); + out.push(";\n"); + }); + out.dedent(); + out.push("}"); } - buffer.dedent(); - buffer.push("};\n"); } /// Writes `params: httpParams({ … }),` when the operation declares query diff --git a/src/emit/model/emit_ts_models.rs b/src/emit/model/emit_ts_models.rs index fd285e5..ce578cf 100644 --- a/src/emit/model/emit_ts_models.rs +++ b/src/emit/model/emit_ts_models.rs @@ -104,23 +104,30 @@ fn is_self_alias(mapped: &ResolvedMappedType<'_>) -> bool { /// Emits the mapped types' import block: regular imports first, grouped by /// path, then the re-exports. fn emit_mapped_imports(mapped_types: &[ResolvedMappedType<'_>], out: &mut Writer) { - let mut imports = BTreeMap::<&str, BTreeSet<(&str, Option<&str>)>>::new(); - let mut reexports = BTreeMap::<&str, BTreeSet<(&str, &str)>>::new(); - - for mapped in mapped_types { - let path = mapped.import.as_ref(); - if is_self_alias(mapped) { - reexports - .entry(path) - .or_default() - .insert((mapped.ty.as_ref(), mapped.schema)); - } else { - imports - .entry(path) + let (self_aliased, aliased): (Vec<_>, Vec<_>) = mapped_types + .iter() + .partition(|mapped| is_self_alias(mapped)); + + let imports = aliased.iter().fold( + BTreeMap::<&str, BTreeSet<(&str, Option<&str>)>>::new(), + |mut grouped, mapped| { + grouped + .entry(mapped.import.as_ref()) .or_default() .insert((mapped.ty.as_ref(), mapped.alias.as_deref())); - } - } + grouped + }, + ); + let reexports = self_aliased.iter().fold( + BTreeMap::<&str, BTreeSet<(&str, &str)>>::new(), + |mut grouped, mapped| { + grouped + .entry(mapped.import.as_ref()) + .or_default() + .insert((mapped.ty.as_ref(), mapped.schema)); + grouped + }, + ); for (path, bindings) in &imports { let bindings = bindings diff --git a/src/emit/ts/decl.rs b/src/emit/ts/decl.rs index aa11265..9621641 100644 --- a/src/emit/ts/decl.rs +++ b/src/emit/ts/decl.rs @@ -1,9 +1,7 @@ //! Declaration-level emit: JSDoc, interfaces, type aliases, literal unions. -use crate::ir::schema::SchemaType; - use super::literal::quoted; -use super::types::property_declaration; +use super::types::{Render, property_declaration}; use super::writer::{Writer, wln}; /// Width below which a top-level literal union stays on one line. Counts @@ -68,7 +66,7 @@ pub(crate) fn jsdoc(out: &mut Writer, doc: Doc<'_>) { pub(crate) struct Member<'a> { pub(crate) name: &'a str, pub(crate) optional: bool, - pub(crate) ty: &'a SchemaType, + pub(crate) ty: &'a dyn Render, pub(crate) doc: Doc<'a>, } @@ -87,11 +85,11 @@ pub(crate) fn interface_block<'a>( "interface " }; out.open_block(&format!("{keyword}{name}")); - for member in members { + members.into_iter().for_each(|member| { jsdoc(out, member.doc); - property_declaration(out, member.name, member.optional, member.ty); + property_declaration(out, member.name, member.optional, &member.ty); out.push(";\n"); - } + }); out.close_block(""); } diff --git a/src/emit/ts/literal.rs b/src/emit/ts/literal.rs index 7593a4d..40fb828 100644 --- a/src/emit/ts/literal.rs +++ b/src/emit/ts/literal.rs @@ -9,19 +9,25 @@ use crate::ident::is_ident; pub(crate) fn escape_into(out: &mut String, value: &str) { out.reserve(value.len() + 2); out.push('\''); - for ch in value.chars() { - match ch { - '\\' => out.push_str("\\\\"), - '\'' => out.push_str("\\'"), - '\n' => out.push_str("\\n"), - '\r' => out.push_str("\\r"), - '\t' => out.push_str("\\t"), - _ => out.push(ch), - } - } + value.chars().for_each(|ch| match escape_sequence(ch) { + Some(sequence) => out.push_str(sequence), + None => out.push(ch), + }); out.push('\''); } +/// The escape `ch` needs, or `None` when it stands for itself. +const fn escape_sequence(ch: char) -> Option<&'static str> { + match ch { + '\\' => Some("\\\\"), + '\'' => Some("\\'"), + '\n' => Some("\\n"), + '\r' => Some("\\r"), + '\t' => Some("\\t"), + _ => None, + } +} + /// `value` as a single-quoted TypeScript string literal. pub(crate) fn quoted(value: &str) -> String { let mut out = String::with_capacity(value.len() + 2); diff --git a/src/emit/ts_tests.rs b/src/emit/ts_tests.rs index 6001665..affece0 100644 --- a/src/emit/ts_tests.rs +++ b/src/emit/ts_tests.rs @@ -420,11 +420,11 @@ mod tests { fn jsdoc_escapes_close_comment_sequence() { let mut out = Writer::with_capacity(4096); jsdoc(&mut out, Doc::new(Some("Crafted */ injection /*"), false)); - let s = out.into_string(); + let rendered = out.into_string(); // The only allowed `*/` is the trailing JSDoc closer on its own line. // Strip exactly the opener and closer lines, then assert no `*/` remains // in the body of the comment — i.e. the description was escaped. - let body = s + let body = rendered .strip_prefix("/**\n") .and_then(|rest| rest.strip_suffix(" */\n")) .expect("jsdoc output should be wrapped in /** ... */"); @@ -433,8 +433,8 @@ mod tests { "raw */ leaked into JSDoc body: {body}" ); assert!( - s.contains("*\\/"), - "expected escaped *\\/ in output, got: {s}" + rendered.contains("*\\/"), + "expected escaped *\\/ in output, got: {rendered}" ); } } diff --git a/src/ident.rs b/src/ident.rs index 4887510..564edb6 100644 --- a/src/ident.rs +++ b/src/ident.rs @@ -71,6 +71,10 @@ impl TypeName { pub(crate) const fn new(name: String) -> Self { Self(name) } + + pub(crate) fn as_str(&self) -> &str { + &self.0 + } } impl std::fmt::Display for TypeName { diff --git a/src/io/writer.rs b/src/io/writer.rs index 55420df..ebaa63b 100644 --- a/src/io/writer.rs +++ b/src/io/writer.rs @@ -30,7 +30,7 @@ fn write_artifact( let artifact_rel = std::path::Path::new(&artifact.path); if artifact_rel .components() - .any(|c| matches!(c, std::path::Component::ParentDir)) + .any(|component| matches!(component, std::path::Component::ParentDir)) { bail!( reporter, diff --git a/src/ir/normalize/mod.rs b/src/ir/normalize/mod.rs index 15e4ca0..640e1f9 100644 --- a/src/ir/normalize/mod.rs +++ b/src/ir/normalize/mod.rs @@ -30,8 +30,10 @@ pub(crate) fn normalize_api_model( reporter: &Reporter, ) -> Result { let schemas = normalize_schemas(&document.components.schemas, reporter)?; - let schema_index: BTreeMap<&str, &SchemaType> = - schemas.iter().map(|m| (m.name.as_ref(), &m.body)).collect(); + let schema_index: BTreeMap<&str, &SchemaType> = schemas + .iter() + .map(|symbol| (symbol.name.as_ref(), &symbol.body)) + .collect(); let operations = normalize_operations( &document.paths, &schema_index, diff --git a/src/ir/normalize/operations/form.rs b/src/ir/normalize/operations/form.rs index be61523..e26a1e1 100644 --- a/src/ir/normalize/operations/form.rs +++ b/src/ir/normalize/operations/form.rs @@ -6,7 +6,7 @@ use std::collections::BTreeMap; use crate::error::{Context, Diagnostic, Reporter, bail_policy}; use crate::ident::Ident; use crate::ir::canonical::{BodyField, BodyFieldType}; -use crate::ir::schema::{SchemaScalar, SchemaType}; +use crate::ir::schema::{SchemaProperty, SchemaScalar, SchemaType}; use crate::parse::openapi_model::{AdditionalProperties, MediaType, Schema}; use super::super::schema::normalize_schema; @@ -149,32 +149,49 @@ pub(super) fn normalize_form_body_fields( let raw_property_lookup = collect_raw_property_formats(raw_schema); - let mut fields: Vec = Vec::with_capacity(properties.len()); - for prop in properties.iter() { - let Some(name) = Ident::parse(prop.name.as_ref()) else { - bail_policy!( - reporter, - "invalid-form-field-name", - "body field '{name}' in {method} {path}: name is not a valid JavaScript identifier. Rename the field or split this body into a non-generated client.", - name = prop.name.as_ref(), - ); - }; - let raw_format = raw_property_lookup - .get(prop.name.as_ref()) - .copied() - .unwrap_or(RawPropertyFormat::default()); - let ty = classify_body_field_type(&prop.ty, raw_format, prop.name.as_ref(), body)?; - fields.push(BodyField { - name, - required: prop.required, - ty, - }); - } - - fields.sort_by(|a, b| a.name.cmp(&b.name)); + let mut fields = properties + .iter() + .map(|property| { + let raw_format = raw_property_lookup + .get(property.name.as_ref()) + .copied() + .unwrap_or_default(); + body_field(property, raw_format, body) + }) + .collect::, Diagnostic>>()?; + + fields.sort_by(|left, right| left.name.cmp(&right.name)); Ok((body_ref, fields)) } +/// Lowers one body property. +fn body_field( + property: &SchemaProperty, + raw_format: RawPropertyFormat<'_>, + body: FormBody<'_>, +) -> Result { + let FormBody { + method, + path, + reporter, + .. + } = body; + let Some(name) = Ident::parse(property.name.as_ref()) else { + bail_policy!( + reporter, + "invalid-form-field-name", + "body field '{name}' in {method} {path}: name is not a valid JavaScript identifier. Rename the field or split this body into a non-generated client.", + name = property.name.as_ref(), + ); + }; + + Ok(BodyField { + name, + required: property.required, + ty: classify_body_field_type(&property.ty, raw_format, property.name.as_ref(), body)?, + }) +} + /// One body property's raw `format` hints: `own` from the property /// schema, `items` from its array-item schema. #[derive(Clone, Copy, Default)] @@ -186,19 +203,21 @@ struct RawPropertyFormat<'a> { /// Collects the per-property `format` hints `SchemaType` does not carry. /// Empty when the body is a top-level `$ref`. fn collect_raw_property_formats(raw_schema: &Schema) -> BTreeMap<&str, RawPropertyFormat<'_>> { - let mut lookup = BTreeMap::new(); - let Some(properties) = &raw_schema.properties else { - return lookup; - }; - for (name, schema) in properties.iter() { - let own = schema.format.as_deref(); - let items = schema - .items - .as_deref() - .and_then(|item_schema| item_schema.format.as_deref()); - lookup.insert(name.as_str(), RawPropertyFormat { own, items }); - } - lookup + raw_schema + .properties + .iter() + .flat_map(|properties| properties.iter()) + .map(|(name, schema)| { + let format = RawPropertyFormat { + own: schema.format.as_deref(), + items: schema + .items + .as_deref() + .and_then(|item_schema| item_schema.format.as_deref()), + }; + (name.as_str(), format) + }) + .collect() } /// Classifies one form-body property. Accepts a scalar, a binary, or an @@ -323,12 +342,18 @@ content: match result.content { BodyContent::Multipart { body_ref, fields } => { assert_eq!(body_ref, None); - let names: Vec<&str> = fields.iter().map(|f| f.name.as_str()).collect(); + let names: Vec<&str> = fields.iter().map(|field| field.name.as_str()).collect(); assert_eq!(names, vec!["avatar", "nickname", "status", "tagIds"]); - let avatar = fields.iter().find(|f| f.name.as_str() == "avatar").unwrap(); + let avatar = fields + .iter() + .find(|field| field.name.as_str() == "avatar") + .unwrap(); assert_eq!(avatar.ty, BodyFieldType::Binary); assert!(avatar.required); - let status = fields.iter().find(|f| f.name.as_str() == "status").unwrap(); + let status = fields + .iter() + .find(|field| field.name.as_str() == "status") + .unwrap(); assert!(matches!( status.ty, BodyFieldType::Scalar(SchemaScalar::String) @@ -336,10 +361,13 @@ content: assert!(status.required); let nickname = fields .iter() - .find(|f| f.name.as_str() == "nickname") + .find(|field| field.name.as_str() == "nickname") .unwrap(); assert!(!nickname.required); - let tag_ids = fields.iter().find(|f| f.name.as_str() == "tagIds").unwrap(); + let tag_ids = fields + .iter() + .find(|field| field.name.as_str() == "tagIds") + .unwrap(); assert!(matches!( tag_ids.ty, BodyFieldType::ArrayOfScalar(SchemaScalar::Number) @@ -503,7 +531,10 @@ content: match result.content { BodyContent::UrlEncoded { fields, .. } => { assert_eq!( - fields.iter().map(|f| f.name.as_str()).collect::>(), + fields + .iter() + .map(|field| field.name.as_str()) + .collect::>(), vec!["status", "tagIds"] ); } diff --git a/src/ir/normalize/operations/mod.rs b/src/ir/normalize/operations/mod.rs index 8ca1bf9..7721a9a 100644 --- a/src/ir/normalize/operations/mod.rs +++ b/src/ir/normalize/operations/mod.rs @@ -114,12 +114,12 @@ fn normalize_operation( .clone() .unwrap_or_else(|| format!("{declared_method}_{}", path.replace(['/', '{', '}'], "_"))); - let cx = OperationCx::new(method.as_str(), path, schemas, response_types, reporter); + let context = OperationCx::new(method.as_str(), path, schemas, response_types, reporter); Ok(OperationDef { - request: normalize_request(operation, &operation_id, cx)?, - response: normalize_success_response(operation.responses.as_deref(), cx)?, - errors: normalize_error_responses(operation.responses.as_deref(), cx)?, + request: normalize_request(operation, &operation_id, context)?, + response: normalize_success_response(operation.responses.as_deref(), context)?, + errors: normalize_error_responses(operation.responses.as_deref(), context)?, operation_id, tags: operation.tags.clone(), method, diff --git a/src/ir/normalize/operations/responses.rs b/src/ir/normalize/operations/responses.rs index 5c675db..26f9d96 100644 --- a/src/ir/normalize/operations/responses.rs +++ b/src/ir/normalize/operations/responses.rs @@ -153,7 +153,7 @@ fn classify_response_kind( if let Some(m) = user_mapping .iter() - .find(|m| m.content_type.eq_ignore_ascii_case(&normalized)) + .find(|mapping| mapping.content_type.eq_ignore_ascii_case(&normalized)) { return match m.response_type { ResponseType::Json => ResponseKind::Json, @@ -357,7 +357,7 @@ mod tests { normalize_error_responses(Some(&responses), test_cx(&[], &ctx)).expect("normalize ok"); assert_eq!( - errors.iter().map(|e| e.status).collect::>(), + errors.iter().map(|error| error.status).collect::>(), vec![400, 404, 500] ); } @@ -398,7 +398,7 @@ mod tests { normalize_error_responses(Some(&responses), test_cx(&[], &ctx)).expect("normalize ok"); assert_eq!( - errors.iter().map(|e| e.status).collect::>(), + errors.iter().map(|error| error.status).collect::>(), vec![400] ); } @@ -418,7 +418,7 @@ mod tests { // Only 400 survives — `default` is intentionally not surfaced. assert_eq!( - errors.iter().map(|e| e.status).collect::>(), + errors.iter().map(|error| error.status).collect::>(), vec![400] ); } diff --git a/src/ir/normalize/semantic.rs b/src/ir/normalize/semantic.rs index 70d8a19..70684ec 100644 --- a/src/ir/normalize/semantic.rs +++ b/src/ir/normalize/semantic.rs @@ -4,7 +4,7 @@ use std::collections::{BTreeMap, BTreeSet}; use crate::error::{Diagnostic, DiagnosticCode, Reporter, bail, bail_policy}; -use crate::ir::canonical::{ApiModel, BodyContent, ModelSymbol, ResponseContent}; +use crate::ir::canonical::{ApiModel, BodyContent, ModelSymbol, OperationDef, ResponseContent}; use crate::ir::schema::{SchemaProperty, SchemaScalar, SchemaType, collect_type_references}; /// Sorts the schemas by name, narrows the discriminator properties, and @@ -109,6 +109,37 @@ fn narrow_discriminator_properties( Ok(()) } +/// Every schema-typed position an operation declares. +fn operation_types(operation: &OperationDef) -> impl Iterator { + let body = operation + .request + .body + .as_ref() + .and_then(|body| match &body.content { + BodyContent::Json(ty) => Some(ty), + BodyContent::Multipart { .. } | BodyContent::UrlEncoded { .. } => None, + }); + let response = operation + .response + .as_ref() + .and_then(|response| match response { + ResponseContent::Json(Some(ty)) => Some(ty), + ResponseContent::Json(None) + | ResponseContent::Blob + | ResponseContent::Text + | ResponseContent::ArrayBuffer => None, + }); + + operation + .request + .inputs + .iter() + .map(|input| &input.ty) + .chain(operation.request.headers.iter().map(|header| &header.ty)) + .chain(body) + .chain(response) +} + /// Finds a property by name through the shapes that can carry one: an /// inline object, an `allOf` part, a `$ref` target, or a nullable wrapper. fn find_property<'a>( @@ -157,14 +188,9 @@ fn narrow_property_in_body(body: &mut SchemaType, name: &str, literal_value: &st } false } - SchemaType::Intersection(parts) => { - for part in parts { - if narrow_property_in_body(part, name, literal_value) { - return true; - } - } - false - } + SchemaType::Intersection(parts) => parts + .iter_mut() + .any(|part| narrow_property_in_body(part, name, literal_value)), SchemaType::Nullable(inner) => narrow_property_in_body(inner, name, literal_value), _ => false, } @@ -176,46 +202,22 @@ fn validate_references(document: &ApiModel, reporter: &Reporter) -> Result<(), D .iter() .map(|symbol| symbol.name.as_ref()) .collect(); - let mut refs: BTreeSet<&str> = BTreeSet::new(); - - for symbol in &document.schemas { - collect_type_references(&symbol.body, &mut refs); - } - - for operation in &document.operations { - for input in &operation.request.inputs { - collect_type_references(&input.ty, &mut refs); - } - for header in &operation.request.headers { - collect_type_references(&header.ty, &mut refs); - } - if let Some(body) = &operation.request.body { - match &body.content { - BodyContent::Json(ty) => collect_type_references(ty, &mut refs), - // `BodyFieldType` carries no schema reference; `body_ref` was - // resolved at lowering time. - BodyContent::Multipart { .. } | BodyContent::UrlEncoded { .. } => {} - } - } - if let Some(response) = &operation.response { - match response { - ResponseContent::Json(Some(ty)) => collect_type_references(ty, &mut refs), - ResponseContent::Json(None) - | ResponseContent::Blob - | ResponseContent::Text - | ResponseContent::ArrayBuffer => {} - } - } - } + let refs: BTreeSet<&str> = document + .schemas + .iter() + .map(|symbol| &symbol.body) + .chain(document.operations.iter().flat_map(operation_types)) + .fold(BTreeSet::new(), |mut refs, ty| { + collect_type_references(ty, &mut refs); + refs + }); - for name in refs { - if !symbol_index.contains(name) { - bail!( - reporter, - DiagnosticCode::InvalidReference, - "Failed to validate spec: unresolved schema reference {name}. Check for typos in the $ref and confirm that components.schemas defines a top-level entry named '{name}'." - ); - } + if let Some(name) = refs.into_iter().find(|name| !symbol_index.contains(name)) { + bail!( + reporter, + DiagnosticCode::InvalidReference, + "Failed to validate spec: unresolved schema reference {name}. Check for typos in the $ref and confirm that components.schemas defines a top-level entry named '{name}'." + ); } Ok(()) @@ -252,7 +254,7 @@ mod tests { SchemaType::Union { members: members .into_iter() - .map(|n| SchemaType::Ref(n.into())) + .map(|name| SchemaType::Ref(name.into())) .collect(), discriminator: Some(Discriminator { property_name: "kind".into(), diff --git a/src/ir/schema.rs b/src/ir/schema.rs index 657296c..238d0c6 100644 --- a/src/ir/schema.rs +++ b/src/ir/schema.rs @@ -68,14 +68,12 @@ fn walk_refs<'ir>(ty: &'ir SchemaType, refs: &mut BTreeSet<&'ir str>) { refs.insert(name.as_ref()); } SchemaType::Union { members, .. } | SchemaType::Intersection(members) => { - for member in members { - walk_refs(member, refs); - } + members.iter().for_each(|member| walk_refs(member, refs)); } SchemaType::InlineObject { properties } => { - for property in properties { - walk_refs(&property.ty, refs); - } + properties + .iter() + .for_each(|property| walk_refs(&property.ty, refs)); } } } diff --git a/src/options.rs b/src/options.rs index 299cb4d..34f13ec 100644 --- a/src/options.rs +++ b/src/options.rs @@ -125,49 +125,59 @@ fn validate_emit_targets( Ok(()) } +/// The first key that repeats, in iteration order. +fn first_duplicate(keys: impl IntoIterator) -> Option { + let mut seen = std::collections::BTreeSet::new(); + keys.into_iter().find(|key| !seen.insert(key.clone())) +} + fn validate_mapped_types( mapped_types: &[MappedType], reporter: &Reporter, ) -> Result<(), Diagnostic> { - let mut seen = std::collections::BTreeSet::<&str>::new(); - for mapped_type in mapped_types { - if mapped_type.schema.trim().is_empty() - || mapped_type.import.trim().is_empty() - || mapped_type.ty.trim().is_empty() - { - return Err(reporter.error( - DiagnosticCode::InvalidOption, - "Failed to resolve generation options: mapped type entries require schema, import, and type.", - )); - } + mapped_types + .iter() + .try_for_each(|mapped_type| validate_mapped_type(mapped_type, reporter))?; - if !is_ident(&mapped_type.ty) { - bail!( - reporter, - DiagnosticCode::InvalidOption, - "Failed to resolve generation options: mapped type type '{}' is not a valid TypeScript identifier (expected /^[A-Za-z_$][A-Za-z0-9_$]*$/).", - mapped_type.ty, - ); - } + if let Some(schema) = first_duplicate(mapped_types.iter().map(|entry| entry.schema.as_str())) { + bail!( + reporter, + DiagnosticCode::InvalidOption, + "Failed to resolve generation options: mapped type schema '{schema}' is duplicated; each schema must appear at most once.", + ); + } - if let Some(alias) = mapped_type.alias.as_deref() - && !is_ident(alias) - { - bail!( - reporter, - DiagnosticCode::InvalidOption, - "Failed to resolve generation options: mapped type alias '{alias}' is not a valid TypeScript identifier." - ); - } + Ok(()) +} - if !seen.insert(mapped_type.schema.as_str()) { - bail!( - reporter, - DiagnosticCode::InvalidOption, - "Failed to resolve generation options: mapped type schema '{}' is duplicated; each schema must appear at most once.", - mapped_type.schema, - ); - } +fn validate_mapped_type(mapped_type: &MappedType, reporter: &Reporter) -> Result<(), Diagnostic> { + if mapped_type.schema.trim().is_empty() + || mapped_type.import.trim().is_empty() + || mapped_type.ty.trim().is_empty() + { + return Err(reporter.error( + DiagnosticCode::InvalidOption, + "Failed to resolve generation options: mapped type entries require schema, import, and type.", + )); + } + + if !is_ident(&mapped_type.ty) { + bail!( + reporter, + DiagnosticCode::InvalidOption, + "Failed to resolve generation options: mapped type type '{}' is not a valid TypeScript identifier (expected /^[A-Za-z_$][A-Za-z0-9_$]*$/).", + mapped_type.ty, + ); + } + + if let Some(alias) = mapped_type.alias.as_deref() + && !is_ident(alias) + { + bail!( + reporter, + DiagnosticCode::InvalidOption, + "Failed to resolve generation options: mapped type alias '{alias}' is not a valid TypeScript identifier." + ); } Ok(()) @@ -177,29 +187,38 @@ fn validate_response_type_mapping( mappings: &[ResponseTypeMapping], reporter: &Reporter, ) -> Result<(), Diagnostic> { - let mut seen = std::collections::BTreeSet::::new(); - for m in mappings { - let lc = m.content_type.to_ascii_lowercase(); - if lc.is_empty() { - return Err(reporter.error( - DiagnosticCode::InvalidOption, - "responseTypeMapping.contentType must be non-empty.", - )); - } - if !lc.contains('/') { - bail!( - reporter, - DiagnosticCode::InvalidOption, - "responseTypeMapping.contentType {lc:?} must contain '/'." - ); - } - if !seen.insert(lc.clone()) { - bail!( - reporter, - DiagnosticCode::InvalidOption, - "responseTypeMapping has duplicate contentType {lc:?} (case-insensitive)." - ); - } + let content_types: Vec = mappings + .iter() + .map(|mapping| mapping.content_type.to_ascii_lowercase()) + .collect(); + + content_types + .iter() + .try_for_each(|content_type| validate_content_type(content_type, reporter))?; + + if let Some(duplicate) = first_duplicate(content_types) { + bail!( + reporter, + DiagnosticCode::InvalidOption, + "responseTypeMapping has duplicate contentType {duplicate:?} (case-insensitive)." + ); + } + Ok(()) +} + +fn validate_content_type(content_type: &str, reporter: &Reporter) -> Result<(), Diagnostic> { + if content_type.is_empty() { + return Err(reporter.error( + DiagnosticCode::InvalidOption, + "responseTypeMapping.contentType must be non-empty.", + )); + } + if !content_type.contains('/') { + bail!( + reporter, + DiagnosticCode::InvalidOption, + "responseTypeMapping.contentType {content_type:?} must contain '/'." + ); } Ok(()) } diff --git a/src/parse/input.rs b/src/parse/input.rs index 0ddc80e..6e90ef7 100644 --- a/src/parse/input.rs +++ b/src/parse/input.rs @@ -295,7 +295,6 @@ components: assert_eq!(err.subcode, Some("duplicate-schema-name")); } - #[test] fn rejects_input_larger_than_cap() { let nanos = SystemTime::now() diff --git a/src/parse/openapi_model.rs b/src/parse/openapi_model.rs index 47a73c9..f04d1cc 100644 --- a/src/parse/openapi_model.rs +++ b/src/parse/openapi_model.rs @@ -81,12 +81,12 @@ impl Operation { .summary .as_deref() .map(str::trim) - .filter(|s| !s.is_empty()), + .filter(|text| !text.is_empty()), self .description .as_deref() .map(str::trim) - .filter(|s| !s.is_empty()), + .filter(|text| !text.is_empty()), ) { (None, None) => None, (Some(s), None) => Some(s.to_string()), diff --git a/src/parse/policy.rs b/src/parse/policy.rs index b84c6b1..e020c94 100644 --- a/src/parse/policy.rs +++ b/src/parse/policy.rs @@ -54,38 +54,44 @@ pub(crate) fn validate_generation_policy( } // Each operationId, against the first operation that declared it. - let mut seen_operation_ids: BTreeMap<&str, (&'static str, &str)> = BTreeMap::new(); - - for (path, path_item) in document.paths.iter() { - for (method, operation) in path_item.operations() { - if operation.operation_id.is_none() { - bail_policy!( - reporter, - "missing-operation-id", - "Failed to plan services: operation {} {} must define operationId when service generation is enabled.", - method.to_ascii_uppercase(), - path - ); - } + document + .paths + .iter() + .flat_map(|(path, path_item)| { + path_item + .operations() + .map(move |(method, operation)| (path.as_str(), method, operation)) + }) + .try_fold( + BTreeMap::<&str, (&'static str, &str)>::new(), + |mut declared, (path, method, operation)| { + let Some(operation_id) = operation.operation_id.as_deref() else { + bail_policy!( + reporter, + "missing-operation-id", + "Failed to plan services: operation {} {} must define operationId when service generation is enabled.", + method.to_ascii_uppercase(), + path + ); + }; - if let Some(ref op_id) = operation.operation_id { - if let Some(&(prev_method, prev_path)) = seen_operation_ids.get(op_id.as_str()) { + if let Some(&(first_method, first_path)) = declared.get(operation_id) { bail_policy!( reporter, "duplicate-operation-id", "Failed to plan services: operationId '{}' is defined on both {} {} and {} {}. \ operationIds must be globally unique.", - op_id, - prev_method.to_ascii_uppercase(), - prev_path, + operation_id, + first_method.to_ascii_uppercase(), + first_path, method.to_ascii_uppercase(), path, ); } - seen_operation_ids.insert(op_id.as_str(), (method, path.as_str())); - } - } - } + declared.insert(operation_id, (method, path)); + Ok(declared) + }, + )?; Ok(()) } diff --git a/src/pipeline.rs b/src/pipeline.rs index 83bd7ae..2308bdf 100644 --- a/src/pipeline.rs +++ b/src/pipeline.rs @@ -317,7 +317,7 @@ mod tests { let util_artifact = result .artifacts .iter() - .find(|a| a.path == "rest.util.ts") + .find(|artifact| artifact.path == "rest.util.ts") .expect("rest.util.ts present"); assert_eq!(util_artifact.path, "rest.util.ts"); assert!( @@ -351,14 +351,14 @@ mod tests { !result .artifacts .iter() - .any(|a| a.path == "errors.generated.ts"), + .any(|artifact| artifact.path == "errors.generated.ts"), "errors.generated.ts must not be emitted as a standalone artifact", ); let service = result .artifacts .iter() - .find(|a| a.path == "rest/pet.rest.generated.ts") + .find(|artifact| artifact.path == "rest/pet.rest.generated.ts") .expect("pet service emitted"); assert!(service.contents.contains("export interface UpdatePetError")); diff --git a/src/plan/artifact_plan.rs b/src/plan/artifact_plan.rs index 6fddd71..a128bd4 100644 --- a/src/plan/artifact_plan.rs +++ b/src/plan/artifact_plan.rs @@ -645,7 +645,7 @@ mod tests { let ids: Vec<&str> = service .operations .iter() - .map(|op| op.operation_id.as_str()) + .map(|operation| operation.operation_id.as_str()) .collect(); let mut sorted = ids.clone(); sorted.sort_unstable(); @@ -660,7 +660,7 @@ mod tests { adoption .operations .iter() - .map(|op| op.operation_id.as_str()) + .map(|operation| operation.operation_id.as_str()) .collect::>(), vec!["abandonPet", "adoptPet"] ); diff --git a/src/plan/naming/config.rs b/src/plan/naming/config.rs index fd09150..0cbf1d8 100644 --- a/src/plan/naming/config.rs +++ b/src/plan/naming/config.rs @@ -42,8 +42,8 @@ pub(crate) enum Case { } impl Case { - pub(crate) fn parse(s: &str) -> Option { - match s { + pub(crate) fn parse(value: &str) -> Option { + match value { "camel" => Some(Self::Camel), "pascal" => Some(Self::Pascal), "snake" => Some(Self::Snake), diff --git a/src/plan/naming/engine.rs b/src/plan/naming/engine.rs index b2cea18..ce122cb 100644 --- a/src/plan/naming/engine.rs +++ b/src/plan/naming/engine.rs @@ -31,20 +31,22 @@ pub(crate) fn evaluate_chain( Naming::Chain(entries) => entries.as_slice(), }; let mut failures = Vec::with_capacity(entries.len()); - for entry in entries { - match evaluate_entry(entry, ctx) { - Ok(s) => return Ok(s), - Err(f) => failures.push(f), - } - } - Err(failures) + entries + .iter() + .find_map(|entry| match evaluate_entry(entry, ctx) { + Ok(name) => Some(name), + Err(failure) => { + failures.push(failure); + None + } + }) + .ok_or(failures) } fn evaluate_entry(entry: &RuleEntry, ctx: &OperationContext<'_>) -> Result { match entry { RuleEntry::Shorthand(format_template) => { - let s = expand(format_template, ctx, &HashMap::new()).map_err(map_template_error)?; - Ok(s) + expand(format_template, ctx, &HashMap::new()).map_err(map_template_error) } RuleEntry::Rule(rule) => evaluate_rule(rule, ctx), } @@ -72,7 +74,7 @@ fn evaluate_rule(rule: &Rule, ctx: &OperationContext<'_>) -> Result bool { - if value.is_empty() { - return true; - } - if value.starts_with('-') || value.ends_with('-') { - return false; - } - let mut prev_hyphen = false; - for ch in value.chars() { - match ch { - 'a'..='z' | '0'..='9' => prev_hyphen = false, - '-' => { - if prev_hyphen { - return false; - } - prev_hyphen = true; - } - _ => return false, - } - } - true + value.is_empty() + || (!value.starts_with('-') + && !value.ends_with('-') + && !value.contains("--") + && value + .chars() + .all(|ch| matches!(ch, 'a'..='z' | '0'..='9' | '-'))) } proptest! { diff --git a/src/plan/naming/parse_spec.rs b/src/plan/naming/parse_spec.rs index 188ce98..d62d6d1 100644 --- a/src/plan/naming/parse_spec.rs +++ b/src/plan/naming/parse_spec.rs @@ -20,21 +20,17 @@ pub(crate) enum CompileError { /// Every other flag fails, `g`, `y` and `u` included: Rust's engine has /// no equivalent, and ignoring one would silently change the match. pub(crate) fn compile(source: &str, flags: &str) -> Result { - let mut builder = RegexBuilder::new(source); - for ch in flags.chars() { - match ch { - 'i' => { - builder.case_insensitive(true); - } - 'm' => { - builder.multi_line(true); - } - 's' => { - builder.dot_matches_new_line(true); - } - other => return Err(CompileError::UnsupportedFlag(other)), - } - } + let builder = flags + .chars() + .try_fold(RegexBuilder::new(source), |mut builder, flag| { + match flag { + 'i' => builder.case_insensitive(true), + 'm' => builder.multi_line(true), + 's' => builder.dot_matches_new_line(true), + unsupported => return Err(CompileError::UnsupportedFlag(unsupported)), + }; + Ok(builder) + })?; let regex = builder .build() .map_err(|err| CompileError::InvalidPattern(err.to_string()))?; diff --git a/src/plan/services/body.rs b/src/plan/services/body.rs index 27c9600..33a9b72 100644 --- a/src/plan/services/body.rs +++ b/src/plan/services/body.rs @@ -61,7 +61,7 @@ fn plan_form_fields<'ir>(fields: &'ir [BodyField]) -> Vec> ty: &field.ty, }) .collect(); - out.sort_by(|a, b| a.name.cmp(&b.name)); + out.sort_by(|left, right| left.name.cmp(&right.name)); out } @@ -80,9 +80,10 @@ pub(super) fn check_body_field_collisions( return Ok(()); } let body_names: Vec<&str> = match body { - Some(PlannedRequestBody::FlatJson { properties, .. }) => { - properties.iter().map(|p| p.name.as_ref()).collect() - } + Some(PlannedRequestBody::FlatJson { properties, .. }) => properties + .iter() + .map(|property| property.name.as_ref()) + .collect(), Some(PlannedRequestBody::Multipart { fields } | PlannedRequestBody::UrlEncoded { fields }) => { fields.iter().map(|field| field.name.as_str()).collect() } @@ -90,7 +91,7 @@ pub(super) fn check_body_field_collisions( }; let colliding: Vec<&str> = body_names .into_iter() - .filter(|n| path_query_names.contains(n)) + .filter(|name| path_query_names.contains(name)) .collect(); if colliding.is_empty() { return Ok(()); @@ -343,7 +344,7 @@ mod tests { let op = &services[0].operations[0]; match &op.request.body { Some(PlannedRequestBody::Multipart { fields }) => { - assert!(fields.iter().any(|f| f.name.as_str() == "avatar")); + assert!(fields.iter().any(|field| field.name.as_str() == "avatar")); } other => panic!("expected multipart body, got {other:?}"), } @@ -360,7 +361,7 @@ mod tests { else { panic!("expected multipart body"); }; - let names: Vec<&str> = fields.iter().map(|f| f.name.as_str()).collect(); + let names: Vec<&str> = fields.iter().map(|field| field.name.as_str()).collect(); let mut sorted = names.clone(); sorted.sort_unstable(); assert_eq!(names, sorted); diff --git a/src/plan/services/grouping.rs b/src/plan/services/grouping.rs index a441dcc..df2f966 100644 --- a/src/plan/services/grouping.rs +++ b/src/plan/services/grouping.rs @@ -1,6 +1,6 @@ //! Grouping operations into services. -use std::collections::HashMap; +use indexmap::IndexMap; use crate::{ error::{Diagnostic, Reporter}, @@ -20,25 +20,22 @@ pub(crate) fn group_operations<'a>( resolver: &crate::plan::naming::NamingResolver, reporter: &Reporter, ) -> Result, Diagnostic> { - let mut groups: GroupedOperations<'a> = Vec::new(); - let mut group_indexes = HashMap::::new(); - - for operation in operations { - let group_name = resolver.group(operation, reporter)?; - let method_name = resolver.method_name(operation, reporter)?; - - let group_index = group_indexes.get(&group_name).copied().unwrap_or_else(|| { - let index = groups.len(); - let key = group_name.clone(); - groups.push((group_name, Vec::new())); - group_indexes.insert(key, index); - index - }); - - groups[group_index].1.push((operation, method_name)); - } - - Ok(groups) + // `IndexMap` keeps the groups in discovery order. + operations + .iter() + .try_fold( + IndexMap::>::new(), + |mut groups, operation| { + let group_name = resolver.group(operation, reporter)?; + let method_name = resolver.method_name(operation, reporter)?; + groups + .entry(group_name) + .or_default() + .push((operation, method_name)); + Ok(groups) + }, + ) + .map(|groups| groups.into_iter().collect()) } #[cfg(test)] diff --git a/src/plan/services/mod.rs b/src/plan/services/mod.rs index 202bf81..868bea9 100644 --- a/src/plan/services/mod.rs +++ b/src/plan/services/mod.rs @@ -27,13 +27,13 @@ fn check_path_query_collisions( ) -> Result<(), Diagnostic> { let path_set: BTreeSet<&str> = fields .iter() - .filter(|f| f.kind == RequestFieldKind::Path) - .map(|f| f.name.as_ref()) + .filter(|field| field.kind == RequestFieldKind::Path) + .map(|field| field.name.as_ref()) .collect(); let colliding: Vec<&str> = fields .iter() - .filter(|f| f.kind == RequestFieldKind::Query && path_set.contains(f.name.as_ref())) - .map(|f| f.name.as_ref()) + .filter(|field| field.kind == RequestFieldKind::Query && path_set.contains(field.name.as_ref())) + .map(|field| field.name.as_ref()) .collect(); if !colliding.is_empty() { let names = colliding.join(", "); @@ -138,8 +138,8 @@ mod tests { let path_fields: Vec<&str> = request .fields .iter() - .filter(|f| f.kind == RequestFieldKind::Path) - .map(|f| f.name.as_ref()) + .filter(|field| field.kind == RequestFieldKind::Path) + .map(|field| field.name.as_ref()) .collect(); assert_eq!(path_fields, vec!["petId"]); match &request.body { @@ -209,11 +209,11 @@ mod tests { assert_eq!( properties .iter() - .map(|p| p.name.as_ref()) + .map(|property| property.name.as_ref()) .collect::>(), vec!["csvImportId", "doImport"] ); - assert!(properties.iter().all(|p| !p.optional)); + assert!(properties.iter().all(|property| !property.optional)); } #[test] diff --git a/src/result.rs b/src/result.rs index 17bd9ae..ab1f024 100644 --- a/src/result.rs +++ b/src/result.rs @@ -22,7 +22,11 @@ impl GenerateSummary { /// the generator emits rather than what the document declared. pub(crate) fn from_ir(normalized_source_path: String, ir: &ApiModel) -> Self { // One path carries an operation per method, so the list repeats. - let mut paths: Vec<&str> = ir.operations.iter().map(|op| op.path.as_str()).collect(); + let mut paths: Vec<&str> = ir + .operations + .iter() + .map(|operation| operation.path.as_str()) + .collect(); paths.sort_unstable(); paths.dedup(); // The caps in `crate::parse::limits` keep every count far below @@ -40,9 +44,12 @@ impl GenerateSummary { const U32_MAX_AS_USIZE: usize = u32::MAX as usize; -fn clamp_count(n: usize) -> u32 { - debug_assert!(n <= U32_MAX_AS_USIZE, "IR count exceeded u32::MAX: {n}"); - u32::try_from(usize::min(n, U32_MAX_AS_USIZE)).unwrap_or(u32::MAX) +fn clamp_count(count: usize) -> u32 { + debug_assert!( + count <= U32_MAX_AS_USIZE, + "IR count exceeded u32::MAX: {count}" + ); + u32::try_from(usize::min(count, U32_MAX_AS_USIZE)).unwrap_or(u32::MAX) } /// One generated artifact. `contents` carries the emitted source whether From 40d3e65c4a9e57cfef0cb33bed077656294ddc0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andrzej=20=C5=9Awi=C4=99tek?= Date: Tue, 8 Sep 2026 19:52:31 +0200 Subject: [PATCH 09/11] Fixed the lint job by splitting the fixture-coverage check off the regenerator --- .github/workflows/CI.yml | 2 +- .github/workflows/docs.yml | 3 ++- package.json | 1 + scripts/check-fixture-coverage.ts | 22 ++++++++++++++++++++++ scripts/lib/snapshot-layout.ts | 17 +++++++++++++++++ scripts/regen-snapshots.ts | 14 ++------------ 6 files changed, 45 insertions(+), 14 deletions(-) create mode 100644 scripts/check-fixture-coverage.ts diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 7d2d81c..1fdb29d 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -50,7 +50,7 @@ jobs: - name: Typecheck run: bun run typecheck - name: Snapshot fixture coverage - run: bun run regen-snapshots + run: bun run check-fixture-coverage - name: Cargo fmt run: cargo fmt -- --check - name: Clippy diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index b96f6f2..8394c44 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -63,8 +63,9 @@ jobs: gitHubToken: ${{ secrets.GITHUB_TOKEN }} workingDirectory: website command: deploy + # A pull request from a fork gets no secrets, so wrangler cannot run. - name: Upload preview version - if: github.event_name == 'pull_request' + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository uses: cloudflare/wrangler-action@v3 with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} diff --git a/package.json b/package.json index 6791e7a..0b15b6d 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,7 @@ "typecheck:runtime": "tsc -p tsconfig.runtime.json", "typecheck:test": "tsc -p __test__/tsconfig.json", "prepublishOnly": "bun scripts/check-version-not-placeholder.ts && napi prepublish -t npm", + "check-fixture-coverage": "bun scripts/check-fixture-coverage.ts", "regen-snapshots": "bun scripts/regen-snapshots.ts", "test": "ava", "version": "napi version" diff --git a/scripts/check-fixture-coverage.ts b/scripts/check-fixture-coverage.ts new file mode 100644 index 0000000..39d1460 --- /dev/null +++ b/scripts/check-fixture-coverage.ts @@ -0,0 +1,22 @@ +#!/usr/bin/env bun +// Fails when a fixture in test/fixtures/ appears in none of the three sets +// in scripts/lib/snapshot-layout.ts. Needs no built binding. + +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { unclassifiedFixtures } from './lib/snapshot-layout.ts'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const unclassified = unclassifiedFixtures(repoRoot); + +if (unclassified.length > 0) { + console.error( + `${unclassified.length} fixture(s) appear in none of SUCCESS_FIXTURES, ` + + 'FAILURE_FIXTURES or UNSNAPSHOTTED:\n' + + unclassified.map(name => ` ${name}`).join('\n'), + ); + process.exit(1); +} + +console.log(`fixture coverage ok: every fixture in ${path.join('test', 'fixtures')} is classified`); diff --git a/scripts/lib/snapshot-layout.ts b/scripts/lib/snapshot-layout.ts index 2a8ac62..59a5dca 100644 --- a/scripts/lib/snapshot-layout.ts +++ b/scripts/lib/snapshot-layout.ts @@ -2,6 +2,7 @@ // laid out. Read by both scripts/regen-snapshots.ts and // __test__/generate.snapshot.spec.ts. +import fs from 'node:fs'; import path from 'node:path'; import type { GenerateOptions } from '../../index.js'; @@ -171,3 +172,19 @@ export const FAILURE_FIXTURES: readonly FailureFixture[] = [ * parser's line and column output; the spec asserts it by regex. */ export const UNSNAPSHOTTED: readonly string[] = ['malformed.yaml']; + +/** Extensions a fixture file carries. */ +const FIXTURE_RE = /\.(?:ya?ml|json)$/u; + +/** Fixtures on disk that appear in none of the three sets, in disk order. */ +export function unclassifiedFixtures(repoRoot: string): readonly string[] { + const classified = new Set([ + ...SUCCESS_FIXTURES.map(entry => entry.fixture), + ...FAILURE_FIXTURES.map(entry => entry.fixture), + ...UNSNAPSHOTTED, + ]); + return fs + .readdirSync(path.join(repoRoot, 'test', 'fixtures')) + .filter(name => FIXTURE_RE.test(name)) + .filter(name => !classified.has(name)); +} diff --git a/scripts/regen-snapshots.ts b/scripts/regen-snapshots.ts index 659d070..b63fcca 100644 --- a/scripts/regen-snapshots.ts +++ b/scripts/regen-snapshots.ts @@ -24,28 +24,18 @@ import { SNAPSHOT_EMIT, STATIC_TEMPLATE_PATHS, SUCCESS_FIXTURES, - UNSNAPSHOTTED, snapshotDir, + unclassifiedFixtures, } from './lib/snapshot-layout.ts'; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); -const fixturesDir = path.join(repoRoot, 'test', 'fixtures'); const snapshots = snapshotDir(repoRoot); const staticTemplateDir = path.join(snapshots, 'static-template'); const staticTemplateIndex = path.join(snapshots, 'static-template.json'); /** Fails when a fixture on disk appears in none of the three sets. */ function assertEveryFixtureIsClassified(): void { - const classified = new Set([ - ...SUCCESS_FIXTURES.map(entry => entry.fixture), - ...FAILURE_FIXTURES.map(entry => entry.fixture), - ...UNSNAPSHOTTED, - ]); - const unclassified = fs - .readdirSync(fixturesDir) - .filter(name => /\.(ya?ml|json)$/u.test(name)) - .filter(name => !classified.has(name)); - + const unclassified = unclassifiedFixtures(repoRoot); if (unclassified.length > 0) { console.error( `regen-snapshots: ${unclassified.length} fixture(s) appear in none of ` + From 7d19fd5321821d68c508ad7a3c95694162ca99c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andrzej=20=C5=9Awi=C4=99tek?= Date: Tue, 8 Sep 2026 19:52:31 +0200 Subject: [PATCH 10/11] Renamed the abbreviated modules and bindings and split the long functions --- __test__/generate.snapshot.spec.ts | 8 - index.d.ts | 7 +- src/{ir => api_model}/canonical.rs | 26 +- src/{ir => api_model}/mod.rs | 0 src/{ir => api_model}/normalize/mod.rs | 8 +- src/api_model/normalize/operations/body.rs | 160 +++++++++++ .../normalize/operations/form.rs | 268 +++++++++++------- .../normalize/operations/mod.rs | 23 +- .../normalize/operations/parameters.rs | 210 ++++++++++++++ .../normalize/operations/path_template.rs | 4 +- .../normalize/operations/responses.rs | 32 +-- .../normalize/schema/composition.rs | 2 +- .../normalize/schema/enums.rs | 0 src/{ir => api_model}/normalize/schema/map.rs | 2 +- src/{ir => api_model}/normalize/schema/mod.rs | 6 +- .../normalize/schema/reference.rs | 2 +- .../normalize/schema/tests.rs | 7 +- src/{ir => api_model}/normalize/semantic.rs | 208 ++++++++------ src/{ir => api_model}/normalize/tests.rs | 32 +-- src/{ir => api_model}/normalize/walk.rs | 0 src/{ir => api_model}/schema.rs | 15 +- src/{ir => api_model}/tests.rs | 59 ++-- src/emit/angular/imports.rs | 30 +- src/emit/angular/mod.rs | 10 +- src/emit/angular/request.rs | 105 ++++--- src/emit/angular/service.rs | 34 +-- src/emit/model/emit_ts_models.rs | 24 +- src/emit/model/mod.rs | 8 +- src/emit/ts/decl.rs | 15 +- src/emit/ts/imports.rs | 17 +- src/emit/ts/literal.rs | 4 +- src/emit/ts/mod.rs | 4 +- src/emit/ts/types.rs | 48 ++-- src/emit/ts/writer.rs | 25 ++ src/emit/ts_tests.rs | 8 +- src/{ident.rs => identifier.rs} | 34 +-- src/io/writer.rs | 8 +- src/ir/normalize/operations/body.rs | 143 ---------- src/ir/normalize/operations/parameters.rs | 117 -------- src/lib.rs | 4 +- src/options.rs | 21 +- src/parse/policy.rs | 87 +++--- src/pipeline.rs | 4 +- src/plan/artifact_plan.rs | 119 ++++---- src/plan/mod.rs | 16 +- src/plan/naming/case.rs | 44 ++- src/plan/naming/context.rs | 4 +- src/plan/naming/defaults.rs | 2 +- src/plan/naming/engine.rs | 2 +- src/plan/naming/fixed.rs | 4 +- src/plan/naming/mod.rs | 8 +- src/plan/naming/template.rs | 2 +- src/plan/services/body.rs | 66 ++--- src/plan/services/grouping.rs | 6 +- src/plan/services/mod.rs | 40 +-- src/result.rs | 2 +- src/test_support.rs | 42 +-- website/scripts/bundle-engine.ts | 33 +-- 58 files changed, 1227 insertions(+), 992 deletions(-) rename src/{ir => api_model}/canonical.rs (92%) rename src/{ir => api_model}/mod.rs (100%) rename src/{ir => api_model}/normalize/mod.rs (92%) create mode 100644 src/api_model/normalize/operations/body.rs rename src/{ir => api_model}/normalize/operations/form.rs (77%) rename src/{ir => api_model}/normalize/operations/mod.rs (88%) create mode 100644 src/api_model/normalize/operations/parameters.rs rename src/{ir => api_model}/normalize/operations/path_template.rs (98%) rename src/{ir => api_model}/normalize/operations/responses.rs (94%) rename src/{ir => api_model}/normalize/schema/composition.rs (98%) rename src/{ir => api_model}/normalize/schema/enums.rs (100%) rename src/{ir => api_model}/normalize/schema/map.rs (97%) rename src/{ir => api_model}/normalize/schema/mod.rs (97%) rename src/{ir => api_model}/normalize/schema/reference.rs (93%) rename src/{ir => api_model}/normalize/schema/tests.rs (94%) rename src/{ir => api_model}/normalize/semantic.rs (70%) rename src/{ir => api_model}/normalize/tests.rs (95%) rename src/{ir => api_model}/normalize/walk.rs (100%) rename src/{ir => api_model}/schema.rs (86%) rename src/{ir => api_model}/tests.rs (84%) rename src/{ident.rs => identifier.rs} (68%) delete mode 100644 src/ir/normalize/operations/body.rs delete mode 100644 src/ir/normalize/operations/parameters.rs diff --git a/__test__/generate.snapshot.spec.ts b/__test__/generate.snapshot.spec.ts index e6fb2aa..3b4e37f 100644 --- a/__test__/generate.snapshot.spec.ts +++ b/__test__/generate.snapshot.spec.ts @@ -4,13 +4,8 @@ import path from 'node:path'; import { execFileSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; -// Through the wrapper: a caught failure is a real `GenerateError`, whose -// `path` and `warnings` the snapshots pin. import { generate, isGenerateError } from '../scripts/lib/engine.ts'; import type { GenerateOptions } from '../scripts/lib/engine.ts'; -// Every fixture list, the banner regex and the static-template set come -// from the module the regenerator writes with, so reader and writer cannot -// drift from each other or from test/fixtures/. import { BANNER_RE, FAILURE_FIXTURES, @@ -31,9 +26,6 @@ function readJsonSnapshot(name: string) { return JSON.parse(fs.readFileSync(snapshot(name), 'utf8')); } -// Every fixture list, the banner regex and the static-template set come -// from the module the regenerator writes with, so reader and writer cannot -// drift from each other or from test/fixtures/. const STATIC_TEMPLATE_DIR = path.join( repoRoot, '__test__', diff --git a/index.d.ts b/index.d.ts index b393ef8..2564e82 100644 --- a/index.d.ts +++ b/index.d.ts @@ -111,11 +111,8 @@ export declare const InputFormat: { }; /** - * One caller-declared mapped type: replace the generated declaration for - * `schema` with `ty` imported from `import`. - * - * Field names match the config vocabulary. `ty` crosses the NAPI - * boundary as `type`. + * Replaces the generated declaration for `schema` with `type_name`, + * imported from `import`. Crosses the NAPI boundary as `type`. */ export interface MappedType { schema: string diff --git a/src/ir/canonical.rs b/src/api_model/canonical.rs similarity index 92% rename from src/ir/canonical.rs rename to src/api_model/canonical.rs index 3dc666e..d683173 100644 --- a/src/ir/canonical.rs +++ b/src/api_model/canonical.rs @@ -1,5 +1,5 @@ -use crate::ident::Ident; -use crate::ir::schema::{SchemaScalar, SchemaType}; +use crate::api_model::schema::{SchemaScalar, SchemaType}; +use crate::identifier::Identifier; /// A named, top-level schema declaration. /// @@ -30,7 +30,7 @@ pub(crate) struct RequestInputDef { pub(crate) name: Box, pub(crate) source: RequestInputSource, pub(crate) required: bool, - pub(crate) ty: SchemaType, + pub(crate) schema: SchemaType, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -43,7 +43,7 @@ pub(crate) enum RequestInputSource { pub(crate) struct HeaderDef { pub(crate) name: Box, pub(crate) required: bool, - pub(crate) ty: SchemaType, + pub(crate) schema: SchemaType, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -70,9 +70,9 @@ pub(crate) enum BodyContent { /// One field of a `multipart/form-data` or urlencoded body. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct BodyField { - pub(crate) name: Ident, + pub(crate) name: Identifier, pub(crate) required: bool, - pub(crate) ty: BodyFieldType, + pub(crate) field_type: BodyFieldType, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -229,23 +229,23 @@ mod tests { #[test] fn body_content_variants_have_distinct_payload_shapes() { - use crate::ir::schema::{SchemaScalar, SchemaType}; + use crate::api_model::schema::{SchemaScalar, SchemaType}; let json = BodyContent::Json(SchemaType::Scalar(SchemaScalar::String)); let multipart = BodyContent::Multipart { body_ref: None, fields: vec![BodyField { - name: Ident::parse("avatar").expect("identifier"), + name: Identifier::parse("avatar").expect("identifier"), required: true, - ty: BodyFieldType::Binary, + field_type: BodyFieldType::Binary, }], }; let url_encoded = BodyContent::UrlEncoded { body_ref: Some("LoginForm".into()), fields: vec![BodyField { - name: Ident::parse("username").expect("identifier"), + name: Identifier::parse("username").expect("identifier"), required: true, - ty: BodyFieldType::Scalar(SchemaScalar::String), + field_type: BodyFieldType::Scalar(SchemaScalar::String), }], }; @@ -256,7 +256,7 @@ mod tests { #[test] fn body_field_type_variants_cover_value_space() { - use crate::ir::schema::SchemaScalar; + use crate::api_model::schema::SchemaScalar; let scalar = BodyFieldType::Scalar(SchemaScalar::String); let array_of_scalar = BodyFieldType::ArrayOfScalar(SchemaScalar::Number); @@ -270,7 +270,7 @@ mod tests { #[test] fn response_content_variants_carry_expected_payloads() { - use crate::ir::schema::{SchemaScalar, SchemaType}; + use crate::api_model::schema::{SchemaScalar, SchemaType}; let json_with_schema = ResponseContent::Json(Some(SchemaType::Scalar(SchemaScalar::String))); let json_without = ResponseContent::Json(None); diff --git a/src/ir/mod.rs b/src/api_model/mod.rs similarity index 100% rename from src/ir/mod.rs rename to src/api_model/mod.rs diff --git a/src/ir/normalize/mod.rs b/src/api_model/normalize/mod.rs similarity index 92% rename from src/ir/normalize/mod.rs rename to src/api_model/normalize/mod.rs index 640e1f9..f789595 100644 --- a/src/ir/normalize/mod.rs +++ b/src/api_model/normalize/mod.rs @@ -7,9 +7,9 @@ mod walk; use std::collections::BTreeMap; +use crate::api_model::canonical::{ApiInfo, ApiModel}; +use crate::api_model::schema::SchemaType; use crate::error::{Diagnostic, DiagnosticCode, Reporter}; -use crate::ir::canonical::{ApiInfo, ApiModel}; -use crate::ir::schema::SchemaType; use crate::options::ResponseTypeMapping; use crate::parse::openapi_model::{OpenApiDocument, Schema}; use operations::normalize_operations; @@ -79,7 +79,7 @@ pub(crate) fn unsupported_rule(reporter: &Reporter, detail: impl AsRef) -> /// Returns an [`unsupported`] diagnostic from the enclosing function. macro_rules! bail_unsupported { ($reporter:expr, $($message:tt)*) => { - return ::core::result::Result::Err($crate::ir::normalize::unsupported( + return ::core::result::Result::Err($crate::api_model::normalize::unsupported( $reporter, ::std::format!($($message)*), )) @@ -89,7 +89,7 @@ macro_rules! bail_unsupported { /// Returns an [`unsupported_rule`] diagnostic from the enclosing function. macro_rules! bail_unsupported_rule { ($reporter:expr, $($message:tt)*) => { - return ::core::result::Result::Err($crate::ir::normalize::unsupported_rule( + return ::core::result::Result::Err($crate::api_model::normalize::unsupported_rule( $reporter, ::std::format!($($message)*), )) diff --git a/src/api_model/normalize/operations/body.rs b/src/api_model/normalize/operations/body.rs new file mode 100644 index 0000000..115d081 --- /dev/null +++ b/src/api_model/normalize/operations/body.rs @@ -0,0 +1,160 @@ +//! Request-body lowering: content-type dispatch onto JSON, multipart or +//! urlencoded. + +use crate::api_model::canonical::{BodyContent, BodyField, RequestBodyDef}; +use crate::api_model::schema::SchemaType; +use crate::error::{Context, Diagnostic, bail_policy}; +use crate::parse::openapi_model::{MediaType, RequestBody}; + +use super::super::schema::normalize_schema; +use super::super::{SchemaWalk, bail_unsupported, unsupported}; +use super::form::{FormBody, FormKind, normalize_form_body_fields}; +use super::{JSON, LoweringContext, MULTIPART, URL_ENCODED}; + +pub(super) fn normalize_request_body( + request_body: Option<&RequestBody>, + context: LoweringContext<'_>, +) -> Result, Diagnostic> { + let Some(body) = request_body else { + return Ok(None); + }; + + if body.content.len() > 1 { + bail_policy!( + context.reporter(), + "multi-content-body", + "requestBody for {} {} must declare exactly one content type.", + context.method(), + context.path() + ); + } + + let Some((mime, media)) = body.content.iter().next() else { + return Ok(None); + }; + + Ok(Some(RequestBodyDef { + required: body.required, + // OpenAPI permits MIME case variation (`Application/JSON`). + content: normalize_content(&mime.to_ascii_lowercase(), media, context)?, + })) +} + +fn normalize_content( + mime: &str, + media: &MediaType, + context: LoweringContext<'_>, +) -> Result { + match mime { + JSON => normalize_json_body(media, context).map(BodyContent::Json), + MULTIPART => { + let (body_ref, fields) = normalize_form(FormKind::Multipart, media, context)?; + Ok(BodyContent::Multipart { body_ref, fields }) + } + URL_ENCODED => { + let (body_ref, fields) = normalize_form(FormKind::UrlEncoded, media, context)?; + Ok(BodyContent::UrlEncoded { body_ref, fields }) + } + other => bail_policy!( + context.reporter(), + "unsupported-body-content-type", + "requestBody for {} {}: unsupported content type {other:?}. Use {JSON}, {MULTIPART}, or {URL_ENCODED}.", + context.method(), + context.path() + ), + } +} + +/// Rejects a JSON body that declares no schema, or one no more precise than +/// `{}`. +fn normalize_json_body( + media: &MediaType, + context: LoweringContext<'_>, +) -> Result { + let (method, path, reporter) = (context.method(), context.path(), context.reporter()); + let declared = media.schema.as_ref().ok_or_else(|| { + unsupported( + reporter, + format!("requestBody for {method} {path} must define schema."), + ) + })?; + + let walk = SchemaWalk::root(Context::RequestBody { method, path }, reporter); + let schema = normalize_schema(declared, walk)?; + + if matches!(schema, SchemaType::Any) { + bail_unsupported!( + reporter, + "requestBody for {method} {path} must define a concrete schema." + ); + } + Ok(schema) +} + +fn normalize_form( + kind: FormKind, + media: &MediaType, + context: LoweringContext<'_>, +) -> Result<(Option>, Vec), Diagnostic> { + normalize_form_body_fields( + media, + FormBody::new(kind, context.method(), context.path(), context.reporter()), + context.schemas(), + ) +} + +#[cfg(test)] +mod tests { + use super::super::LoweringContext; + + fn test_cx<'a>( + schemas: &'a BTreeMap<&'a str, &'a SchemaType>, + reporter: &'a crate::error::Reporter, + ) -> LoweringContext<'a> { + LoweringContext::new("POST", "/x", schemas, &[], reporter) + } + use std::collections::BTreeMap; + + use super::normalize_request_body; + use crate::api_model::schema::SchemaType; + use crate::parse::openapi_model::RequestBody; + use crate::test_support::test_reporter; + + fn parse_request_body(yaml: &str) -> RequestBody { + serde_yml::from_str(yaml).expect("fixture parses as RequestBody") + } + + fn empty_schema_index<'a>() -> BTreeMap<&'a str, &'a SchemaType> { + BTreeMap::new() + } + + #[test] + fn rejects_body_with_multiple_content_types() { + let yaml = r#" +content: + application/json: + schema: { type: object, properties: { x: { type: string } } } + multipart/form-data: + schema: { type: object, properties: { x: { type: string } } } +"#; + let body = parse_request_body(yaml); + let ctx = test_reporter(); + let err = normalize_request_body(Some(&body), test_cx(&empty_schema_index(), &ctx)) + .expect_err("multi-content should fail"); + assert_eq!(err.subcode, Some("multi-content-body")); + } + + #[test] + fn rejects_unsupported_body_content_type() { + let yaml = r#" +content: + application/xml: + schema: { type: object, properties: { x: { type: string } } } +"#; + let body = parse_request_body(yaml); + let ctx = test_reporter(); + let err = normalize_request_body(Some(&body), test_cx(&empty_schema_index(), &ctx)) + .expect_err("xml body should fail"); + assert_eq!(err.subcode, Some("unsupported-body-content-type")); + } +} diff --git a/src/ir/normalize/operations/form.rs b/src/api_model/normalize/operations/form.rs similarity index 77% rename from src/ir/normalize/operations/form.rs rename to src/api_model/normalize/operations/form.rs index e26a1e1..cc41245 100644 --- a/src/ir/normalize/operations/form.rs +++ b/src/api_model/normalize/operations/form.rs @@ -3,14 +3,15 @@ use std::collections::BTreeMap; +use crate::api_model::canonical::{BodyField, BodyFieldType}; +use crate::api_model::schema::{SchemaProperty, SchemaScalar, SchemaType}; use crate::error::{Context, Diagnostic, Reporter, bail_policy}; -use crate::ident::Ident; -use crate::ir::canonical::{BodyField, BodyFieldType}; -use crate::ir::schema::{SchemaProperty, SchemaScalar, SchemaType}; +use crate::identifier::Identifier; use crate::parse::openapi_model::{AdditionalProperties, MediaType, Schema}; use super::super::schema::normalize_schema; use super::super::{SchemaWalk, unsupported}; +use super::URL_ENCODED; /// The form flavour, the operation position and the diagnostic sink every /// rejection message needs. @@ -84,6 +85,9 @@ impl FormKind { } } +/// The `format` marking a binary field. +const BINARY: &str = "binary"; + /// Subcode for a binary field in a urlencoded body, scalar or array. const URLENCODED_BINARY_FIELD: &str = "urlencoded-binary-field"; @@ -95,64 +99,41 @@ pub(super) fn normalize_form_body_fields( body: FormBody<'_>, schema_index: &BTreeMap<&str, &SchemaType>, ) -> Result<(Option>, Vec), Diagnostic> { - let FormBody { - kind, - method, - path, - reporter, - } = body; - let raw_schema = media.schema.as_ref().ok_or_else(|| { - Diagnostic::policy_violation( - reporter, - "missing-body-schema", - format!("requestBody for {method} {path} must define schema."), - ) - })?; - - // Only `additionalProperties: false` and its absence leave the field - // set closed. - if let Some(ap) = &raw_schema.additional_properties - && !matches!(ap, AdditionalProperties::Boolean(false)) - { - bail_policy!( - reporter, - kind.subcode(Reject::OpenSchema), - "requestBody for {method} {path}: {} bodies must not declare additionalProperties; every field must be enumerated.", - kind.label(), - ); - } + let raw_schema = declared_schema(media, body)?; + reject_open_schema(raw_schema, body)?; - let walk = SchemaWalk::root(Context::RequestBody { method, path }, reporter); + let walk = SchemaWalk::root( + Context::RequestBody { + method: body.method, + path: body.path, + }, + body.reporter, + ); let normalized = normalize_schema(raw_schema, walk)?; - - let (body_ref, resolved_ty): (Option>, &SchemaType) = match &normalized { - SchemaType::Ref(name) => { - let resolved = schema_index.get(name.as_ref()).ok_or_else(|| { - unsupported( - reporter, - format!("requestBody for {method} {path} references unknown schema '{name}'.",), - ) - })?; - (Some(name.clone()), *resolved) - } + let (body_ref, resolved) = match &normalized { + SchemaType::Ref(name) => ( + Some(name.clone()), + referenced_schema(name.as_ref(), body, schema_index)?, + ), other => (None, other), }; - let SchemaType::InlineObject { properties } = resolved_ty else { + let SchemaType::InlineObject { properties } = resolved else { bail_policy!( - reporter, - kind.subcode(Reject::NonObjectBody), - "requestBody for {method} {path}: {} body schema must resolve to an object.", - kind.label(), + body.reporter, + body.kind.subcode(Reject::NonObjectBody), + "requestBody for {} {}: {} body schema must resolve to an object.", + body.method, + body.path, + body.kind.label(), ); }; - let raw_property_lookup = collect_raw_property_formats(raw_schema); - + let raw_formats = collect_raw_property_formats(raw_schema); let mut fields = properties .iter() .map(|property| { - let raw_format = raw_property_lookup + let raw_format = raw_formats .get(property.name.as_ref()) .copied() .unwrap_or_default(); @@ -164,6 +145,54 @@ pub(super) fn normalize_form_body_fields( Ok((body_ref, fields)) } +fn declared_schema<'a>(media: &'a MediaType, body: FormBody<'_>) -> Result<&'a Schema, Diagnostic> { + media.schema.as_ref().ok_or_else(|| { + Diagnostic::policy_violation( + body.reporter, + "missing-body-schema", + format!( + "requestBody for {} {} must define schema.", + body.method, body.path + ), + ) + }) +} + +/// Only `additionalProperties: false` and its absence leave the field set +/// closed. +fn reject_open_schema(raw_schema: &Schema, body: FormBody<'_>) -> Result<(), Diagnostic> { + if let Some(additional) = &raw_schema.additional_properties + && !matches!(additional, AdditionalProperties::Boolean(false)) + { + bail_policy!( + body.reporter, + body.kind.subcode(Reject::OpenSchema), + "requestBody for {} {}: {} bodies must not declare additionalProperties; every field must be enumerated.", + body.method, + body.path, + body.kind.label(), + ); + } + Ok(()) +} + +/// The schema a top-level `$ref` names, from the document's schema index. +fn referenced_schema<'a>( + name: &str, + body: FormBody<'_>, + schema_index: &'a BTreeMap<&str, &'a SchemaType>, +) -> Result<&'a SchemaType, Diagnostic> { + schema_index.get(name).copied().ok_or_else(|| { + unsupported( + body.reporter, + format!( + "requestBody for {} {} references unknown schema '{name}'.", + body.method, body.path + ), + ) + }) +} + /// Lowers one body property. fn body_field( property: &SchemaProperty, @@ -176,7 +205,7 @@ fn body_field( reporter, .. } = body; - let Some(name) = Ident::parse(property.name.as_ref()) else { + let Some(name) = Identifier::parse(property.name.as_ref()) else { bail_policy!( reporter, "invalid-form-field-name", @@ -188,7 +217,12 @@ fn body_field( Ok(BodyField { name, required: property.required, - ty: classify_body_field_type(&property.ty, raw_format, property.name.as_ref(), body)?, + field_type: classify_body_field_type( + &property.schema, + raw_format, + property.name.as_ref(), + body, + )?, }) } @@ -223,89 +257,109 @@ fn collect_raw_property_formats(raw_schema: &Schema) -> BTreeMap<&str, RawProper /// Classifies one form-body property. Accepts a scalar, a binary, or an /// array of either; every other shape fails with the matching [`Reject`]. fn classify_body_field_type( - ty: &SchemaType, + schema: &SchemaType, raw_format: RawPropertyFormat<'_>, field_name: &str, body: FormBody<'_>, ) -> Result { - let FormBody { - kind, - method, - path, - reporter, - } = body; - match ty { - SchemaType::Scalar(SchemaScalar::String) if raw_format.own == Some("binary") => match kind { - FormKind::Multipart => Ok(BodyFieldType::Binary), - FormKind::UrlEncoded => Err(Diagnostic::policy_violation( - reporter, - URLENCODED_BINARY_FIELD, - format!( - "body field '{field_name}' in {method} {path}: binary fields are not supported in application/x-www-form-urlencoded." - ), - )), - }, + match schema { + SchemaType::Scalar(SchemaScalar::String) if raw_format.own == Some(BINARY) => { + binary_field(BodyFieldType::Binary, "binary", field_name, body) + } SchemaType::Array(inner) if matches!(inner.as_ref(), SchemaType::Scalar(SchemaScalar::String)) - && raw_format.items == Some("binary") => + && raw_format.items == Some(BINARY) => { - match kind { - FormKind::Multipart => Ok(BodyFieldType::ArrayOfBinary), - FormKind::UrlEncoded => Err(Diagnostic::policy_violation( - reporter, - URLENCODED_BINARY_FIELD, - format!( - "body field '{field_name}' in {method} {path}: array-of-binary fields are not supported in application/x-www-form-urlencoded." - ), - )), - } + binary_field( + BodyFieldType::ArrayOfBinary, + "array-of-binary", + field_name, + body, + ) } SchemaType::Scalar(scalar) => Ok(BodyFieldType::Scalar(scalar.clone())), SchemaType::Array(inner) => match inner.as_ref() { SchemaType::Scalar(scalar) => Ok(BodyFieldType::ArrayOfScalar(scalar.clone())), - - _ => Err(Diagnostic::policy_violation( - reporter, - kind.subcode(Reject::ComposedField), - format!( - "body field '{field_name}' in {method} {path}: array items must be scalar or binary." - ), + _ => Err(reject_field( + Reject::ComposedField, + "array items must be scalar or binary.".to_string(), + field_name, + body, )), }, - SchemaType::InlineObject { .. } | SchemaType::Ref(_) => Err(Diagnostic::policy_violation( - reporter, - kind.subcode(Reject::NestedObject), + SchemaType::InlineObject { .. } | SchemaType::Ref(_) => Err(reject_field( + Reject::NestedObject, format!( - "body field '{field_name}' in {method} {path}: nested objects are not supported in {} bodies.", - kind.label(), + "nested objects are not supported in {} bodies.", + body.kind.label() ), + field_name, + body, )), - _ => Err(Diagnostic::policy_violation( - reporter, - kind.subcode(Reject::ComposedField), + _ => Err(reject_field( + Reject::ComposedField, + format!( + "composed schemas are not supported in {} bodies.", + body.kind.label() + ), + field_name, + body, + )), + } +} + +/// A binary field, which only multipart can carry. +fn binary_field( + carried: BodyFieldType, + label: &str, + field_name: &str, + body: FormBody<'_>, +) -> Result { + match body.kind { + FormKind::Multipart => Ok(carried), + FormKind::UrlEncoded => Err(Diagnostic::policy_violation( + body.reporter, + URLENCODED_BINARY_FIELD, format!( - "body field '{field_name}' in {method} {path}: composed schemas are not supported in {} bodies.", - kind.label(), + "body field '{field_name}' in {} {}: {label} fields are not supported in {URL_ENCODED}.", + body.method, body.path ), )), } } +/// A rejected field, its `detail` appended to the field's position. +fn reject_field( + reject: Reject, + detail: String, + field_name: &str, + body: FormBody<'_>, +) -> Diagnostic { + Diagnostic::policy_violation( + body.reporter, + body.kind.subcode(reject), + format!( + "body field '{field_name}' in {} {}: {detail}", + body.method, body.path + ), + ) +} + #[cfg(test)] mod tests { - use super::super::OperationCx; + use super::super::LoweringContext; fn test_cx<'a>( schemas: &'a BTreeMap<&'a str, &'a SchemaType>, reporter: &'a crate::error::Reporter, - ) -> OperationCx<'a> { - OperationCx::new("POST", "/x", schemas, &[], reporter) + ) -> LoweringContext<'a> { + LoweringContext::new("POST", "/x", schemas, &[], reporter) } use super::URLENCODED_BINARY_FIELD; use std::collections::BTreeMap; - use crate::ir::canonical::{BodyContent, BodyFieldType}; - use crate::ir::schema::{SchemaProperty, SchemaScalar, SchemaType}; + use crate::api_model::canonical::{BodyContent, BodyFieldType}; + use crate::api_model::schema::{SchemaProperty, SchemaScalar, SchemaType}; use crate::parse::openapi_model::RequestBody; use crate::test_support::test_reporter; @@ -348,14 +402,14 @@ content: .iter() .find(|field| field.name.as_str() == "avatar") .unwrap(); - assert_eq!(avatar.ty, BodyFieldType::Binary); + assert_eq!(avatar.field_type, BodyFieldType::Binary); assert!(avatar.required); let status = fields .iter() .find(|field| field.name.as_str() == "status") .unwrap(); assert!(matches!( - status.ty, + status.field_type, BodyFieldType::Scalar(SchemaScalar::String) )); assert!(status.required); @@ -369,7 +423,7 @@ content: .find(|field| field.name.as_str() == "tagIds") .unwrap(); assert!(matches!( - tag_ids.ty, + tag_ids.field_type, BodyFieldType::ArrayOfScalar(SchemaScalar::Number) )); } @@ -397,7 +451,7 @@ content: match result.content { BodyContent::Multipart { fields, .. } => { assert_eq!(fields.len(), 1); - assert_eq!(fields[0].ty, BodyFieldType::ArrayOfBinary); + assert_eq!(fields[0].field_type, BodyFieldType::ArrayOfBinary); } other => panic!("expected Multipart, got {other:?}"), } @@ -416,7 +470,7 @@ content: properties: vec![SchemaProperty { name: "status".into(), required: true, - ty: SchemaType::Scalar(SchemaScalar::String), + schema: SchemaType::Scalar(SchemaScalar::String), description: None, deprecated: false, }], diff --git a/src/ir/normalize/operations/mod.rs b/src/api_model/normalize/operations/mod.rs similarity index 88% rename from src/ir/normalize/operations/mod.rs rename to src/api_model/normalize/operations/mod.rs index 7721a9a..585ebc1 100644 --- a/src/ir/normalize/operations/mod.rs +++ b/src/api_model/normalize/operations/mod.rs @@ -11,11 +11,11 @@ mod responses; use std::collections::BTreeMap; -use crate::error::{Diagnostic, Reporter}; -use crate::ir::canonical::{ +use crate::api_model::canonical::{ HttpMethod, OperationDef, RequestDef, RequestInputDef, RequestInputSource, }; -use crate::ir::schema::SchemaType; +use crate::api_model::schema::SchemaType; +use crate::error::{Diagnostic, Reporter}; use crate::options::ResponseTypeMapping; use crate::parse::openapi_model::{Operation, PathItem}; @@ -25,13 +25,18 @@ use parameters::normalize_request_inputs; use path_template::validate_path_template; use responses::{normalize_error_responses, normalize_success_response}; +/// The media types a request body may declare. +pub(super) const JSON: &str = "application/json"; +pub(super) const MULTIPART: &str = "multipart/form-data"; +pub(super) const URL_ENCODED: &str = "application/x-www-form-urlencoded"; + /// Everything an operation's lowering needs besides the operation itself: /// where it sits, the schemas its `$ref`s may resolve to, the caller's /// response-kind overrides, and the diagnostic sink. /// /// `method` is the canonical upper-case name. #[derive(Clone, Copy)] -pub(super) struct OperationCx<'a> { +pub(super) struct LoweringContext<'a> { method: &'a str, path: &'a str, schemas: &'a BTreeMap<&'a str, &'a SchemaType>, @@ -39,7 +44,7 @@ pub(super) struct OperationCx<'a> { reporter: &'a Reporter, } -impl<'a> OperationCx<'a> { +impl<'a> LoweringContext<'a> { pub(super) const fn new( method: &'a str, path: &'a str, @@ -114,7 +119,7 @@ fn normalize_operation( .clone() .unwrap_or_else(|| format!("{declared_method}_{}", path.replace(['/', '{', '}'], "_"))); - let context = OperationCx::new(method.as_str(), path, schemas, response_types, reporter); + let context = LoweringContext::new(method.as_str(), path, schemas, response_types, reporter); Ok(OperationDef { request: normalize_request(operation, &operation_id, context)?, @@ -142,13 +147,13 @@ fn unsupported_method_detail(declared_method: &str, path: &str) -> String { fn normalize_request( operation: &Operation, operation_id: &str, - cx: OperationCx<'_>, + context: LoweringContext<'_>, ) -> Result { - let (inputs, headers) = normalize_request_inputs(&operation.parameters, operation_id, cx)?; + let (inputs, headers) = normalize_request_inputs(&operation.parameters, operation_id, context)?; Ok(RequestDef { inputs, headers, - body: normalize_request_body(operation.request_body.as_ref(), cx)?, + body: normalize_request_body(operation.request_body.as_ref(), context)?, }) } diff --git a/src/api_model/normalize/operations/parameters.rs b/src/api_model/normalize/operations/parameters.rs new file mode 100644 index 0000000..5d963c9 --- /dev/null +++ b/src/api_model/normalize/operations/parameters.rs @@ -0,0 +1,210 @@ +//! `in: path` / `in: query` / `in: header` parameter lowering. + +use crate::api_model::canonical::{HeaderDef, RequestInputDef, RequestInputSource}; +use crate::api_model::schema::SchemaType; +use crate::error::{Diagnostic, DiagnosticCode}; + +use super::super::schema::normalize_schema; +use super::super::{SchemaWalk, bail_unsupported, unsupported}; +use crate::error::Context; + +use super::{LoweringContext, request_input_sort_key}; + +/// Which slot of the request contract a parameter lands in. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Destination { + Input(RequestInputSource), + Header, +} + +impl Destination { + /// The slot `location` names. `Ok(None)` is a `cookie`, which the + /// contract omits. + fn parse( + location: &str, + name: &str, + operation_id: &str, + context: LoweringContext<'_>, + ) -> Result, Diagnostic> { + let reporter = context.reporter(); + match location { + "path" => Ok(Some(Self::Input(RequestInputSource::Path))), + "query" => Ok(Some(Self::Input(RequestInputSource::Query))), + "header" => Ok(Some(Self::Header)), + "cookie" => { + reporter.warning( + DiagnosticCode::UnsupportedSemantic, + Some("unsupported-parameter-location"), + format!( + "operationId '{operation_id}': parameter '{name}' uses location 'cookie', which is not supported in the generated service contract and will be omitted.", + ), + ); + Ok(None) + } + other => bail_unsupported!( + reporter, + "parameter {name} for {} {} uses unsupported location {other}.", + context.method(), + context.path() + ), + } + } +} + +/// A normalized schema shape that a parameter position cannot carry. +#[derive(Clone, Copy)] +enum UnsupportedShape { + InlineObject, + Empty, +} + +impl UnsupportedShape { + /// The shape `schema` presents, or `None` when the position accepts it. + const fn of(schema: &SchemaType) -> Option { + match schema { + SchemaType::InlineObject { .. } => Some(Self::InlineObject), + SchemaType::Any => Some(Self::Empty), + _ => None, + } + } + + const fn label(self) -> &'static str { + match self { + Self::InlineObject => "an inline object schema", + Self::Empty => "an empty schema", + } + } +} + +/// One parameter reduced to the slot it fills and the type it carries. +struct Parameter { + destination: Destination, + name: Box, + required: bool, + schema: SchemaType, +} + +impl Parameter { + fn into_input(self, source: RequestInputSource) -> RequestInputDef { + RequestInputDef { + name: self.name, + source, + required: self.required, + schema: self.schema, + } + } + + fn into_header(self) -> HeaderDef { + HeaderDef { + name: self.name, + required: self.required, + schema: self.schema, + } + } +} + +/// Lowers an operation's parameters into its path/query inputs and its +/// header list, each sorted by name. +pub(super) fn normalize_request_inputs( + parameters: &[crate::parse::openapi_model::Parameter], + operation_id: &str, + context: LoweringContext<'_>, +) -> Result<(Vec, Vec), Diagnostic> { + let lowered = parameters + .iter() + .map(|parameter| lower(parameter, operation_id, context)) + .collect::, Diagnostic>>()?; + + let (mut inputs, mut headers) = lowered.into_iter().flatten().fold( + (Vec::with_capacity(parameters.len()), Vec::new()), + |(mut inputs, mut headers), parameter| { + match parameter.destination { + Destination::Input(source) => inputs.push(parameter.into_input(source)), + Destination::Header => headers.push(parameter.into_header()), + } + (inputs, headers) + }, + ); + + inputs.sort_by(|left, right| request_input_sort_key(left).cmp(&request_input_sort_key(right))); + headers.sort_by(|left, right| left.name.cmp(&right.name)); + Ok((inputs, headers)) +} + +/// Lowers one parameter, or `None` when the contract omits it. +fn lower( + parameter: &crate::parse::openapi_model::Parameter, + operation_id: &str, + context: LoweringContext<'_>, +) -> Result, Diagnostic> { + let name = ¶meter.name; + let Some(destination) = + Destination::parse(parameter.location.as_str(), name, operation_id, context)? + else { + return Ok(None); + }; + + reject_unsupported_declaration(parameter, destination, context)?; + + Ok(Some(Parameter { + destination, + name: name.as_str().into(), + required: parameter.required, + schema: normalize_parameter_schema(parameter, context)?, + })) +} + +/// Rejects an optional path parameter and one declared with `content`. +fn reject_unsupported_declaration( + parameter: &crate::parse::openapi_model::Parameter, + destination: Destination, + context: LoweringContext<'_>, +) -> Result<(), Diagnostic> { + let (method, path, reporter) = (context.method(), context.path(), context.reporter()); + let name = ¶meter.name; + + if destination == Destination::Input(RequestInputSource::Path) && !parameter.required { + bail_unsupported!( + reporter, + "path parameter {name} for {method} {path} must be required." + ); + } + + if parameter.content.is_some() { + bail_unsupported!( + reporter, + "parameter {name} for {method} {path} must use schema, not content." + ); + } + + Ok(()) +} + +/// Normalizes a parameter's declared schema, rejecting a shape the position +/// cannot carry. +fn normalize_parameter_schema( + parameter: &crate::parse::openapi_model::Parameter, + context: LoweringContext<'_>, +) -> Result { + let (method, path, reporter) = (context.method(), context.path(), context.reporter()); + let name = ¶meter.name; + + let declared = parameter.schema.as_ref().ok_or_else(|| { + unsupported( + reporter, + format!("parameter {name} for {method} {path} must define schema."), + ) + })?; + + let walk = SchemaWalk::root(Context::Parameter { method, path }, reporter); + let schema = normalize_schema(declared, walk)?; + + if let Some(shape) = UnsupportedShape::of(&schema) { + bail_unsupported!( + reporter, + "parameter {name} for {method} {path} uses {}, which is outside the supported subset.", + shape.label() + ); + } + Ok(schema) +} diff --git a/src/ir/normalize/operations/path_template.rs b/src/api_model/normalize/operations/path_template.rs similarity index 98% rename from src/ir/normalize/operations/path_template.rs rename to src/api_model/normalize/operations/path_template.rs index 0ab33df..0921fab 100644 --- a/src/ir/normalize/operations/path_template.rs +++ b/src/api_model/normalize/operations/path_template.rs @@ -1,7 +1,7 @@ //! Path-template validation. use crate::error::{Diagnostic, Reporter, bail_policy}; -use crate::ident::is_ident; +use crate::identifier::is_identifier; use super::super::bail_unsupported; @@ -27,7 +27,7 @@ pub(super) fn validate_path_template(path: &str, reporter: &Reporter) -> Result< ); }; let name = &after_open[..close]; - if !is_ident(name) { + if !is_identifier(name) { bail_policy!( reporter, "invalid-path-parameter-name", diff --git a/src/ir/normalize/operations/responses.rs b/src/api_model/normalize/operations/responses.rs similarity index 94% rename from src/ir/normalize/operations/responses.rs rename to src/api_model/normalize/operations/responses.rs index 26f9d96..e097e76 100644 --- a/src/ir/normalize/operations/responses.rs +++ b/src/api_model/normalize/operations/responses.rs @@ -2,18 +2,18 @@ use std::collections::BTreeMap; +use crate::api_model::canonical::{ErrorResponse, ResponseContent}; use crate::error::{Context, Diagnostic}; -use crate::ir::canonical::{ErrorResponse, ResponseContent}; use crate::options::{ResponseType, ResponseTypeMapping}; use crate::parse::openapi_model::{MediaType, Response}; use super::super::SchemaWalk; use super::super::schema::normalize_schema; -use super::OperationCx; +use super::LoweringContext; pub(super) fn normalize_success_response( responses: Option<&BTreeMap>, - cx: OperationCx<'_>, + context: LoweringContext<'_>, ) -> Result, Diagnostic> { let Some(responses) = responses else { return Ok(None); @@ -30,17 +30,17 @@ pub(super) fn normalize_success_response( return Ok(None); }; - let Some((mime, media)) = pick_response_media(content, cx.response_types()) else { + let Some((mime, media)) = pick_response_media(content, context.response_types()) else { return Ok(None); }; - let kind = classify_response_kind(mime, cx.response_types()); + let kind = classify_response_kind(mime, context.response_types()); let walk = SchemaWalk::root( Context::ResponseSchema { - method: cx.method(), - path: cx.path(), + method: context.method(), + path: context.path(), }, - cx.reporter(), + context.reporter(), ); Ok(Some(match kind { @@ -63,7 +63,7 @@ pub(super) fn normalize_success_response( /// Skips a schemaless response, a non-JSON one, and the `default` key. pub(super) fn normalize_error_responses( responses: Option<&BTreeMap>, - cx: OperationCx<'_>, + context: LoweringContext<'_>, ) -> Result, Diagnostic> { let Some(responses) = responses else { return Ok(Vec::new()); @@ -71,10 +71,10 @@ pub(super) fn normalize_error_responses( let walk = SchemaWalk::root( Context::ResponseSchema { - method: cx.method(), - path: cx.path(), + method: context.method(), + path: context.path(), }, - cx.reporter(), + context.reporter(), ); let mut errors = responses .iter() @@ -174,15 +174,15 @@ fn classify_response_kind( #[cfg(test)] mod tests { - use super::super::OperationCx; + use super::super::LoweringContext; fn test_cx<'a>( response_types: &'a [ResponseTypeMapping], reporter: &'a crate::error::Reporter, - ) -> OperationCx<'a> { - static EMPTY: std::sync::LazyLock> = + ) -> LoweringContext<'a> { + static EMPTY: std::sync::LazyLock> = std::sync::LazyLock::new(BTreeMap::new); - OperationCx::new("GET", "/x", &EMPTY, response_types, reporter) + LoweringContext::new("GET", "/x", &EMPTY, response_types, reporter) } use std::collections::BTreeMap; diff --git a/src/ir/normalize/schema/composition.rs b/src/api_model/normalize/schema/composition.rs similarity index 98% rename from src/ir/normalize/schema/composition.rs rename to src/api_model/normalize/schema/composition.rs index 0fe9897..e15ee4e 100644 --- a/src/ir/normalize/schema/composition.rs +++ b/src/api_model/normalize/schema/composition.rs @@ -3,8 +3,8 @@ use std::collections::BTreeMap; +use crate::api_model::schema::{Discriminator, SchemaType}; use crate::error::Diagnostic; -use crate::ir::schema::{Discriminator, SchemaType}; use crate::parse::openapi_model::{self, Schema}; use super::super::{SchemaWalk, bail_unsupported}; diff --git a/src/ir/normalize/schema/enums.rs b/src/api_model/normalize/schema/enums.rs similarity index 100% rename from src/ir/normalize/schema/enums.rs rename to src/api_model/normalize/schema/enums.rs diff --git a/src/ir/normalize/schema/map.rs b/src/api_model/normalize/schema/map.rs similarity index 97% rename from src/ir/normalize/schema/map.rs rename to src/api_model/normalize/schema/map.rs index 1d02aa1..fa8e666 100644 --- a/src/ir/normalize/schema/map.rs +++ b/src/api_model/normalize/schema/map.rs @@ -1,7 +1,7 @@ //! `additionalProperties` lowering into `Record`. +use crate::api_model::schema::SchemaType; use crate::error::Diagnostic; -use crate::ir::schema::SchemaType; use crate::parse::openapi_model::{AdditionalProperties, Schema}; use super::super::{SchemaWalk, bail_unsupported_rule}; diff --git a/src/ir/normalize/schema/mod.rs b/src/api_model/normalize/schema/mod.rs similarity index 97% rename from src/ir/normalize/schema/mod.rs rename to src/api_model/normalize/schema/mod.rs index b2ecd53..ec9c069 100644 --- a/src/ir/normalize/schema/mod.rs +++ b/src/api_model/normalize/schema/mod.rs @@ -14,9 +14,9 @@ mod tests; use std::collections::{BTreeMap, HashSet}; +use crate::api_model::canonical::ModelSymbol; +use crate::api_model::schema::{SchemaProperty, SchemaScalar, SchemaType}; use crate::error::{Context, Diagnostic, Reporter}; -use crate::ir::canonical::ModelSymbol; -use crate::ir::schema::{SchemaProperty, SchemaScalar, SchemaType}; use crate::parse::openapi_model::{AdditionalProperties, Schema}; use super::{SchemaWalk, bail_unsupported, bail_unsupported_rule, check_unsupported_not}; @@ -101,7 +101,7 @@ pub(super) fn normalize_properties( Ok(SchemaProperty { name: name.as_str().into(), required: required.contains(name.as_str()), - ty: apply_nullable_flag(base, property.nullable.unwrap_or(false)), + schema: apply_nullable_flag(base, property.nullable.unwrap_or(false)), description: property.description.clone(), deprecated: property.deprecated, }) diff --git a/src/ir/normalize/schema/reference.rs b/src/api_model/normalize/schema/reference.rs similarity index 93% rename from src/ir/normalize/schema/reference.rs rename to src/api_model/normalize/schema/reference.rs index 6964c70..a383fb7 100644 --- a/src/ir/normalize/schema/reference.rs +++ b/src/api_model/normalize/schema/reference.rs @@ -11,7 +11,7 @@ const INTERNAL_SCHEMA_PREFIX: &str = "#/components/schemas/"; /// /// Rejects a reference outside `components.schemas` — an external file, a /// URL, another component section — and one whose target name is empty. -pub(in crate::ir::normalize::schema) fn normalize_reference( +pub(in crate::api_model::normalize::schema) fn normalize_reference( reference: &str, walk: SchemaWalk<'_>, ) -> Result, Diagnostic> { diff --git a/src/ir/normalize/schema/tests.rs b/src/api_model/normalize/schema/tests.rs similarity index 94% rename from src/ir/normalize/schema/tests.rs rename to src/api_model/normalize/schema/tests.rs index c0b5514..d345908 100644 --- a/src/ir/normalize/schema/tests.rs +++ b/src/api_model/normalize/schema/tests.rs @@ -46,10 +46,9 @@ proptest! { #[test] fn depth_exceeded_diagnostic_includes_breadcrumb_chain() { // Build a 40-level-deep schema by wrapping in array; MAX_NORMALIZE_DEPTH is 32. - let mut schema = Schema::default_string(); - for _ in 0..40 { - schema = Schema::wrap_array(schema); - } + let schema = (0..40).fold(Schema::default_string(), |inner, _| { + Schema::wrap_array(inner) + }); let path: Rc = Rc::from("test"); let reporter = Reporter::new(path); diff --git a/src/ir/normalize/semantic.rs b/src/api_model/normalize/semantic.rs similarity index 70% rename from src/ir/normalize/semantic.rs rename to src/api_model/normalize/semantic.rs index 70684ec..40827f1 100644 --- a/src/ir/normalize/semantic.rs +++ b/src/api_model/normalize/semantic.rs @@ -3,9 +3,13 @@ use std::collections::{BTreeMap, BTreeSet}; +use crate::api_model::canonical::{ + ApiModel, BodyContent, ModelSymbol, OperationDef, ResponseContent, +}; +use crate::api_model::schema::{ + Discriminator, SchemaProperty, SchemaScalar, SchemaType, collect_type_references, +}; use crate::error::{Diagnostic, DiagnosticCode, Reporter, bail, bail_policy}; -use crate::ir::canonical::{ApiModel, BodyContent, ModelSymbol, OperationDef, ResponseContent}; -use crate::ir::schema::{SchemaProperty, SchemaScalar, SchemaType, collect_type_references}; /// Sorts the schemas by name, narrows the discriminator properties, and /// fails on a `$ref` that resolves to no declared schema. Mutates `model` @@ -25,88 +29,124 @@ pub(super) fn finalize(model: &mut ApiModel, reporter: &Reporter) -> Result<(), /// Fails with `missing-discriminator-property` when a member does not /// declare the property, and `discriminator-property-must-be-string` when /// it declares it with a non-string type. +/// The literal each discriminated member narrows its property to, keyed by +/// member schema name then property name. +type Narrowings = BTreeMap, BTreeMap, Box>>; + fn narrow_discriminator_properties( symbols: &mut [ModelSymbol], reporter: &Reporter, ) -> Result<(), Diagnostic> { - let mut narrowings: BTreeMap, BTreeMap, Box>> = BTreeMap::new(); - for symbol in symbols.iter() { - if let SchemaType::Union { - members, - discriminator: Some(discriminator), - .. - } = &symbol.body - { - for member in members { - if let SchemaType::Ref(schema_name) = member { - // A `mapping` entry for this member supplies the wire value; - // without one it is the lowercased schema name. - let literal_value: Box = discriminator - .mapping - .iter() - .find(|(_, target)| target.as_ref() == schema_name.as_ref()) - .map_or_else( - || schema_name.to_ascii_lowercase().into_boxed_str(), - |(wire_value, _)| wire_value.clone(), - ); - narrowings - .entry(schema_name.clone()) - .or_default() - .insert(discriminator.property_name.clone(), literal_value); - } - } - } - } - + let narrowings = collect_narrowings(symbols); if narrowings.is_empty() { return Ok(()); } + validate_narrowings(symbols, &narrowings, reporter)?; + apply_narrowings(symbols, &narrowings); + Ok(()) +} + +fn collect_narrowings(symbols: &[ModelSymbol]) -> Narrowings { + symbols + .iter() + .filter_map(|symbol| match &symbol.body { + SchemaType::Union { + members, + discriminator: Some(discriminator), + .. + } => Some((members, discriminator)), + _ => None, + }) + .flat_map(|(members, discriminator)| { + members.iter().filter_map(move |member| match member { + SchemaType::Ref(schema_name) => Some((schema_name, discriminator)), + _ => None, + }) + }) + .fold( + Narrowings::new(), + |mut narrowings, (schema_name, discriminator)| { + narrowings.entry(schema_name.clone()).or_default().insert( + discriminator.property_name.clone(), + wire_value(discriminator, schema_name), + ); + narrowings + }, + ) +} + +/// The wire value a `mapping` entry gives this member, or its lowercased +/// schema name. +fn wire_value(discriminator: &Discriminator, schema_name: &str) -> Box { + discriminator + .mapping + .iter() + .find(|(_, target)| target.as_ref() == schema_name) + .map_or_else( + || schema_name.to_ascii_lowercase().into_boxed_str(), + |(value, _)| value.clone(), + ) +} - // Validate every member before mutating any: a rejected spec leaves - // the model untouched. +/// Runs before any mutation, so a rejected spec leaves the model untouched. +fn validate_narrowings( + symbols: &[ModelSymbol], + narrowings: &Narrowings, + reporter: &Reporter, +) -> Result<(), Diagnostic> { let by_name: BTreeMap<&str, &SchemaType> = symbols .iter() .map(|symbol| (symbol.name.as_ref(), &symbol.body)) .collect(); - for symbol in symbols.iter() { - let Some(props) = narrowings.get(&symbol.name) else { - continue; - }; - for property_name in props.keys() { - let Some(property) = find_property(&symbol.body, property_name, &by_name) else { - bail_policy!( - reporter, - "missing-discriminator-property", - "Failed to validate spec: oneOf member '{}' does not declare the discriminator property '{}'. Add the property to the member schema (typically as `type: string`) or remove the discriminator.", - symbol.name, - property_name - ); - }; - if !is_string_discriminator_shape(&property.ty) { - bail_policy!( - reporter, - "discriminator-property-must-be-string", - "Failed to validate spec: oneOf member '{}' declares discriminator property '{}' with a non-string type. Discriminator properties must be `type: string` (optionally with an enum); change the property type or remove the discriminator.", - symbol.name, - property_name - ); - } - } + symbols + .iter() + .filter_map(|symbol| narrowings.get(&symbol.name).map(|names| (symbol, names))) + .flat_map(|(symbol, names)| names.keys().map(move |name| (symbol, name))) + .try_for_each(|(symbol, property_name)| { + validate_discriminator_property(symbol, property_name, &by_name, reporter) + }) +} + +fn validate_discriminator_property( + symbol: &ModelSymbol, + property_name: &str, + by_name: &BTreeMap<&str, &SchemaType>, + reporter: &Reporter, +) -> Result<(), Diagnostic> { + let Some(property) = find_property(&symbol.body, property_name, by_name) else { + bail_policy!( + reporter, + "missing-discriminator-property", + "Failed to validate spec: oneOf member '{}' does not declare the discriminator property '{}'. Add the property to the member schema (typically as `type: string`) or remove the discriminator.", + symbol.name, + property_name + ); + }; + + if !is_string_discriminator_shape(&property.schema) { + bail_policy!( + reporter, + "discriminator-property-must-be-string", + "Failed to validate spec: oneOf member '{}' declares discriminator property '{}' with a non-string type. Discriminator properties must be `type: string` (optionally with an enum); change the property type or remove the discriminator.", + symbol.name, + property_name + ); } + Ok(()) +} - // Only inline objects are mutated; a `Ref` member is narrowed when - // this loop reaches the symbol it names. - for symbol in symbols.iter_mut() { - let Some(props) = narrowings.get(&symbol.name) else { - continue; +/// Only inline objects are mutated; a `Ref` member is narrowed when this +/// reaches the symbol it names. +fn apply_narrowings(symbols: &mut [ModelSymbol], narrowings: &Narrowings) { + symbols.iter_mut().for_each(|symbol| { + let Some(names) = narrowings.get(&symbol.name) else { + return; }; - for (property_name, literal_value) in props { + names.iter().for_each(|(property_name, literal_value)| { narrow_property_in_body(&mut symbol.body, property_name, literal_value.as_ref()); - } - } - - Ok(()) + }); + }); } /// Every schema-typed position an operation declares. @@ -116,14 +156,14 @@ fn operation_types(operation: &OperationDef) -> impl Iterator Some(ty), + BodyContent::Json(schema) => Some(schema), BodyContent::Multipart { .. } | BodyContent::UrlEncoded { .. } => None, }); let response = operation .response .as_ref() .and_then(|response| match response { - ResponseContent::Json(Some(ty)) => Some(ty), + ResponseContent::Json(Some(schema)) => Some(schema), ResponseContent::Json(None) | ResponseContent::Blob | ResponseContent::Text @@ -134,8 +174,14 @@ fn operation_types(operation: &OperationDef) -> impl Iterator( /// True for the property types a discriminator may declare: bare `string` /// or a string-literal enum. -const fn is_string_discriminator_shape(ty: &SchemaType) -> bool { +const fn is_string_discriminator_shape(schema: &SchemaType) -> bool { matches!( - ty, + schema, SchemaType::Scalar(SchemaScalar::String) | SchemaType::StringLiterals { .. } ) } @@ -181,7 +227,7 @@ fn narrow_property_in_body(body: &mut SchemaType, name: &str, literal_value: &st .iter_mut() .find(|property| property.name.as_ref() == name) { - property.ty = SchemaType::StringLiterals { + property.schema = SchemaType::StringLiterals { values: vec![literal_value.to_owned()], }; return true; @@ -207,8 +253,8 @@ fn validate_references(document: &ApiModel, reporter: &Reporter) -> Result<(), D .iter() .map(|symbol| &symbol.body) .chain(document.operations.iter().flat_map(operation_types)) - .fold(BTreeSet::new(), |mut refs, ty| { - collect_type_references(ty, &mut refs); + .fold(BTreeSet::new(), |mut refs, schema| { + collect_type_references(schema, &mut refs); refs }); @@ -226,16 +272,16 @@ fn validate_references(document: &ApiModel, reporter: &Reporter) -> Result<(), D #[cfg(test)] mod tests { use super::narrow_discriminator_properties; - use crate::ir::canonical::ModelSymbol; - use crate::ir::schema::{Discriminator, SchemaProperty, SchemaScalar, SchemaType}; + use crate::api_model::canonical::ModelSymbol; + use crate::api_model::schema::{Discriminator, SchemaProperty, SchemaScalar, SchemaType}; use crate::test_support::test_reporter; use std::collections::BTreeMap; - fn property(name: &str, ty: SchemaType) -> SchemaProperty { + fn property(name: &str, schema: SchemaType) -> SchemaProperty { SchemaProperty { name: name.into(), required: true, - ty, + schema, description: None, deprecated: false, } @@ -299,7 +345,7 @@ mod tests { SchemaType::InlineObject { properties } => properties .iter() .find(|p| p.name.as_ref() == "kind") - .map(|p| &p.ty), + .map(|p| &p.schema), _ => None, }) .expect("kind property present on inline part of Intersection"); diff --git a/src/ir/normalize/tests.rs b/src/api_model/normalize/tests.rs similarity index 95% rename from src/ir/normalize/tests.rs rename to src/api_model/normalize/tests.rs index 40e5462..a93ef41 100644 --- a/src/ir/normalize/tests.rs +++ b/src/api_model/normalize/tests.rs @@ -3,18 +3,18 @@ //! shape. The final semantic step (discriminator narrowing + `$ref` //! validation) is exercised through `normalize_document` since it runs //! inside `normalize_api_model`. Two-stage tests that pair normalize -//! with `render_type_reference` live in `crate::ir::tests` instead. +//! with `render_type_reference` live in `crate::api_model::tests` instead. use serde_json::Value; -use crate::error::DiagnosticCode; -use crate::ir::canonical::{ +use crate::api_model::canonical::{ ApiInfo, ApiModel, BodyContent, HeaderDef, HttpMethod, ModelSymbol, OperationDef, RequestBodyDef, RequestDef, RequestInputDef, RequestInputSource, ResponseContent, }; -use crate::ir::normalize::normalize_document; -use crate::ir::normalize::semantic; -use crate::ir::schema::SchemaType; +use crate::api_model::normalize::normalize_document; +use crate::api_model::normalize::semantic; +use crate::api_model::schema::SchemaType; +use crate::error::DiagnosticCode; use crate::test_support::{reporter_for, test_reporter}; fn parse_fixture(source: &str) -> Value { @@ -54,7 +54,7 @@ fn normalize_lowers_oneof_anyof_and_collapses_single_entry_composition() { .iter() .find(|property| property.name.as_ref() == "contact") .expect("contact property exists"); - assert!(matches!(contact.ty, SchemaType::Union { .. })); + assert!(matches!(contact.schema, SchemaType::Union { .. })); } other => panic!("expected object schema, got {other:?}"), } @@ -125,7 +125,7 @@ fn normalize_supports_inline_object_model_shapes_outside_allof() { .iter() .find(|property| property.name.as_ref() == "details") .expect("details property exists"); - match &details.ty { + match &details.schema { SchemaType::InlineObject { properties } => { assert!( properties @@ -136,7 +136,7 @@ fn normalize_supports_inline_object_model_shapes_outside_allof() { .iter() .find(|property| property.name.as_ref() == "address") .expect("address property exists"); - assert!(matches!(address.ty, SchemaType::InlineObject { .. })); + assert!(matches!(address.schema, SchemaType::InlineObject { .. })); } other => panic!("expected inline object property, got {other:?}"), } @@ -145,7 +145,7 @@ fn normalize_supports_inline_object_model_shapes_outside_allof() { .iter() .find(|property| property.name.as_ref() == "labelsByLocale") .expect("labelsByLocale property exists"); - match &labels_by_locale.ty { + match &labels_by_locale.schema { SchemaType::Map(values) => { assert!(matches!(values.as_ref(), SchemaType::InlineObject { .. })); } @@ -156,7 +156,7 @@ fn normalize_supports_inline_object_model_shapes_outside_allof() { .iter() .find(|property| property.name.as_ref() == "visits") .expect("visits property exists"); - match &visits.ty { + match &visits.schema { SchemaType::Array(items) => { assert!(matches!(items.as_ref(), SchemaType::InlineObject { .. })); } @@ -182,7 +182,7 @@ fn normalize_supports_typed_additional_properties_for_nested_and_named_object_ma .expect("scope property exists"); assert!(matches!( - &scope.ty, + &scope.schema, SchemaType::StringLiterals { values } if values == &vec![ "available".to_string(), @@ -195,7 +195,7 @@ fn normalize_supports_typed_additional_properties_for_nested_and_named_object_ma .iter() .find(|property| property.name.as_ref() == "petsByBreed") .expect("petsByBreed property exists"); - match &pets_by_breed.ty { + match &pets_by_breed.schema { SchemaType::Map(values) => match values.as_ref() { SchemaType::Array(items) => { assert!(matches!(items.as_ref(), SchemaType::Ref(name) if name.as_ref() == "Pet")); @@ -387,19 +387,19 @@ fn semantic_finalize_lowers_operations_with_inputs_body_and_response() { name: "id".into(), source: RequestInputSource::Path, required: true, - ty: SchemaType::Scalar(crate::ir::schema::SchemaScalar::String), + schema: SchemaType::Scalar(crate::api_model::schema::SchemaScalar::String), }, RequestInputDef { name: "includeInactive".into(), source: RequestInputSource::Query, required: false, - ty: SchemaType::Scalar(crate::ir::schema::SchemaScalar::Boolean), + schema: SchemaType::Scalar(crate::api_model::schema::SchemaScalar::Boolean), }, ], headers: vec![HeaderDef { name: "xTrace".into(), required: false, - ty: SchemaType::Scalar(crate::ir::schema::SchemaScalar::String), + schema: SchemaType::Scalar(crate::api_model::schema::SchemaScalar::String), }], body: Some(RequestBodyDef { required: true, diff --git a/src/ir/normalize/walk.rs b/src/api_model/normalize/walk.rs similarity index 100% rename from src/ir/normalize/walk.rs rename to src/api_model/normalize/walk.rs diff --git a/src/ir/schema.rs b/src/api_model/schema.rs similarity index 86% rename from src/ir/schema.rs rename to src/api_model/schema.rs index 238d0c6..dd11ad8 100644 --- a/src/ir/schema.rs +++ b/src/api_model/schema.rs @@ -4,7 +4,7 @@ use std::collections::{BTreeMap, BTreeSet}; pub(crate) struct SchemaProperty { pub(crate) name: Box, pub(crate) required: bool, - pub(crate) ty: SchemaType, + pub(crate) schema: SchemaType, /// Emitted as JSDoc above the declaration in named interfaces only. pub(crate) description: Option, /// Emitted as `@deprecated` in named interfaces only. @@ -54,12 +54,15 @@ pub(crate) enum SchemaScalar { Boolean, } -pub(crate) fn collect_type_references<'ir>(ty: &'ir SchemaType, imports: &mut BTreeSet<&'ir str>) { - walk_refs(ty, imports); +pub(crate) fn collect_type_references<'model>( + schema: &'model SchemaType, + imports: &mut BTreeSet<&'model str>, +) { + walk_refs(schema, imports); } -fn walk_refs<'ir>(ty: &'ir SchemaType, refs: &mut BTreeSet<&'ir str>) { - match ty { +fn walk_refs<'model>(schema: &'model SchemaType, refs: &mut BTreeSet<&'model str>) { + match schema { SchemaType::Any | SchemaType::Scalar(_) | SchemaType::StringLiterals { .. } => {} SchemaType::Array(items) | SchemaType::Map(items) | SchemaType::Nullable(items) => { walk_refs(items, refs); @@ -73,7 +76,7 @@ fn walk_refs<'ir>(ty: &'ir SchemaType, refs: &mut BTreeSet<&'ir str>) { SchemaType::InlineObject { properties } => { properties .iter() - .for_each(|property| walk_refs(&property.ty, refs)); + .for_each(|property| walk_refs(&property.schema, refs)); } } } diff --git a/src/ir/tests.rs b/src/api_model/tests.rs similarity index 84% rename from src/ir/tests.rs rename to src/api_model/tests.rs index 0ac82c6..bbd4b13 100644 --- a/src/ir/tests.rs +++ b/src/api_model/tests.rs @@ -8,10 +8,10 @@ use serde_json::Value; +use crate::api_model::canonical::ModelSymbol; +use crate::api_model::normalize::normalize_document; +use crate::api_model::schema::SchemaType; use crate::emit::ts::types::render_to_string; -use crate::ir::canonical::ModelSymbol; -use crate::ir::normalize::normalize_document; -use crate::ir::schema::SchemaType; use crate::test_support::reporter_for; fn parse_fixture(source: &str) -> Value { @@ -38,18 +38,20 @@ fn normalize_supports_empty_schema_any_type_and_empty_object_shapes() { assert!(!matches!(&any_value.body, SchemaType::Ref(_))); assert_eq!(render_to_string(&any_value.body), "unknown"); - for schema_name in ["EmptyObject", "EmptyObjectWithProperties"] { - let empty_object = find_symbol(&ir.schemas, schema_name); - match &empty_object.body { - SchemaType::InlineObject { properties } => { - assert!( - properties.is_empty(), - "{schema_name} should have no properties" - ); + ["EmptyObject", "EmptyObjectWithProperties"] + .iter() + .for_each(|schema_name| { + let empty_object = find_symbol(&ir.schemas, schema_name); + match &empty_object.body { + SchemaType::InlineObject { properties } => { + assert!( + properties.is_empty(), + "{schema_name} should have no properties" + ); + } + other => panic!("expected object schema for {schema_name}, got {other:?}"), } - other => panic!("expected object schema for {schema_name}, got {other:?}"), - } - } + }); let shape_container = find_symbol(&ir.schemas, "ShapeContainer"); let properties = match &shape_container.body { @@ -61,7 +63,7 @@ fn normalize_supports_empty_schema_any_type_and_empty_object_shapes() { .iter() .find(|property| property.name.as_ref() == "anything") .expect("anything property exists"); - assert_eq!(render_to_string(&anything.ty), "unknown"); + assert_eq!(render_to_string(&anything.schema), "unknown"); let empty_inline = properties .iter() @@ -80,24 +82,27 @@ fn normalize_supports_empty_schema_any_type_and_empty_object_shapes() { .find(|property| property.name.as_ref() == "emptyMap") .expect("emptyMap property exists"); - for property in [empty_inline, empty_inline_with_properties] { - match &property.ty { + [empty_inline, empty_inline_with_properties] + .iter() + .for_each(|property| match &property.schema { SchemaType::InlineObject { properties } => assert!(properties.is_empty()), other => panic!("expected empty inline object, got {other:?}"), - } - } + }); - match &empty_array.ty { + match &empty_array.schema { SchemaType::Array(items) => { - assert_eq!(render_to_string(&empty_array.ty), "unknown[]"); + assert_eq!(render_to_string(&empty_array.schema), "unknown[]"); assert!(!matches!(items.as_ref(), SchemaType::Ref(_))); } other => panic!("expected array, got {other:?}"), - } + }; - match &empty_map.ty { + match &empty_map.schema { SchemaType::Map(values) => { - assert_eq!(render_to_string(&empty_map.ty), "Record"); + assert_eq!( + render_to_string(&empty_map.schema), + "Record" + ); assert!(!matches!(values.as_ref(), SchemaType::Ref(_))); } other => panic!("expected map, got {other:?}"), @@ -151,7 +156,7 @@ fn ir_renders_union_and_intersection_type_fragments_from_normalized_composition( assert!(rendered.contains("nickname?: string | null;")); } other => panic!("expected IR intersection, got {other:?}"), - } + }; let additional_properties_document = parse_fixture(include_str!( "../../test/fixtures/additional-properties.openapi.yaml" @@ -167,7 +172,7 @@ fn ir_renders_union_and_intersection_type_fragments_from_normalized_composition( SchemaType::InlineObject { properties } if symbol.name.as_ref() == "PetCatalog" => properties .iter() .find(|property| property.name.as_ref() == "petsByBreed") - .map(|property| &property.ty), + .map(|property| &property.schema), _ => None, }) .expect("PetCatalog.petsByBreed exists in IR"); @@ -183,7 +188,7 @@ fn ir_renders_union_and_intersection_type_fragments_from_normalized_composition( SchemaType::InlineObject { properties } if symbol.name.as_ref() == "PetCatalog" => properties .iter() .find(|property| property.name.as_ref() == "scope") - .map(|property| &property.ty), + .map(|property| &property.schema), _ => None, }) .expect("PetCatalog.scope exists in IR"); diff --git a/src/emit/angular/imports.rs b/src/emit/angular/imports.rs index a09d968..42e4de4 100644 --- a/src/emit/angular/imports.rs +++ b/src/emit/angular/imports.rs @@ -1,8 +1,8 @@ use std::collections::{BTreeMap, BTreeSet}; +use crate::api_model::canonical::ResponseContent; +use crate::api_model::schema::{SchemaType, collect_type_references}; use crate::emit::ts::{Writer, type_import_block}; -use crate::ir::canonical::ResponseContent; -use crate::ir::schema::{SchemaType, collect_type_references}; use crate::plan::artifact_plan::{PlannedOperation, PlannedRequestBody, RequestFieldKind}; /// Path from a generated service file to the model artifact, one @@ -34,8 +34,8 @@ pub(super) fn render_service_imports( operations .iter() .flat_map(operation_types) - .fold(BTreeSet::new(), |mut imports, ty| { - collect_type_references(ty, &mut imports); + .fold(BTreeSet::new(), |mut imports, schema| { + collect_type_references(schema, &mut imports); imports }); @@ -50,9 +50,9 @@ fn operation_types<'a>( operation: &'a PlannedOperation<'a>, ) -> impl Iterator { let body: Box> = match &operation.request.body { - Some(PlannedRequestBody::Nested { ty, .. }) => Box::new(std::iter::once(*ty)), + Some(PlannedRequestBody::Nested { schema, .. }) => Box::new(std::iter::once(*schema)), Some(PlannedRequestBody::FlatJson { properties, .. }) => { - Box::new(properties.iter().map(|property| property.ty)) + Box::new(properties.iter().map(|property| property.schema)) } Some(PlannedRequestBody::Multipart { .. } | PlannedRequestBody::UrlEncoded { .. }) | None => { Box::new(std::iter::empty()) @@ -62,7 +62,7 @@ fn operation_types<'a>( .response .as_ref() .and_then(|response| match response { - ResponseContent::Json(Some(ty)) => Some(ty), + ResponseContent::Json(Some(schema)) => Some(schema), ResponseContent::Json(None) | ResponseContent::Blob | ResponseContent::Text @@ -73,8 +73,8 @@ fn operation_types<'a>( .request .fields .iter() - .map(|field| field.ty) - .chain(operation.request.headers.iter().map(|header| header.ty)) + .map(|field| field.schema) + .chain(operation.request.headers.iter().map(|header| header.schema)) .chain(body) .chain(response) .chain(operation.errors.iter().map(|error| &error.body)) @@ -83,8 +83,8 @@ fn operation_types<'a>( #[cfg(test)] mod tests { use super::*; - use crate::ir::canonical::HttpMethod; - use crate::ir::schema::{SchemaScalar, SchemaType}; + use crate::api_model::canonical::HttpMethod; + use crate::api_model::schema::{SchemaScalar, SchemaType}; use crate::plan::artifact_plan::{ PlannedHeader, PlannedRequestContract, PlannedRequestField, RequestFieldKind, }; @@ -124,12 +124,12 @@ mod tests { #[test] fn helper_import_includes_http_params_when_any_operation_has_query_fields() { - let limit_ty = SchemaType::Scalar(SchemaScalar::Number); + let limit_schema = SchemaType::Scalar(SchemaScalar::Number); let request = PlannedRequestContract { fields: vec![PlannedRequestField { name: "limit".into(), optional: true, - ty: &limit_ty, + schema: &limit_schema, kind: RequestFieldKind::Query, }], headers: vec![], @@ -169,13 +169,13 @@ mod tests { #[test] fn model_refs_from_headers_are_imported() { - let key_ty = SchemaType::Ref("IdempotencyKey".into()); + let key_schema = SchemaType::Ref("IdempotencyKey".into()); let request = PlannedRequestContract { fields: vec![], headers: vec![PlannedHeader { name: "X-Idempotency-Key".into(), optional: false, - ty: &key_ty, + schema: &key_schema, }], body: None, }; diff --git a/src/emit/angular/mod.rs b/src/emit/angular/mod.rs index 75fecdc..c74410e 100644 --- a/src/emit/angular/mod.rs +++ b/src/emit/angular/mod.rs @@ -16,9 +16,9 @@ pub(crate) const REST_VALIDATE_TEMPLATE: &str = #[cfg(test)] mod tests { use super::*; - use crate::ident::TypeName; - use crate::ir::canonical::HttpMethod; - use crate::ir::schema::{SchemaScalar, SchemaType}; + use crate::api_model::canonical::HttpMethod; + use crate::api_model::schema::{SchemaScalar, SchemaType}; + use crate::identifier::TypeName; use crate::plan::artifact_plan::{ PlannedRequestContract, PlannedRequestField, RequestFieldKind, ServicePlan, }; @@ -62,7 +62,7 @@ mod tests { #[test] fn emit_service_includes_request_interface_when_operation_has_input_fields() { - let ty = SchemaType::Scalar(SchemaScalar::String); + let schema = SchemaType::Scalar(SchemaScalar::String); let plan = ServicePlan { group_name: "pet".into(), class_name: TypeName::new("PetRest".to_string()), @@ -75,7 +75,7 @@ mod tests { fields: vec![PlannedRequestField { name: "id".into(), optional: false, - ty: &ty, + schema: &schema, kind: RequestFieldKind::Path, }], headers: vec![], diff --git a/src/emit/angular/request.rs b/src/emit/angular/request.rs index 903d611..80f6147 100644 --- a/src/emit/angular/request.rs +++ b/src/emit/angular/request.rs @@ -1,8 +1,9 @@ +use crate::api_model::canonical::BodyFieldType; use crate::emit::ts::{ - Doc, Member, Position, Render, Writer, interface_block, property_declaration, w, wln, + Doc, Member, Position, Render, Writer, interface_block, member_declaration, w, wln, + write_separated, }; -use crate::ident::TypeName; -use crate::ir::canonical::BodyFieldType; +use crate::identifier::TypeName; use crate::plan::artifact_plan::{ PlannedFormField, PlannedHeader, PlannedOperation, PlannedRequestBody, PlannedRequestContract, PlannedRequestField, RequestFieldKind, @@ -91,7 +92,7 @@ fn field_member<'a>(field: &'a PlannedRequestField<'a>) -> Member<'a> { Member { name: field.name.as_ref(), optional: field.optional, - ty: field.ty, + type_expr: field.schema, doc: Doc::default(), } } @@ -100,7 +101,7 @@ fn form_member<'a>(field: &'a PlannedFormField<'a>) -> Member<'a> { Member { name: field.name.as_str(), optional: field.optional, - ty: field.ty, + type_expr: field.field_type, doc: Doc::default(), } } @@ -108,10 +109,10 @@ fn form_member<'a>(field: &'a PlannedFormField<'a>) -> Member<'a> { /// The members a body contributes; at most one arm is non-empty. fn body_members<'a>(body: Option<&'a PlannedRequestBody<'a>>) -> impl Iterator> { let nested = body.and_then(|body| match body { - PlannedRequestBody::Nested { ty, optional } => Some(Member { + PlannedRequestBody::Nested { schema, optional } => Some(Member { name: "body", optional: *optional, - ty: *ty, + type_expr: *schema, doc: Doc::default(), }), _ => None, @@ -163,7 +164,7 @@ impl<'a> HeaderObject<'a> { (!self.0.is_empty()).then(|| Member { name: "headers", optional: self.0.iter().all(|header| header.optional), - ty: self, + type_expr: self, doc: Doc::default(), }) } @@ -171,14 +172,11 @@ impl<'a> HeaderObject<'a> { impl Render for HeaderObject<'_> { fn render(&self, out: &mut Writer, _at: Position) { - out.push("{\n"); - out.indent(); - self.0.iter().for_each(|header| { - property_declaration(out, header.name.as_ref(), header.optional, &header.ty); - out.push(";\n"); + out.inline_block(|out| { + self.0.iter().for_each(|header| { + member_declaration(out, header.name.as_ref(), header.optional, &header.schema); + }); }); - out.dedent(); - out.push("}"); } } @@ -197,12 +195,7 @@ fn write_params_line(buffer: &mut Writer, operation: &PlannedOperation<'_>) { } buffer.push("params: httpParams({ "); - for (index, name) in query.enumerate() { - if index > 0 { - buffer.push(", "); - } - buffer.push(name); - } + write_separated(buffer, query, ", ", Writer::push); buffer.push(" }),\n"); } @@ -216,12 +209,9 @@ fn write_body_line(buffer: &mut Writer, operation: &PlannedOperation<'_>) { PlannedRequestBody::Nested { .. } => buffer.push("body: body,\n"), PlannedRequestBody::FlatJson { properties, .. } => { buffer.push("body: { "); - for (index, property) in properties.iter().enumerate() { - if index > 0 { - buffer.push(", "); - } - buffer.push(property.name.as_ref()); - } + write_separated(buffer, properties, ", ", |out, property| { + out.push(property.name.as_ref()); + }); buffer.push(" },\n"); } PlannedRequestBody::Multipart { fields } => { @@ -245,12 +235,12 @@ fn write_form_body(buffer: &mut Writer, fields: &[PlannedFormField<'_>], kind: F wln!(buffer, "body: ((): {ts_type} => {{"); buffer.indent(); wln!(buffer, "const {variable} = {constructor};"); - for field in fields { + fields.iter().for_each(|field| { let name = field.name.as_str(); if field.optional { w!(buffer, "if ({name} !== undefined) "); } - match field.ty { + match field.field_type { BodyFieldType::Scalar(_) => { wln!(buffer, "{variable}.append('{name}', String({name}));"); } @@ -268,7 +258,7 @@ fn write_form_body(buffer: &mut Writer, fields: &[PlannedFormField<'_>], kind: F ); } } - } + }); wln!(buffer, "return {variable};"); buffer.dedent(); buffer.push("})(),\n"); @@ -305,12 +295,13 @@ mod tests { fn type_name(name: &str) -> TypeName { TypeName::new(name.to_string()) } - use crate::ir::canonical::{BodyFieldType, ErrorResponse, HttpMethod}; - use crate::ir::schema::{SchemaProperty, SchemaScalar, SchemaType}; + use crate::api_model::canonical::{BodyFieldType, ErrorResponse, HttpMethod}; + use crate::api_model::schema::{SchemaProperty, SchemaScalar, SchemaType}; use crate::plan::artifact_plan::{PlannedHeader, PlannedRequestContract}; use crate::test_support::{ body_field, flat_json_body, nested_body, op_with, op_with_errors, op_with_multipart_fields, - op_with_multipart_fields_full, op_with_urlencoded_fields, path_field, query_field, string_ty, + op_with_multipart_fields_full, op_with_urlencoded_fields, path_field, query_field, + string_schema, }; fn render_errors(error_name: &str, errors: &[ErrorResponse]) -> String { @@ -345,7 +336,7 @@ mod tests { properties: vec![SchemaProperty { name: "code".into(), required: true, - ty: SchemaType::Scalar(SchemaScalar::String), + schema: SchemaType::Scalar(SchemaScalar::String), description: None, deprecated: false, }], @@ -358,13 +349,13 @@ mod tests { #[test] fn requestful_builder_renders_get_with_path_param_only() { - let ty = string_ty(); + let schema = string_schema(); let op = op_with( "getPet", HttpMethod::Get, "/pets/{petId}", PlannedRequestContract { - fields: vec![path_field("petId", &ty)], + fields: vec![path_field("petId", &schema)], headers: vec![], body: None, }, @@ -387,7 +378,7 @@ mod tests { #[test] fn requestful_builder_renders_post_with_ref_body_and_headers() { - let str_ty = string_ty(); + let str_schema = string_schema(); let body_ref = SchemaType::Ref("CreatePetPayload".into()); let op = op_with( "createPet", @@ -398,7 +389,7 @@ mod tests { headers: vec![PlannedHeader { name: "X-Trace-Id".into(), optional: false, - ty: &str_ty, + schema: &str_schema, }], body: Some(nested_body(&body_ref, false)), }, @@ -422,7 +413,7 @@ mod tests { fn requestful_builder_assembles_object_literal_for_flat_json_body() { // Inline JSON object bodies hoist their properties to top-level // fields, re-assembled into an object literal at the `body:` slot. - let str_ty = string_ty(); + let str_schema = string_schema(); let bool_ty = SchemaType::Scalar(SchemaScalar::Boolean); let op = op_with( "decide", @@ -433,7 +424,7 @@ mod tests { headers: vec![], body: Some(flat_json_body( vec![ - body_field("csvImportId", false, &str_ty), + body_field("csvImportId", false, &str_schema), body_field("doImport", false, &bool_ty), ], true, @@ -452,15 +443,15 @@ mod tests { #[test] fn requestful_builder_renders_query_params_via_http_params() { - let str_ty = string_ty(); + let str_schema = string_schema(); let op = op_with( "listPets", HttpMethod::Get, "/pets", PlannedRequestContract { fields: vec![ - query_field("limit", true, &str_ty), - query_field("offset", true, &str_ty), + query_field("limit", true, &str_schema), + query_field("offset", true, &str_schema), ], headers: vec![], body: None, @@ -530,7 +521,7 @@ mod tests { #[test] fn request_interface_renders_ref_body_as_nested_alongside_headers() { - let str_ty = string_ty(); + let str_schema = string_schema(); let payload_ref = SchemaType::Ref("CreatePetPayload".into()); let op = op_with( "createPet", @@ -542,12 +533,12 @@ mod tests { PlannedHeader { name: "X-Trace-Id".into(), optional: false, - ty: &str_ty, + schema: &str_schema, }, PlannedHeader { name: "X-Idempotency-Key".into(), optional: true, - ty: &str_ty, + schema: &str_schema, }, ], body: Some(nested_body(&payload_ref, false)), @@ -571,7 +562,7 @@ mod tests { #[test] fn request_interface_hoists_flat_json_body_properties_to_top_level() { - let str_ty = string_ty(); + let str_schema = string_schema(); let bool_ty = SchemaType::Scalar(SchemaScalar::Boolean); let op = op_with( "decide", @@ -582,7 +573,7 @@ mod tests { headers: vec![], body: Some(flat_json_body( vec![ - body_field("csvImportId", false, &str_ty), + body_field("csvImportId", false, &str_schema), body_field("doImport", false, &bool_ty), ], true, @@ -624,17 +615,17 @@ mod tests { #[test] fn request_interface_marks_headers_optional_when_all_headers_optional() { - let str_ty = string_ty(); + let str_schema = string_schema(); let op = op_with( "getPet", HttpMethod::Get, "/pets/{id}", PlannedRequestContract { - fields: vec![path_field("id", &str_ty)], + fields: vec![path_field("id", &str_schema)], headers: vec![PlannedHeader { name: "X-Trace-Id".into(), optional: true, - ty: &str_ty, + schema: &str_schema, }], body: None, }, @@ -651,13 +642,13 @@ mod tests { #[test] fn request_interface_omits_headers_block_when_absent() { - let str_ty = string_ty(); + let str_schema = string_schema(); let op = op_with( "getPet", HttpMethod::Get, "/pets/{id}", PlannedRequestContract { - fields: vec![path_field("id", &str_ty)], + fields: vec![path_field("id", &str_schema)], headers: vec![], body: None, }, @@ -674,12 +665,12 @@ mod tests { #[test] fn request_interface_renders_binary_as_blob_or_file_union() { - let str_ty = string_ty(); + let str_schema = string_schema(); let binary = BodyFieldType::Binary; let op = op_with_multipart_fields_full( - vec![path_field("petId", &str_ty)], // path - vec![], // headers - vec![("avatar", false, &binary)], // form fields + vec![path_field("petId", &str_schema)], // path + vec![], // headers + vec![("avatar", false, &binary)], // form fields ); let mut buf = Writer::with_capacity(512); render_request_interface(&mut buf, &op, &type_name("OpParams")); diff --git a/src/emit/angular/service.rs b/src/emit/angular/service.rs index 3cd6d7c..18a50ba 100644 --- a/src/emit/angular/service.rs +++ b/src/emit/angular/service.rs @@ -1,6 +1,6 @@ +use crate::api_model::canonical::ResponseContent; use crate::emit::ts::{Doc, Position, Render, Writer, jsdoc, w}; -use crate::ident::TypeName; -use crate::ir::canonical::ResponseContent; +use crate::identifier::TypeName; use crate::plan::artifact_plan::{PlannedOperation, ServicePlan}; use super::imports::render_service_imports; @@ -22,10 +22,10 @@ pub(crate) fn emit_service(service_plan: &ServicePlan<'_>) -> String { buffer.line("})"); buffer.open_block(&format!("export class {}", service_plan.class_name)); - for operation in &service_plan.operations { + service_plan.operations.iter().for_each(|operation| { buffer.blank_line(); render_operation_property(&mut buffer, operation); - } + }); buffer.close_block(""); @@ -113,8 +113,8 @@ fn write_response_call_site( fn write_response_type(buffer: &mut Writer, response: Option<&ResponseContent>) { match response { - Some(ResponseContent::Json(Some(ty))) => { - ty.render(buffer, Position::Standalone); + Some(ResponseContent::Json(Some(schema))) => { + schema.render(buffer, Position::Standalone); } Some(ResponseContent::Json(None)) | None => { buffer.push("void"); @@ -128,10 +128,10 @@ fn write_response_type(buffer: &mut Writer, response: Option<&ResponseContent>) #[cfg(test)] mod tests { use super::*; - use crate::ir::canonical::{HttpMethod, ResponseContent}; - use crate::ir::schema::{SchemaScalar, SchemaType}; + use crate::api_model::canonical::{HttpMethod, ResponseContent}; + use crate::api_model::schema::{SchemaScalar, SchemaType}; use crate::plan::artifact_plan::PlannedRequestContract; - use crate::test_support::{op_with, path_field, string_ty}; + use crate::test_support::{op_with, path_field, string_schema}; // The four tests below pin the helper expression emitted by // render_operation_property across every ResponseContent variant. @@ -165,9 +165,9 @@ mod tests { #[test] fn request_factory_call_uses_bare_helper_for_json_response() { - let str_ty = string_ty(); + let str_schema = string_schema(); let json = ResponseContent::Json(Some(SchemaType::Scalar(SchemaScalar::String))); - let op = op_with_response_and_path("listPets", &str_ty, &json); + let op = op_with_response_and_path("listPets", &str_schema, &json); let out = render_property(&op); assert!( @@ -188,8 +188,8 @@ mod tests { #[test] fn request_factory_call_uses_blob_variant_for_blob_response() { - let str_ty = string_ty(); - let op = op_with_response_and_path("download", &str_ty, &ResponseContent::Blob); + let str_schema = string_schema(); + let op = op_with_response_and_path("download", &str_schema, &ResponseContent::Blob); let out = render_property(&op); assert!( @@ -208,8 +208,8 @@ mod tests { #[test] fn request_factory_call_uses_text_variant_for_text_response() { - let str_ty = string_ty(); - let op = op_with_response_and_path("rawConfig", &str_ty, &ResponseContent::Text); + let str_schema = string_schema(); + let op = op_with_response_and_path("rawConfig", &str_schema, &ResponseContent::Text); let out = render_property(&op); assert!( @@ -228,8 +228,8 @@ mod tests { #[test] fn request_factory_call_uses_array_buffer_variant_for_array_buffer_response() { - let str_ty = string_ty(); - let op = op_with_response_and_path("fetch", &str_ty, &ResponseContent::ArrayBuffer); + let str_schema = string_schema(); + let op = op_with_response_and_path("fetch", &str_schema, &ResponseContent::ArrayBuffer); let out = render_property(&op); assert!( diff --git a/src/emit/model/emit_ts_models.rs b/src/emit/model/emit_ts_models.rs index ce578cf..79a4dcb 100644 --- a/src/emit/model/emit_ts_models.rs +++ b/src/emit/model/emit_ts_models.rs @@ -1,14 +1,14 @@ use std::collections::{BTreeMap, BTreeSet}; use crate::{ + api_model::{ + canonical::ModelSymbol, + schema::{SchemaProperty, SchemaType}, + }, emit::ts::{ Binding, Doc, Member, Position, Render, Statement, Writer, import_line, interface_block, jsdoc, string_union, type_alias, type_reexport_line, w, }, - ir::{ - canonical::ModelSymbol, - schema::{SchemaProperty, SchemaType}, - }, plan::artifact_plan::ResolvedMappedType, }; @@ -82,7 +82,7 @@ fn member(property: &SchemaProperty) -> Member<'_> { Member { name: property.name.as_ref(), optional: !property.required, - ty: &property.ty, + type_expr: &property.schema, doc: Doc::new(property.description.as_deref(), property.deprecated), } } @@ -92,7 +92,7 @@ fn native_binding<'a>(mapped: &'a ResolvedMappedType<'_>) -> &'a str { mapped .alias .as_deref() - .unwrap_or_else(|| mapped.ty.as_ref()) + .unwrap_or_else(|| mapped.type_name.as_ref()) } /// True when the binding a mapped type introduces already equals the @@ -114,7 +114,7 @@ fn emit_mapped_imports(mapped_types: &[ResolvedMappedType<'_>], out: &mut Writer grouped .entry(mapped.import.as_ref()) .or_default() - .insert((mapped.ty.as_ref(), mapped.alias.as_deref())); + .insert((mapped.type_name.as_ref(), mapped.alias.as_deref())); grouped }, ); @@ -124,18 +124,18 @@ fn emit_mapped_imports(mapped_types: &[ResolvedMappedType<'_>], out: &mut Writer grouped .entry(mapped.import.as_ref()) .or_default() - .insert((mapped.ty.as_ref(), mapped.schema)); + .insert((mapped.type_name.as_ref(), mapped.schema)); grouped }, ); - for (path, bindings) in &imports { + imports.iter().for_each(|(path, bindings)| { let bindings = bindings .iter() .map(|&(name, alias)| Binding { name, alias }); import_line(out, bindings, path, Statement::TypeImport); - } - for (path, entries) in &reexports { + }); + reexports.iter().for_each(|(path, entries)| { type_reexport_line(out, entries, path); - } + }); } diff --git a/src/emit/model/mod.rs b/src/emit/model/mod.rs index d55d981..3749651 100644 --- a/src/emit/model/mod.rs +++ b/src/emit/model/mod.rs @@ -4,7 +4,7 @@ pub(crate) mod emit_ts_models; mod tests { use super::emit_ts_models; use crate::{ - ir::{ + api_model::{ canonical::ModelSymbol, schema::{SchemaScalar, SchemaType}, }, @@ -89,7 +89,7 @@ mod tests { &[ResolvedMappedType { schema: "UserId", import: "./shared/user-id".into(), - ty: "ExternalUserId".into(), + type_name: "ExternalUserId".into(), alias: Some("Nickname".into()), }], ); @@ -118,7 +118,7 @@ mod tests { &[ResolvedMappedType { schema: "UserId", import: "./shared/user-id".into(), - ty: "ExternalUserId".into(), + type_name: "ExternalUserId".into(), alias: Some("UserId".into()), }], ); @@ -154,7 +154,7 @@ mod tests { &[ResolvedMappedType { schema: "UserId", import: "./shared/user-id".into(), - ty: "UserId".into(), + type_name: "UserId".into(), alias: None, }], ); diff --git a/src/emit/ts/decl.rs b/src/emit/ts/decl.rs index 9621641..6ba1f3c 100644 --- a/src/emit/ts/decl.rs +++ b/src/emit/ts/decl.rs @@ -1,7 +1,7 @@ //! Declaration-level emit: JSDoc, interfaces, type aliases, literal unions. use super::literal::quoted; -use super::types::{Render, property_declaration}; +use super::types::{Render, member_declaration}; use super::writer::{Writer, wln}; /// Width below which a top-level literal union stays on one line. Counts @@ -47,14 +47,14 @@ pub(crate) fn jsdoc(out: &mut Writer, doc: Doc<'_>) { } out.line("/**"); if let Some(text) = doc.prose() { - for line in text.lines() { + text.lines().for_each(|line| { let body = line.trim_end(); if body.is_empty() { out.line(" *"); } else { wln!(out, " * {}", body.replace("*/", "*\\/")); } - } + }); } if doc.deprecated { out.line(" * @deprecated"); @@ -66,7 +66,7 @@ pub(crate) fn jsdoc(out: &mut Writer, doc: Doc<'_>) { pub(crate) struct Member<'a> { pub(crate) name: &'a str, pub(crate) optional: bool, - pub(crate) ty: &'a dyn Render, + pub(crate) type_expr: &'a dyn Render, pub(crate) doc: Doc<'a>, } @@ -87,8 +87,7 @@ pub(crate) fn interface_block<'a>( out.open_block(&format!("{keyword}{name}")); members.into_iter().for_each(|member| { jsdoc(out, member.doc); - property_declaration(out, member.name, member.optional, &member.ty); - out.push(";\n"); + member_declaration(out, member.name, member.optional, &member.type_expr); }); out.close_block(""); } @@ -122,9 +121,9 @@ pub(crate) fn string_union(out: &mut Writer, name: &str, doc: Doc<'_>, values: & wln!(out, "export type {name} ="); out.indent(); let last = values.len().saturating_sub(1); - for (index, value) in values.iter().enumerate() { + values.iter().enumerate().for_each(|(index, value)| { let terminator = if index == last { ";" } else { "" }; wln!(out, "| {}{terminator}", quoted(value)); - } + }); out.dedent(); } diff --git a/src/emit/ts/imports.rs b/src/emit/ts/imports.rs index 6302732..a8590e3 100644 --- a/src/emit/ts/imports.rs +++ b/src/emit/ts/imports.rs @@ -2,7 +2,7 @@ use std::collections::{BTreeMap, BTreeSet}; -use super::writer::Writer; +use super::writer::{Writer, write_separated}; /// Width above which a statement wraps to one identifier per line. /// @@ -45,14 +45,14 @@ impl<'a> Binding<'a> { /// Emits one `import type { … } from '…';` per path, names in iteration /// order. pub(crate) fn type_import_block(out: &mut Writer, by_path: &BTreeMap<&str, BTreeSet<&str>>) { - for (path, names) in by_path { + by_path.iter().for_each(|(path, names)| { import_line( out, names.iter().copied().map(Binding::plain), path, Statement::TypeImport, ); - } + }); } /// Which statement keyword the bindings belong to. @@ -96,12 +96,7 @@ pub(crate) fn import_line<'a>( if statement.open().len() + names + separators + tail <= INLINE_WIDTH || bindings.len() <= 1 { out.push(statement.open()); - for (index, binding) in bindings.iter().enumerate() { - if index > 0 { - out.push(", "); - } - binding.write(out); - } + write_separated(out, &bindings, ", ", |out, binding| binding.write(out)); out.push(" } from '"); out.push(path); out.push("';\n"); @@ -110,10 +105,10 @@ pub(crate) fn import_line<'a>( out.push(statement.open_wrapped()); out.indent(); - for binding in &bindings { + bindings.iter().for_each(|binding| { binding.write(out); out.push(",\n"); - } + }); out.dedent(); out.push("} from '"); out.push(path); diff --git a/src/emit/ts/literal.rs b/src/emit/ts/literal.rs index 40fb828..a03ed64 100644 --- a/src/emit/ts/literal.rs +++ b/src/emit/ts/literal.rs @@ -2,7 +2,7 @@ use std::borrow::Cow; -use crate::ident::is_ident; +use crate::identifier::is_identifier; /// Appends `value` to `out` as a single-quoted TypeScript string literal, /// quotes included. @@ -42,7 +42,7 @@ pub(crate) fn quoted(value: &str) -> String { /// `IdentifierName` — so only names outside the /// `[A-Za-z_$][A-Za-z0-9_$]*` shape get quoted. pub(crate) fn safe_property_name(name: &str) -> Cow<'_, str> { - if is_ident(name) { + if is_identifier(name) { Cow::Borrowed(name) } else { Cow::Owned(quoted(name)) diff --git a/src/emit/ts/mod.rs b/src/emit/ts/mod.rs index 848c597..6766b14 100644 --- a/src/emit/ts/mod.rs +++ b/src/emit/ts/mod.rs @@ -11,5 +11,5 @@ pub(crate) mod writer; pub(crate) use decl::{Doc, Member, interface_block, jsdoc, string_union, type_alias}; pub(crate) use imports::{Binding, Statement, import_line, type_import_block, type_reexport_line}; -pub(crate) use types::{Position, Render, property_declaration}; -pub(crate) use writer::{Writer, w, wln}; +pub(crate) use types::{Position, Render, member_declaration}; +pub(crate) use writer::{Writer, w, wln, write_separated}; diff --git a/src/emit/ts/types.rs b/src/emit/ts/types.rs index 5739185..26a3c11 100644 --- a/src/emit/ts/types.rs +++ b/src/emit/ts/types.rs @@ -1,10 +1,10 @@ //! Type-expression rendering. -use crate::ir::canonical::BodyFieldType; -use crate::ir::schema::{SchemaProperty, SchemaScalar, SchemaType}; +use crate::api_model::canonical::BodyFieldType; +use crate::api_model::schema::{SchemaProperty, SchemaScalar, SchemaType}; use super::literal::safe_property_name; -use super::writer::Writer; +use super::writer::{Writer, write_separated}; /// Syntactic position of a rendered type, which decides whether a composite /// needs parentheses so the surrounding operator binds correctly. @@ -112,13 +112,24 @@ impl SchemaType { /// Appends `name`, its optional marker, and its type as an interface member /// — without the trailing `;`. -pub(crate) fn property_declaration(out: &mut Writer, name: &str, optional: bool, ty: &impl Render) { +fn property_declaration(out: &mut Writer, name: &str, optional: bool, type_expr: &impl Render) { out.push(&safe_property_name(name)); if optional { out.push("?"); } out.push(": "); - ty.render(out, Position::Standalone); + type_expr.render(out, Position::Standalone); +} + +/// Writes `name{?}: {type};` and ends the line. +pub(crate) fn member_declaration( + out: &mut Writer, + name: &str, + optional: bool, + type_expr: &impl Render, +) { + property_declaration(out, name, optional, type_expr); + out.push(";\n"); } const fn scalar_keyword(scalar: &SchemaScalar) -> &'static str { @@ -130,21 +141,15 @@ const fn scalar_keyword(scalar: &SchemaScalar) -> &'static str { } fn render_composition(out: &mut Writer, members: &[SchemaType], separator: &str) { - for (index, member) in members.iter().enumerate() { - if index > 0 { - out.push(separator); - } + write_separated(out, members, separator, |out, member| { member.render(out, Position::Wrapped); - } + }); } fn render_literal_union(out: &mut Writer, values: &[String]) { - for (index, value) in values.iter().enumerate() { - if index > 0 { - out.push(" | "); - } + write_separated(out, values, " | ", |out, value| { out.push(&super::literal::quoted(value)); - } + }); } fn render_inline_object(out: &mut Writer, properties: &[SchemaProperty]) { @@ -152,14 +157,11 @@ fn render_inline_object(out: &mut Writer, properties: &[SchemaProperty]) { out.push("Record"); return; } - out.push("{\n"); - out.indent(); - for property in properties { - property_declaration(out, &property.name, !property.required, &property.ty); - out.push(";\n"); - } - out.dedent(); - out.push("}"); + out.inline_block(|out| { + properties.iter().for_each(|property| { + member_declaration(out, &property.name, !property.required, &property.schema); + }); + }); } /// Renders `value` into a fresh `String`. diff --git a/src/emit/ts/writer.rs b/src/emit/ts/writer.rs index 5152915..bbfcba8 100644 --- a/src/emit/ts/writer.rs +++ b/src/emit/ts/writer.rs @@ -114,6 +114,16 @@ impl Writer { self.indent(); } + /// Writes an inline `{ … }`, indenting whatever `members` writes. Leaves + /// the closing brace unterminated, for a type position. + pub(crate) fn inline_block(&mut self, members: impl FnOnce(&mut Self)) { + self.push("{\n"); + self.indent(); + members(self); + self.dedent(); + self.push("}"); + } + /// Dedents, then writes `}` followed by `suffix`. pub(crate) fn close_block(&mut self, suffix: &str) { self.dedent(); @@ -156,6 +166,21 @@ impl Writer { } } +/// Writes each of `items` through `write`, separated by `separator`. +pub(crate) fn write_separated( + out: &mut Writer, + items: impl IntoIterator, + separator: &str, + write: impl Fn(&mut Writer, T), +) { + items.into_iter().enumerate().for_each(|(index, item)| { + if index > 0 { + out.push(separator); + } + write(out, item); + }); +} + /// Appends formatted text to a [`Writer`]. macro_rules! w { ($writer:expr, $($arg:tt)*) => { diff --git a/src/emit/ts_tests.rs b/src/emit/ts_tests.rs index affece0..0538011 100644 --- a/src/emit/ts_tests.rs +++ b/src/emit/ts_tests.rs @@ -5,9 +5,9 @@ mod tests { use super::super::ts::literal::safe_property_name; use super::super::ts::types::render_to_string; use super::super::ts::*; - use crate::ident::is_ident; - use crate::ir::canonical::BodyFieldType; - use crate::ir::schema::{SchemaScalar, SchemaType}; + use crate::api_model::canonical::BodyFieldType; + use crate::api_model::schema::{SchemaScalar, SchemaType}; + use crate::identifier::is_identifier; use crate::test_support::{nullable_property, property}; #[test] @@ -361,7 +361,7 @@ mod tests { if out.is_empty() { return false; } - if is_ident(out) { + if is_identifier(out) { return true; } let bytes = out.as_bytes(); diff --git a/src/ident.rs b/src/identifier.rs similarity index 68% rename from src/ident.rs rename to src/identifier.rs index 564edb6..ca579e5 100644 --- a/src/ident.rs +++ b/src/identifier.rs @@ -1,21 +1,15 @@ -//! Validated identifier and name types shared by normalize, plan and emit. -//! -//! Every value here is checked at construction, so a holder may interpolate -//! it into generated TypeScript without re-checking or quoting. +//! Name types checked at construction, so a holder may interpolate one +//! into generated TypeScript without quoting or escaping. -/// A bare JavaScript / TypeScript identifier, restricted to the ASCII -/// subset: `[A-Za-z_$][A-Za-z0-9_$]*`. -/// -/// Holding one is the assertion that the name needs no quoting in property -/// position and no escaping in expression position. +/// An ASCII JavaScript identifier: `[A-Za-z_$][A-Za-z0-9_$]*`. #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] -pub(crate) struct Ident(Box); +pub(crate) struct Identifier(Box); -impl Ident { +impl Identifier { /// Returns `None` when `name` is not a bare identifier — digits-first, /// kebab-case, dotted, empty, or whitespace-bearing names all reject. pub(crate) fn parse(name: &str) -> Option { - is_ident(name).then(|| Self(Box::from(name))) + is_identifier(name).then(|| Self(Box::from(name))) } pub(crate) fn as_str(&self) -> &str { @@ -23,15 +17,15 @@ impl Ident { } } -impl std::fmt::Display for Ident { +impl std::fmt::Display for Identifier { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(&self.0) } } -/// True when `name` is a bare identifier. Prefer [`Ident::parse`] where +/// True when `name` is a bare identifier. Prefer [`Identifier::parse`] where /// the validated name is kept. -pub(crate) fn is_ident(name: &str) -> bool { +pub(crate) fn is_identifier(name: &str) -> bool { let mut chars = name.chars(); chars .next() @@ -85,26 +79,26 @@ impl std::fmt::Display for TypeName { #[cfg(test)] mod tests { - use super::{Ident, is_ident}; + use super::{Identifier, is_identifier}; #[test] fn accepts_the_bare_identifier_grammar() { for name in ["pet", "_pet", "$pet", "Pet2", "a_b$c9"] { - assert!(Ident::parse(name).is_some(), "{name} must parse"); + assert!(Identifier::parse(name).is_some(), "{name} must parse"); } } #[test] fn rejects_names_that_need_quoting() { for name in ["", "2pet", "pet-name", "pet.name", "pet name", "pét"] { - assert!(Ident::parse(name).is_none(), "{name} must reject"); - assert!(!is_ident(name)); + assert!(Identifier::parse(name).is_none(), "{name} must reject"); + assert!(!is_identifier(name)); } } #[test] fn parsed_identifier_round_trips_its_source() { - let ident = Ident::parse("listPets").expect("bare identifier"); + let ident = Identifier::parse("listPets").expect("bare identifier"); assert_eq!(ident.as_str(), "listPets"); assert_eq!(ident.to_string(), "listPets"); } diff --git a/src/io/writer.rs b/src/io/writer.rs index ebaa63b..099c7af 100644 --- a/src/io/writer.rs +++ b/src/io/writer.rs @@ -15,11 +15,9 @@ pub(crate) fn write_generated_artifacts( return Ok(()); }; - for artifact in artifacts { - write_artifact(output_path, artifact, reporter)?; - } - - Ok(()) + artifacts + .iter() + .try_for_each(|artifact| write_artifact(output_path, artifact, reporter)) } fn write_artifact( diff --git a/src/ir/normalize/operations/body.rs b/src/ir/normalize/operations/body.rs deleted file mode 100644 index 35e72dc..0000000 --- a/src/ir/normalize/operations/body.rs +++ /dev/null @@ -1,143 +0,0 @@ -//! Request-body lowering: content-type dispatch onto JSON, multipart or -//! urlencoded. - -use crate::error::{Context, Diagnostic, bail_policy}; -use crate::ir::canonical::{BodyContent, RequestBodyDef}; -use crate::ir::schema::SchemaType; -use crate::parse::openapi_model::RequestBody; - -use super::super::schema::normalize_schema; -use super::super::{SchemaWalk, bail_unsupported, unsupported}; -use super::OperationCx; -use super::form::{FormBody, FormKind, normalize_form_body_fields}; - -pub(super) fn normalize_request_body( - request_body: Option<&RequestBody>, - cx: OperationCx<'_>, -) -> Result, Diagnostic> { - let (method, path, reporter) = (cx.method(), cx.path(), cx.reporter()); - let Some(body) = request_body else { - return Ok(None); - }; - - if body.content.len() > 1 { - bail_policy!( - reporter, - "multi-content-body", - "requestBody for {method} {path} must declare exactly one content type." - ); - } - - let Some((mime, media)) = body.content.iter().next() else { - return Ok(None); - }; - // OpenAPI permits MIME case variation (`Application/JSON`). - let mime_lc = mime.to_ascii_lowercase(); - - let content = match mime_lc.as_str() { - "application/json" => { - let schema = media.schema.as_ref().ok_or_else(|| { - unsupported( - reporter, - format!("requestBody for {method} {path} must define schema."), - ) - })?; - - let walk = SchemaWalk::root(Context::RequestBody { method, path }, reporter); - let ty = normalize_schema(schema, walk)?; - - if matches!(ty, SchemaType::Any) { - bail_unsupported!( - reporter, - "requestBody for {method} {path} must define a concrete schema." - ); - } - - BodyContent::Json(ty) - } - "multipart/form-data" => { - let (body_ref, fields) = normalize_form_body_fields( - media, - FormBody::new(FormKind::Multipart, method, path, reporter), - cx.schemas(), - )?; - BodyContent::Multipart { body_ref, fields } - } - "application/x-www-form-urlencoded" => { - let (body_ref, fields) = normalize_form_body_fields( - media, - FormBody::new(FormKind::UrlEncoded, method, path, reporter), - cx.schemas(), - )?; - BodyContent::UrlEncoded { body_ref, fields } - } - other => { - bail_policy!( - reporter, - "unsupported-body-content-type", - "requestBody for {method} {path}: unsupported content type {other:?}. Use application/json, multipart/form-data, or application/x-www-form-urlencoded." - ); - } - }; - - Ok(Some(RequestBodyDef { - required: body.required, - content, - })) -} - -#[cfg(test)] -mod tests { - use super::super::OperationCx; - - fn test_cx<'a>( - schemas: &'a BTreeMap<&'a str, &'a SchemaType>, - reporter: &'a crate::error::Reporter, - ) -> OperationCx<'a> { - OperationCx::new("POST", "/x", schemas, &[], reporter) - } - use std::collections::BTreeMap; - - use super::normalize_request_body; - use crate::ir::schema::SchemaType; - use crate::parse::openapi_model::RequestBody; - use crate::test_support::test_reporter; - - fn parse_request_body(yaml: &str) -> RequestBody { - serde_yml::from_str(yaml).expect("fixture parses as RequestBody") - } - - fn empty_schema_index<'a>() -> BTreeMap<&'a str, &'a SchemaType> { - BTreeMap::new() - } - - #[test] - fn rejects_body_with_multiple_content_types() { - let yaml = r#" -content: - application/json: - schema: { type: object, properties: { x: { type: string } } } - multipart/form-data: - schema: { type: object, properties: { x: { type: string } } } -"#; - let body = parse_request_body(yaml); - let ctx = test_reporter(); - let err = normalize_request_body(Some(&body), test_cx(&empty_schema_index(), &ctx)) - .expect_err("multi-content should fail"); - assert_eq!(err.subcode, Some("multi-content-body")); - } - - #[test] - fn rejects_unsupported_body_content_type() { - let yaml = r#" -content: - application/xml: - schema: { type: object, properties: { x: { type: string } } } -"#; - let body = parse_request_body(yaml); - let ctx = test_reporter(); - let err = normalize_request_body(Some(&body), test_cx(&empty_schema_index(), &ctx)) - .expect_err("xml body should fail"); - assert_eq!(err.subcode, Some("unsupported-body-content-type")); - } -} diff --git a/src/ir/normalize/operations/parameters.rs b/src/ir/normalize/operations/parameters.rs deleted file mode 100644 index 7788412..0000000 --- a/src/ir/normalize/operations/parameters.rs +++ /dev/null @@ -1,117 +0,0 @@ -//! `in: path` / `in: query` / `in: header` parameter lowering. - -use crate::error::{Diagnostic, DiagnosticCode}; -use crate::ir::canonical::{HeaderDef, RequestInputDef, RequestInputSource}; -use crate::ir::schema::SchemaType; - -use super::super::schema::normalize_schema; -use super::super::{SchemaWalk, bail_unsupported, unsupported}; -use crate::error::Context; - -use super::{OperationCx, request_input_sort_key}; - -/// Which slot of the request contract a parameter lands in. -#[derive(Clone, Copy, PartialEq, Eq)] -enum Destination { - Input(RequestInputSource), - Header, -} - -/// Lowers an operation's parameters into its path/query inputs and its -/// header list, each sorted by name. -/// -/// A `cookie` parameter is dropped with a warning; any other unsupported -/// location fails. -pub(super) fn normalize_request_inputs( - parameters: &[crate::parse::openapi_model::Parameter], - operation_id: &str, - cx: OperationCx<'_>, -) -> Result<(Vec, Vec), Diagnostic> { - let (method, path, reporter) = (cx.method(), cx.path(), cx.reporter()); - let mut inputs = Vec::with_capacity(parameters.len()); - let mut headers = Vec::new(); - - for parameter in parameters { - let name = ¶meter.name; - let destination = match parameter.location.as_str() { - "path" => Destination::Input(RequestInputSource::Path), - "query" => Destination::Input(RequestInputSource::Query), - "header" => Destination::Header, - "cookie" => { - reporter.warning( - DiagnosticCode::UnsupportedSemantic, - Some("unsupported-parameter-location"), - format!( - "operationId '{operation_id}': parameter '{name}' uses location 'cookie', which is not supported in the generated service contract and will be omitted.", - ), - ); - continue; - } - other => { - bail_unsupported!( - reporter, - "parameter {name} for {method} {path} uses unsupported location {other}." - ); - } - }; - - let required = parameter.required; - - if destination == Destination::Input(RequestInputSource::Path) && !required { - bail_unsupported!( - reporter, - "path parameter {name} for {method} {path} must be required." - ); - } - - if parameter.content.is_some() { - bail_unsupported!( - reporter, - "parameter {name} for {method} {path} must use schema, not content." - ); - } - - let schema = parameter.schema.as_ref().ok_or_else(|| { - unsupported( - reporter, - format!("parameter {name} for {method} {path} must define schema."), - ) - })?; - - let walk = SchemaWalk::root(Context::Parameter { method, path }, reporter); - let ty = normalize_schema(schema, walk)?; - match ty { - SchemaType::InlineObject { .. } => { - bail_unsupported!( - reporter, - "parameter {name} for {method} {path} uses an inline object schema, which is outside the supported subset." - ); - } - SchemaType::Any => { - bail_unsupported!( - reporter, - "parameter {name} for {method} {path} uses an empty schema, which is outside the supported subset." - ); - } - _ => {} - } - - match destination { - Destination::Input(source) => inputs.push(RequestInputDef { - name: name.as_str().into(), - source, - required, - ty, - }), - Destination::Header => headers.push(HeaderDef { - name: name.as_str().into(), - required, - ty, - }), - } - } - - inputs.sort_by(|left, right| request_input_sort_key(left).cmp(&request_input_sort_key(right))); - headers.sort_by(|left, right| left.name.cmp(&right.name)); - Ok((inputs, headers)) -} diff --git a/src/lib.rs b/src/lib.rs index e76ed17..597c1d2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,11 +1,11 @@ #![deny(clippy::all)] +mod api_model; mod bindings; mod emit; mod error; -mod ident; +mod identifier; mod io; -mod ir; mod options; mod parse; mod pipeline; diff --git a/src/options.rs b/src/options.rs index 34f13ec..6b9c68f 100644 --- a/src/options.rs +++ b/src/options.rs @@ -5,21 +5,18 @@ use napi_derive::napi; use crate::{ bindings::{EmitTarget, InputFormat, NamingOptions}, error::{Diagnostic, DiagnosticCode, Reporter, bail}, - ident::is_ident, + identifier::is_identifier, }; -/// One caller-declared mapped type: replace the generated declaration for -/// `schema` with `ty` imported from `import`. -/// -/// Field names match the config vocabulary. `ty` crosses the NAPI -/// boundary as `type`. +/// Replaces the generated declaration for `schema` with `type_name`, +/// imported from `import`. Crosses the NAPI boundary as `type`. #[napi(object)] #[derive(Clone, Debug, PartialEq, Eq)] pub struct MappedType { pub schema: String, pub import: String, #[napi(js_name = "type")] - pub ty: String, + pub type_name: String, pub alias: Option, } @@ -153,7 +150,7 @@ fn validate_mapped_types( fn validate_mapped_type(mapped_type: &MappedType, reporter: &Reporter) -> Result<(), Diagnostic> { if mapped_type.schema.trim().is_empty() || mapped_type.import.trim().is_empty() - || mapped_type.ty.trim().is_empty() + || mapped_type.type_name.trim().is_empty() { return Err(reporter.error( DiagnosticCode::InvalidOption, @@ -161,17 +158,17 @@ fn validate_mapped_type(mapped_type: &MappedType, reporter: &Reporter) -> Result )); } - if !is_ident(&mapped_type.ty) { + if !is_identifier(&mapped_type.type_name) { bail!( reporter, DiagnosticCode::InvalidOption, "Failed to resolve generation options: mapped type type '{}' is not a valid TypeScript identifier (expected /^[A-Za-z_$][A-Za-z0-9_$]*$/).", - mapped_type.ty, + mapped_type.type_name, ); } if let Some(alias) = mapped_type.alias.as_deref() - && !is_ident(alias) + && !is_identifier(alias) { bail!( reporter, @@ -344,7 +341,7 @@ mod tests { mapped_types: vec![MappedType { schema: "UserId".to_string(), import: " ".to_string(), - ty: "ExternalUserId".to_string(), + type_name: "ExternalUserId".to_string(), alias: None, }], ..config("spec.yaml") diff --git a/src/parse/policy.rs b/src/parse/policy.rs index e020c94..2efff5a 100644 --- a/src/parse/policy.rs +++ b/src/parse/policy.rs @@ -27,6 +27,12 @@ pub(crate) fn validate_generation_policy( document: &OpenApiDocument, reporter: &Reporter, ) -> Result<(), Diagnostic> { + check_schema_cap(document, reporter)?; + check_operation_cap(document, reporter)?; + check_operation_ids_are_unique(document, reporter) +} + +fn check_schema_cap(document: &OpenApiDocument, reporter: &Reporter) -> Result<(), Diagnostic> { let schema_count = document.components.schemas.len(); let cap_schemas = MAX_SCHEMAS.get(); if schema_count > cap_schemas { @@ -37,7 +43,10 @@ pub(crate) fn validate_generation_policy( the per-document cap is {cap_schemas}. Set OPENAPI_NG_MAX_SCHEMAS to override.", ); } + Ok(()) +} +fn check_operation_cap(document: &OpenApiDocument, reporter: &Reporter) -> Result<(), Diagnostic> { let operation_count: usize = document .paths .values() @@ -52,8 +61,15 @@ pub(crate) fn validate_generation_policy( the per-document cap is {cap_operations}. Set OPENAPI_NG_MAX_OPERATIONS to override.", ); } + Ok(()) +} - // Each operationId, against the first operation that declared it. +/// Fails on the second operation to declare an `operationId`, naming the +/// first, and on an operation that declares none. +fn check_operation_ids_are_unique( + document: &OpenApiDocument, + reporter: &Reporter, +) -> Result<(), Diagnostic> { document .paths .iter() @@ -62,38 +78,45 @@ pub(crate) fn validate_generation_policy( .operations() .map(move |(method, operation)| (path.as_str(), method, operation)) }) - .try_fold( - BTreeMap::<&str, (&'static str, &str)>::new(), - |mut declared, (path, method, operation)| { - let Some(operation_id) = operation.operation_id.as_deref() else { - bail_policy!( - reporter, - "missing-operation-id", - "Failed to plan services: operation {} {} must define operationId when service generation is enabled.", - method.to_ascii_uppercase(), - path - ); - }; - - if let Some(&(first_method, first_path)) = declared.get(operation_id) { - bail_policy!( - reporter, - "duplicate-operation-id", - "Failed to plan services: operationId '{}' is defined on both {} {} and {} {}. \ - operationIds must be globally unique.", - operation_id, - first_method.to_ascii_uppercase(), - first_path, - method.to_ascii_uppercase(), - path, - ); - } - declared.insert(operation_id, (method, path)); - Ok(declared) - }, - )?; + .try_fold(BTreeMap::new(), |declared, (path, method, operation)| { + let Some(operation_id) = operation.operation_id.as_deref() else { + bail_policy!( + reporter, + "missing-operation-id", + "Failed to plan services: operation {} {} must define operationId when service generation is enabled.", + method.to_ascii_uppercase(), + path + ); + }; + claim_operation_id(declared, operation_id, method, path, reporter) + }) + .map(|_| ()) +} - Ok(()) +/// Records `operation_id` against `method` and `path`, failing when another +/// operation already claimed it. +fn claim_operation_id<'a>( + mut declared: BTreeMap<&'a str, (&'static str, &'a str)>, + operation_id: &'a str, + method: &'static str, + path: &'a str, + reporter: &Reporter, +) -> Result, Diagnostic> { + if let Some(&(first_method, first_path)) = declared.get(operation_id) { + bail_policy!( + reporter, + "duplicate-operation-id", + "Failed to plan services: operationId '{}' is defined on both {} {} and {} {}. \ + operationIds must be globally unique.", + operation_id, + first_method.to_ascii_uppercase(), + first_path, + method.to_ascii_uppercase(), + path, + ); + } + declared.insert(operation_id, (method, path)); + Ok(declared) } #[cfg(test)] diff --git a/src/pipeline.rs b/src/pipeline.rs index 2308bdf..acfdf18 100644 --- a/src/pipeline.rs +++ b/src/pipeline.rs @@ -1,9 +1,9 @@ use std::rc::Rc; use crate::{ + api_model::canonical::ApiModel, emit::{emitters_for, render_generated_banner}, error::{Diagnostic, Reporter}, - ir::canonical::ApiModel, options::{GenerateConfig, validate_generate_config}, plan::plan_generation, result::{GenerateSummary, GeneratedArtifact}, @@ -44,7 +44,7 @@ pub(crate) fn build_ir( }; crate::parse::validate_openapi_version(&document, reporter)?; crate::parse::validate_generation_policy(&document, reporter)?; - crate::ir::normalize_api_model(&document, &config.response_type_mapping, reporter) + crate::api_model::normalize_api_model(&document, &config.response_type_mapping, reporter) } pub fn execute_generate(config: GenerateConfig) -> Result { diff --git a/src/plan/artifact_plan.rs b/src/plan/artifact_plan.rs index a128bd4..547ff55 100644 --- a/src/plan/artifact_plan.rs +++ b/src/plan/artifact_plan.rs @@ -1,12 +1,12 @@ use std::collections::BTreeMap; use crate::{ - error::{Diagnostic, DiagnosticCode, Reporter}, - ident::{Ident, MethodName, TypeName}, - ir::canonical::{ + api_model::canonical::{ ApiModel, BodyFieldType, ErrorResponse, HttpMethod, ModelSymbol, ResponseContent, }, - ir::schema::SchemaType, + api_model::schema::SchemaType, + error::{Diagnostic, DiagnosticCode, Reporter}, + identifier::{Identifier, MethodName, TypeName}, options::MappedType, }; @@ -22,7 +22,7 @@ use super::{ pub(crate) struct ResolvedMappedType<'a> { pub(crate) schema: &'a str, pub(crate) import: Box, - pub(crate) ty: Box, + pub(crate) type_name: Box, pub(crate) alias: Option>, } @@ -31,31 +31,31 @@ impl<'a> ResolvedMappedType<'a> { Self { schema, import: Box::from(source.import.as_str()), - ty: Box::from(source.ty.as_str()), + type_name: Box::from(source.type_name.as_str()), alias: source.alias.as_deref().map(Box::from), } } } #[derive(Debug, PartialEq, Eq)] -pub(crate) struct ServicePlan<'ir> { +pub(crate) struct ServicePlan<'model> { pub(crate) group_name: String, pub(crate) class_name: TypeName, pub(crate) artifact_path: String, - pub(crate) operations: Vec>, + pub(crate) operations: Vec>, } #[derive(Debug, PartialEq, Eq)] -pub(crate) struct PlannedOperation<'ir> { +pub(crate) struct PlannedOperation<'model> { pub(crate) operation_id: String, pub(crate) method_name: MethodName, pub(crate) method: HttpMethod, pub(crate) path: String, - pub(crate) request: PlannedRequestContract<'ir>, - pub(crate) response: Option<&'ir ResponseContent>, + pub(crate) request: PlannedRequestContract<'model>, + pub(crate) response: Option<&'model ResponseContent>, /// The operation's typed error responses, empty when it declared /// none. - pub(crate) errors: &'ir [ErrorResponse], + pub(crate) errors: &'model [ErrorResponse], /// Name of the `{Pascal}Params` interface, or `None` when the operation /// declares no path, query, header or body input and so emits none. pub(crate) request_interface: Option, @@ -76,64 +76,71 @@ pub(crate) enum RequestFieldKind { } #[derive(Debug, PartialEq, Eq)] -pub(crate) struct PlannedRequestContract<'ir> { +pub(crate) struct PlannedRequestContract<'model> { /// Path and query parameters; a hoisted body property lives on /// [`PlannedRequestBody::FlatJson`]. - pub(crate) fields: Vec>, + pub(crate) fields: Vec>, /// Header parameters, empty when the operation declares none. - pub(crate) headers: Vec>, + pub(crate) headers: Vec>, /// The body's layout, `None` when the operation declares no body. - pub(crate) body: Option>, + pub(crate) body: Option>, } #[derive(Debug, PartialEq, Eq)] -pub(crate) struct PlannedRequestField<'ir> { +pub(crate) struct PlannedRequestField<'model> { pub(crate) name: Box, pub(crate) optional: bool, - pub(crate) ty: &'ir SchemaType, + pub(crate) schema: &'model SchemaType, pub(crate) kind: RequestFieldKind, } #[derive(Debug, PartialEq, Eq)] -pub(crate) struct PlannedHeader<'ir> { +pub(crate) struct PlannedHeader<'model> { pub(crate) name: Box, pub(crate) optional: bool, - pub(crate) ty: &'ir SchemaType, + pub(crate) schema: &'model SchemaType, } /// One field of a multipart or urlencoded body. #[derive(Debug, PartialEq, Eq)] -pub(crate) struct PlannedFormField<'ir> { - pub(crate) name: Ident, +pub(crate) struct PlannedFormField<'model> { + pub(crate) name: Identifier, pub(crate) optional: bool, - pub(crate) ty: &'ir BodyFieldType, + pub(crate) field_type: &'model BodyFieldType, } /// How a request body is laid out on the request contract. #[derive(Debug, PartialEq, Eq)] -pub(crate) enum PlannedRequestBody<'ir> { +pub(crate) enum PlannedRequestBody<'model> { /// A top-level `$ref`, scalar, array or union, under one `body` key. - Nested { ty: &'ir SchemaType, optional: bool }, + Nested { + schema: &'model SchemaType, + optional: bool, + }, /// An inline JSON object body, its properties hoisted to top level. /// Each `optional` already folds in the envelope's `required`. FlatJson { - properties: Vec>, + properties: Vec>, required: bool, }, /// A `multipart/form-data` body, its fields hoisted to top level. - Multipart { fields: Vec> }, + Multipart { + fields: Vec>, + }, /// An `application/x-www-form-urlencoded` body, its fields hoisted to /// top level. - UrlEncoded { fields: Vec> }, + UrlEncoded { + fields: Vec>, + }, } /// Resolves each mapped type against `model_symbols`, failing on the /// first `schema` the IR does not declare. -pub(crate) fn validate_mapped_types_against_schemas<'ir>( - model_symbols: &'ir [ModelSymbol], +pub(crate) fn validate_mapped_types_against_schemas<'model>( + model_symbols: &'model [ModelSymbol], mapped_types: &[MappedType], reporter: &Reporter, -) -> Result>, Diagnostic> { +) -> Result>, Diagnostic> { let by_name = model_symbols .iter() .map(|symbol| (symbol.name.as_ref(), symbol)) @@ -156,11 +163,11 @@ pub(crate) fn validate_mapped_types_against_schemas<'ir>( .collect() } -pub(crate) fn resolve_service_plans<'ir>( - ir: &'ir ApiModel, +pub(crate) fn resolve_service_plans<'model>( + ir: &'model ApiModel, resolver: &crate::plan::naming::NamingResolver, reporter: &Reporter, -) -> Result>, Diagnostic> { +) -> Result>, Diagnostic> { use super::services::group_operations; let mut services = group_operations(&ir.operations, resolver, reporter)? @@ -185,11 +192,11 @@ pub(crate) fn resolve_service_plans<'ir>( Ok(services) } -fn plan_operation<'ir>( - operation: &'ir crate::ir::canonical::OperationDef, +fn plan_operation<'model>( + operation: &'model crate::api_model::canonical::OperationDef, method_name: MethodName, reporter: &Reporter, -) -> Result, Diagnostic> { +) -> Result, Diagnostic> { let request = plan_request_contract(operation, reporter)?; Ok(PlannedOperation { operation_id: operation.operation_id.clone(), @@ -219,7 +226,7 @@ mod tests { resolve_service_plans, validate_mapped_types_against_schemas, }; use crate::{ - ir::{ + api_model::{ canonical::{ ApiInfo, ApiModel, BodyContent, BodyFieldType, HttpMethod, ModelSymbol, OperationDef, RequestBodyDef, RequestDef, RequestInputDef, RequestInputSource, ResponseContent, @@ -286,21 +293,21 @@ mod tests { SchemaProperty { name: "status".into(), required: true, - ty: SchemaType::Ref("PetStatus".into()), + schema: SchemaType::Ref("PetStatus".into()), description: None, deprecated: false, }, SchemaProperty { name: "tagIds".into(), required: true, - ty: SchemaType::Array(Box::new(SchemaType::Scalar(SchemaScalar::Number))), + schema: SchemaType::Array(Box::new(SchemaType::Scalar(SchemaScalar::Number))), description: None, deprecated: false, }, SchemaProperty { name: "nickname".into(), required: false, - ty: SchemaType::Nullable(Box::new(SchemaType::Scalar(SchemaScalar::String))), + schema: SchemaType::Nullable(Box::new(SchemaType::Scalar(SchemaScalar::String))), description: None, deprecated: false, }, @@ -349,13 +356,13 @@ mod tests { name: "petId".into(), source: RequestInputSource::Path, required: true, - ty: SchemaType::Ref("PetId".into()), + schema: SchemaType::Ref("PetId".into()), }, RequestInputDef { name: "includeHistory".into(), source: RequestInputSource::Query, required: false, - ty: SchemaType::Scalar(SchemaScalar::Boolean), + schema: SchemaType::Scalar(SchemaScalar::Boolean), }, ], headers: Vec::new(), @@ -383,7 +390,7 @@ mod tests { properties: vec![SchemaProperty { name: "petId".into(), required: true, - ty: SchemaType::Ref("PetId".into()), + schema: SchemaType::Ref("PetId".into()), description: None, deprecated: false, }], @@ -421,7 +428,7 @@ mod tests { &[MappedType { schema: "UserId".to_string(), import: "./shared/user-id".to_string(), - ty: "ExternalUserId".to_string(), + type_name: "ExternalUserId".to_string(), alias: Some("UserId".to_string()), }], &ctx, @@ -431,7 +438,7 @@ mod tests { assert_eq!(resolved.len(), 1); assert_eq!(resolved[0].schema, "UserId"); assert_eq!(resolved[0].import.as_ref(), "./shared/user-id"); - assert_eq!(resolved[0].ty.as_ref(), "ExternalUserId"); + assert_eq!(resolved[0].type_name.as_ref(), "ExternalUserId"); assert_eq!(resolved[0].alias.as_deref(), Some("UserId")); } @@ -443,7 +450,7 @@ mod tests { &[MappedType { schema: "Missing".to_string(), import: "./missing".to_string(), - ty: "Missing".to_string(), + type_name: "Missing".to_string(), alias: None, }], &ctx, @@ -503,11 +510,11 @@ mod tests { .collect(); assert_eq!(kinds, vec![RequestFieldKind::Path, RequestFieldKind::Query]); match &update_pet.request.body { - Some(PlannedRequestBody::Nested { ty, optional }) => { + Some(PlannedRequestBody::Nested { schema, optional }) => { assert!(!optional, "body marked required in fixture"); assert!( - matches!(ty, SchemaType::Ref(name) if name.as_ref() == "UpdatePetPayload"), - "expected body ty to remain the ref, got {ty:?}" + matches!(schema, SchemaType::Ref(name) if name.as_ref() == "UpdatePetPayload"), + "expected body schema to remain the ref, got {schema:?}" ); } other => panic!("expected nested ref body, got {other:?}"), @@ -533,7 +540,7 @@ mod tests { properties: vec![SchemaProperty { name: "petId".into(), required: true, - ty: SchemaType::Ref("PetId".into()), + schema: SchemaType::Ref("PetId".into()), description: None, deprecated: false, }], @@ -552,7 +559,7 @@ mod tests { name: "petId".into(), source: RequestInputSource::Path, required: true, - ty: SchemaType::Ref("PetId".into()), + schema: SchemaType::Ref("PetId".into()), }], headers: Vec::new(), body: Some(RequestBodyDef { @@ -674,9 +681,9 @@ mod tests { headers: vec![], body: Some(PlannedRequestBody::Multipart { fields: vec![PlannedFormField { - name: crate::ident::Ident::parse("status").expect("identifier"), + name: crate::identifier::Identifier::parse("status").expect("identifier"), optional: false, - ty: &scalar, + field_type: &scalar, }], }), }; @@ -689,9 +696,9 @@ mod tests { #[test] fn planned_request_body_carries_smart_flatten_variants() { - let ty = SchemaType::Scalar(SchemaScalar::String); + let schema = SchemaType::Scalar(SchemaScalar::String); let _: PlannedRequestBody<'_> = PlannedRequestBody::Nested { - ty: &ty, + schema: &schema, optional: false, }; let _: PlannedRequestBody<'_> = PlannedRequestBody::FlatJson { diff --git a/src/plan/mod.rs b/src/plan/mod.rs index 1b0655a..96f1f66 100644 --- a/src/plan/mod.rs +++ b/src/plan/mod.rs @@ -6,9 +6,9 @@ pub mod naming; pub(crate) mod services; use crate::{ + api_model::canonical::{ApiModel, ModelSymbol}, bindings::EmitTarget, error::{Diagnostic, Reporter}, - ir::canonical::{ApiModel, ModelSymbol}, options::GenerateConfig, }; @@ -21,18 +21,18 @@ use artifact_plan::{ /// /// `services` is empty when Angular is not among the selected targets, and /// `mapped_types` is empty when the caller declared none. -pub(crate) struct GenerationPlan<'ir> { - pub(crate) schemas: &'ir [ModelSymbol], - pub(crate) mapped_types: Vec>, - pub(crate) services: Vec>, +pub(crate) struct GenerationPlan<'model> { + pub(crate) schemas: &'model [ModelSymbol], + pub(crate) mapped_types: Vec>, + pub(crate) services: Vec>, } /// Builds the plan for the targets `config` selects. -pub(crate) fn plan_generation<'ir>( +pub(crate) fn plan_generation<'model>( config: &GenerateConfig, - ir: &'ir ApiModel, + ir: &'model ApiModel, reporter: &Reporter, -) -> Result, Diagnostic> { +) -> Result, Diagnostic> { let emit_models = config.emit.contains(&EmitTarget::Models); let emit_angular = config.emit.contains(&EmitTarget::Angular); diff --git a/src/plan/naming/case.rs b/src/plan/naming/case.rs index 4f8b7ec..ed2b297 100644 --- a/src/plan/naming/case.rs +++ b/src/plan/naming/case.rs @@ -1,14 +1,8 @@ -//! The one name tokenizer and set of case renderers, shared by the -//! caller-facing `case` rule and by [`super::fixed`]. +//! The name tokenizer and case renderers. use crate::plan::naming::config::Case; -/// Splits `name` into its casing tokens, borrowing each from `name`. -/// -/// Separators are every non-alphanumeric character. A run of -/// alphanumerics splits at two case transitions: after a lowercase or -/// digit that precedes an uppercase (`listPets`), and at the last -/// uppercase of a run that is followed by a lowercase (`URLPath`). +/// Splits `name` into its casing tokens, each borrowed from `name`. pub(crate) const fn tokenize(name: &str) -> Tokens<'_> { Tokens { rest: name } } @@ -29,27 +23,25 @@ impl<'a> Iterator for Tokens<'a> { } } -/// Byte length of the token starting at `token`, whose first character -/// is alphanumeric. +/// Byte length of the token at the start of `token`. fn token_len(token: &str) -> usize { - let mut chars = token.char_indices(); - let Some((_, first)) = chars.next() else { - return 0; - }; - let mut previous = first; - for (offset, current) in chars.clone() { - let following = chars.clone().nth(1).map(|(_, ch)| ch); - if !current.is_alphanumeric() || splits_before(previous, current, following) { - return offset; - } - previous = current; - chars.next(); - } - token.len() + let current = token.char_indices().skip(1); + let previous = token.chars(); + let following = token.chars().skip(2).map(Some).chain(std::iter::once(None)); + + current + .zip(previous) + .zip(following) + .find(|(((_, current), previous), following)| { + !current.is_alphanumeric() || splits_before(*previous, *current, *following) + }) + .map_or(token.len(), |(((offset, _), _), _)| offset) } -/// True when a token boundary falls immediately before `current`. -/// `following` is the character after `current`, if any. +/// True when a token boundary falls immediately before `current`, which a +/// run of alphanumerics reaches at two case transitions: after a lowercase +/// or digit (`listPets`), and at the last uppercase of a run followed by a +/// lowercase (`URLPath`). fn splits_before(previous: char, current: char, following: Option) -> bool { let starts_after_lower = previous.is_ascii_lowercase() || previous.is_ascii_digit(); let ends_upper_run = previous.is_ascii_uppercase() diff --git a/src/plan/naming/context.rs b/src/plan/naming/context.rs index 481974b..5026c9b 100644 --- a/src/plan/naming/context.rs +++ b/src/plan/naming/context.rs @@ -3,7 +3,7 @@ use std::collections::BTreeMap; -use crate::ir::canonical::OperationDef; +use crate::api_model::canonical::OperationDef; #[derive(Debug)] pub(crate) struct OperationContext<'a> { @@ -100,7 +100,7 @@ fn clean_path_segments(path: &str) -> Vec<&str> { #[cfg(test)] mod tests { use super::*; - use crate::ir::{ + use crate::api_model::{ canonical::{HttpMethod, OperationDef, RequestDef, ResponseContent}, schema::{SchemaScalar, SchemaType}, }; diff --git a/src/plan/naming/defaults.rs b/src/plan/naming/defaults.rs index 75c604d..857eaef 100644 --- a/src/plan/naming/defaults.rs +++ b/src/plan/naming/defaults.rs @@ -43,7 +43,7 @@ pub(crate) fn default_group(ctx: &OperationContext<'_>) -> String { #[cfg(test)] mod tests { use super::*; - use crate::ir::{ + use crate::api_model::{ canonical::{HttpMethod, OperationDef, RequestDef, ResponseContent}, schema::{SchemaScalar, SchemaType}, }; diff --git a/src/plan/naming/engine.rs b/src/plan/naming/engine.rs index ce122cb..e04cec3 100644 --- a/src/plan/naming/engine.rs +++ b/src/plan/naming/engine.rs @@ -107,7 +107,7 @@ fn map_template_error(err: TemplateError) -> RuleFailure { mod tests { use super::*; use crate::{ - ir::{ + api_model::{ canonical::{HttpMethod, OperationDef, RequestDef, ResponseContent}, schema::{SchemaScalar, SchemaType}, }, diff --git a/src/plan/naming/fixed.rs b/src/plan/naming/fixed.rs index 9c1f200..6e8db0c 100644 --- a/src/plan/naming/fixed.rs +++ b/src/plan/naming/fixed.rs @@ -1,5 +1,5 @@ use crate::{ - ident::{MethodName, TypeName}, + identifier::{MethodName, TypeName}, plan::naming::{case::apply as apply_case, config::Case}, }; @@ -82,7 +82,7 @@ mod tests { use proptest::prelude::*; - /// ASCII-only TS identifier shape, matching `ident::is_ident`. + /// ASCII-only TS identifier shape, matching `ident::is_identifier`. fn is_ts_identifier(value: &str) -> bool { let mut chars = value.chars(); let Some(first) = chars.next() else { diff --git a/src/plan/naming/mod.rs b/src/plan/naming/mod.rs index 4460de7..ce8173b 100644 --- a/src/plan/naming/mod.rs +++ b/src/plan/naming/mod.rs @@ -22,9 +22,9 @@ pub(crate) use fixed::{ pub(crate) use lower::lower; use crate::{ + api_model::canonical::OperationDef, error::{Diagnostic, Reporter}, - ident::MethodName, - ir::canonical::OperationDef, + identifier::MethodName, }; use context::OperationContext; use defaults::{default_group, default_method_name}; @@ -125,11 +125,11 @@ mod tests { use super::parse_spec::compile as compile_parse_spec; use super::*; use crate::{ - error::DiagnosticCode, - ir::{ + api_model::{ canonical::{HttpMethod, OperationDef, RequestDef, ResponseContent}, schema::{SchemaScalar, SchemaType}, }, + error::DiagnosticCode, test_support::test_reporter, }; diff --git a/src/plan/naming/template.rs b/src/plan/naming/template.rs index 99db696..e247874 100644 --- a/src/plan/naming/template.rs +++ b/src/plan/naming/template.rs @@ -84,7 +84,7 @@ mod tests { use std::collections::HashMap; use super::*; - use crate::ir::{ + use crate::api_model::{ canonical::{HttpMethod, OperationDef, RequestDef, ResponseContent}, schema::{SchemaScalar, SchemaType}, }; diff --git a/src/plan/services/body.rs b/src/plan/services/body.rs index 33a9b72..16cca70 100644 --- a/src/plan/services/body.rs +++ b/src/plan/services/body.rs @@ -1,11 +1,11 @@ //! Request-body layout. use crate::{ - error::{Diagnostic, Reporter}, - ir::{ + api_model::{ canonical::{BodyContent, BodyField, RequestBodyDef}, schema::SchemaType, }, + error::{Diagnostic, Reporter}, plan::artifact_plan::{ PlannedFormField, PlannedRequestBody, PlannedRequestField, RequestFieldKind, }, @@ -18,9 +18,9 @@ use crate::{ /// - any other JSON shape → `Nested`, under one `body` key; /// - a form body → `Multipart` / `UrlEncoded`, its fields hoisted and /// sorted by name. -pub(super) fn plan_request_body<'ir>( - body: Option<&'ir RequestBodyDef>, -) -> Option> { +pub(super) fn plan_request_body<'model>( + body: Option<&'model RequestBodyDef>, +) -> Option> { let body = body?; match &body.content { BodyContent::Json(SchemaType::InlineObject { properties }) => { @@ -30,7 +30,7 @@ pub(super) fn plan_request_body<'ir>( .map(|property| PlannedRequestField { name: property.name.clone(), optional: !envelope_required || !property.required, - ty: &property.ty, + schema: &property.schema, kind: RequestFieldKind::Body, }) .collect(); @@ -39,8 +39,8 @@ pub(super) fn plan_request_body<'ir>( required: envelope_required, }) } - BodyContent::Json(ty) => Some(PlannedRequestBody::Nested { - ty, + BodyContent::Json(schema) => Some(PlannedRequestBody::Nested { + schema, optional: !body.required, }), BodyContent::Multipart { fields, .. } => Some(PlannedRequestBody::Multipart { @@ -52,13 +52,13 @@ pub(super) fn plan_request_body<'ir>( } } -fn plan_form_fields<'ir>(fields: &'ir [BodyField]) -> Vec> { - let mut out: Vec> = fields +fn plan_form_fields<'model>(fields: &'model [BodyField]) -> Vec> { + let mut out: Vec> = fields .iter() .map(|field| PlannedFormField { name: field.name.clone(), optional: !field.required, - ty: &field.ty, + field_type: &field.field_type, }) .collect(); out.sort_by(|left, right| left.name.cmp(&right.name)); @@ -112,7 +112,7 @@ mod tests { mod body { use super::super::plan_request_body; use crate::{ - ir::{ + api_model::{ canonical::{BodyContent, RequestBodyDef}, schema::{SchemaProperty, SchemaScalar, SchemaType}, }, @@ -131,9 +131,9 @@ mod tests { content: BodyContent::Json(SchemaType::Ref("CreatePetRequest".into())), }; match plan_request_body(Some(&body)).expect("body present") { - PlannedRequestBody::Nested { ty, optional } => { + PlannedRequestBody::Nested { schema, optional } => { assert!(!optional); - assert!(matches!(ty, SchemaType::Ref(name) if name.as_ref() == "CreatePetRequest")); + assert!(matches!(schema, SchemaType::Ref(name) if name.as_ref() == "CreatePetRequest")); } other => panic!("expected nested ref body, got {other:?}"), } @@ -147,7 +147,7 @@ mod tests { properties: vec![SchemaProperty { name: "status".into(), required: true, - ty: SchemaType::Scalar(SchemaScalar::String), + schema: SchemaType::Scalar(SchemaScalar::String), description: None, deprecated: false, }], @@ -182,7 +182,7 @@ mod tests { mod form_body { use crate::{ - ir::{ + api_model::{ canonical::{ ApiInfo, ApiModel, BodyContent, BodyField, BodyFieldType, HttpMethod, ModelSymbol, OperationDef, RequestBodyDef, RequestDef, RequestInputDef, RequestInputSource, @@ -247,14 +247,14 @@ mod tests { None, vec![ BodyField { - name: crate::ident::Ident::parse("avatar").expect("identifier"), + name: crate::identifier::Identifier::parse("avatar").expect("identifier"), required: true, - ty: BodyFieldType::Binary, + field_type: BodyFieldType::Binary, }, BodyField { - name: crate::ident::Ident::parse("caption").expect("identifier"), + name: crate::identifier::Identifier::parse("caption").expect("identifier"), required: false, - ty: BodyFieldType::Scalar(SchemaScalar::String), + field_type: BodyFieldType::Scalar(SchemaScalar::String), }, ], )], @@ -271,19 +271,19 @@ mod tests { None, vec![ BodyField { - name: crate::ident::Ident::parse("zeta").expect("identifier"), + name: crate::identifier::Identifier::parse("zeta").expect("identifier"), required: true, - ty: BodyFieldType::Scalar(SchemaScalar::String), + field_type: BodyFieldType::Scalar(SchemaScalar::String), }, BodyField { - name: crate::ident::Ident::parse("alpha").expect("identifier"), + name: crate::identifier::Identifier::parse("alpha").expect("identifier"), required: true, - ty: BodyFieldType::Scalar(SchemaScalar::String), + field_type: BodyFieldType::Scalar(SchemaScalar::String), }, BodyField { - name: crate::ident::Ident::parse("mu").expect("identifier"), + name: crate::identifier::Identifier::parse("mu").expect("identifier"), required: true, - ty: BodyFieldType::Scalar(SchemaScalar::String), + field_type: BodyFieldType::Scalar(SchemaScalar::String), }, ], )], @@ -300,19 +300,19 @@ mod tests { name: "fileName".into(), source: RequestInputSource::Path, required: true, - ty: SchemaType::Scalar(SchemaScalar::String), + schema: SchemaType::Scalar(SchemaScalar::String), }], None, vec![ BodyField { - name: crate::ident::Ident::parse("fileName").expect("identifier"), + name: crate::identifier::Identifier::parse("fileName").expect("identifier"), required: true, - ty: BodyFieldType::Scalar(SchemaScalar::String), + field_type: BodyFieldType::Scalar(SchemaScalar::String), }, BodyField { - name: crate::ident::Ident::parse("blob").expect("identifier"), + name: crate::identifier::Identifier::parse("blob").expect("identifier"), required: true, - ty: BodyFieldType::Binary, + field_type: BodyFieldType::Binary, }, ], )], @@ -328,9 +328,9 @@ mod tests { Vec::new(), Some(body_ref), vec![BodyField { - name: crate::ident::Ident::parse("file").expect("identifier"), + name: crate::identifier::Identifier::parse("file").expect("identifier"), required: true, - ty: BodyFieldType::Binary, + field_type: BodyFieldType::Binary, }], )], ) diff --git a/src/plan/services/grouping.rs b/src/plan/services/grouping.rs index df2f966..9880867 100644 --- a/src/plan/services/grouping.rs +++ b/src/plan/services/grouping.rs @@ -3,9 +3,9 @@ use indexmap::IndexMap; use crate::{ + api_model::canonical::OperationDef, error::{Diagnostic, Reporter}, - ident::MethodName, - ir::canonical::OperationDef, + identifier::MethodName, }; pub(crate) type GroupedOperations<'a> = Vec<(String, Vec<(&'a OperationDef, MethodName)>)>; @@ -42,7 +42,7 @@ pub(crate) fn group_operations<'a>( mod tests { mod grouper { use crate::{ - ir::{ + api_model::{ canonical::{HttpMethod, OperationDef, RequestDef, ResponseContent}, schema::{SchemaScalar, SchemaType}, }, diff --git a/src/plan/services/mod.rs b/src/plan/services/mod.rs index 868bea9..8d9f9f7 100644 --- a/src/plan/services/mod.rs +++ b/src/plan/services/mod.rs @@ -9,8 +9,8 @@ mod grouping; use std::collections::BTreeSet; use crate::{ + api_model::canonical::{OperationDef, RequestInputSource}, error::{Diagnostic, Reporter, bail_policy}, - ir::canonical::{OperationDef, RequestInputSource}, plan::artifact_plan::{ PlannedHeader, PlannedRequestContract, PlannedRequestField, RequestFieldKind, }, @@ -48,18 +48,18 @@ fn check_path_query_collisions( Ok(()) } -pub(crate) fn plan_request_contract<'ir>( - operation: &'ir OperationDef, +pub(crate) fn plan_request_contract<'model>( + operation: &'model OperationDef, reporter: &Reporter, -) -> Result, Diagnostic> { - let fields: Vec> = operation +) -> Result, Diagnostic> { + let fields: Vec> = operation .request .inputs .iter() .map(|input| PlannedRequestField { name: input.name.clone(), optional: !input.required, - ty: &input.ty, + schema: &input.schema, kind: match input.source { RequestInputSource::Path => RequestFieldKind::Path, RequestInputSource::Query => RequestFieldKind::Query, @@ -67,14 +67,14 @@ pub(crate) fn plan_request_contract<'ir>( }) .collect(); - let headers: Vec> = operation + let headers: Vec> = operation .request .headers .iter() .map(|header| PlannedHeader { name: header.name.clone(), optional: !header.required, - ty: &header.ty, + schema: &header.schema, }) .collect(); @@ -95,7 +95,7 @@ pub(crate) fn plan_request_contract<'ir>( mod tests { mod contract { use crate::{ - ir::{ + api_model::{ canonical::{ BodyContent, HeaderDef, HttpMethod, OperationDef, RequestBodyDef, RequestDef, RequestInputDef, RequestInputSource, @@ -120,7 +120,7 @@ mod tests { name: "petId".into(), source: RequestInputSource::Path, required: true, - ty: SchemaType::Ref("PetId".into()), + schema: SchemaType::Ref("PetId".into()), }], headers: Vec::new(), body: Some(RequestBodyDef { @@ -143,9 +143,9 @@ mod tests { .collect(); assert_eq!(path_fields, vec!["petId"]); match &request.body { - Some(PlannedRequestBody::Nested { ty, optional }) => { + Some(PlannedRequestBody::Nested { schema, optional }) => { assert!(!optional); - assert!(matches!(ty, SchemaType::Ref(name) if name.as_ref() == "UpdatePetPayload")); + assert!(matches!(schema, SchemaType::Ref(name) if name.as_ref() == "UpdatePetPayload")); } other => panic!("expected nested ref body, got {other:?}"), } @@ -173,14 +173,14 @@ mod tests { SchemaProperty { name: "csvImportId".into(), required: true, - ty: SchemaType::Ref("CsvImportId".into()), + schema: SchemaType::Ref("CsvImportId".into()), description: None, deprecated: false, }, SchemaProperty { name: "doImport".into(), required: true, - ty: SchemaType::Scalar(SchemaScalar::Boolean), + schema: SchemaType::Scalar(SchemaScalar::Boolean), description: None, deprecated: false, }, @@ -229,7 +229,7 @@ mod tests { headers: vec![HeaderDef { name: "x-trace".into(), required: false, - ty: SchemaType::Scalar(SchemaScalar::String), + schema: SchemaType::Scalar(SchemaScalar::String), }], body: None, }, @@ -258,7 +258,7 @@ mod tests { name: "petId".into(), source: RequestInputSource::Path, required: true, - ty: SchemaType::Ref("PetId".into()), + schema: SchemaType::Ref("PetId".into()), }], headers: Vec::new(), body: Some(RequestBodyDef { @@ -267,7 +267,7 @@ mod tests { properties: vec![SchemaProperty { name: "petId".into(), required: true, - ty: SchemaType::Ref("PetId".into()), + schema: SchemaType::Ref("PetId".into()), description: None, deprecated: false, }], @@ -301,7 +301,7 @@ mod tests { name: "petId".into(), source: RequestInputSource::Path, required: true, - ty: SchemaType::Ref("PetId".into()), + schema: SchemaType::Ref("PetId".into()), }], headers: Vec::new(), body: Some(RequestBodyDef { @@ -338,13 +338,13 @@ mod tests { name: "id".into(), source: RequestInputSource::Path, required: true, - ty: SchemaType::Scalar(SchemaScalar::String), + schema: SchemaType::Scalar(SchemaScalar::String), }, RequestInputDef { name: "id".into(), source: RequestInputSource::Query, required: false, - ty: SchemaType::Scalar(SchemaScalar::String), + schema: SchemaType::Scalar(SchemaScalar::String), }, ], headers: Vec::new(), diff --git a/src/result.rs b/src/result.rs index ab1f024..331b3c5 100644 --- a/src/result.rs +++ b/src/result.rs @@ -1,6 +1,6 @@ use napi_derive::napi; -use crate::ir::canonical::ApiModel; +use crate::api_model::canonical::ApiModel; #[napi(object)] #[derive(Clone, Debug, Eq, PartialEq)] diff --git a/src/test_support.rs b/src/test_support.rs index 4f88e0f..7edcfff 100644 --- a/src/test_support.rs +++ b/src/test_support.rs @@ -1,12 +1,12 @@ use std::rc::Rc; use crate::{ - error::Reporter, - ident::{Ident, MethodName}, - ir::{ + api_model::{ canonical::{BodyFieldType, HttpMethod, ResponseContent}, schema::{SchemaProperty, SchemaScalar, SchemaType}, }, + error::Reporter, + identifier::{Identifier, MethodName}, plan::{ artifact_plan::{ PlannedFormField, PlannedHeader, PlannedOperation, PlannedRequestBody, @@ -26,35 +26,35 @@ pub(crate) fn reporter_for(path: &str) -> Reporter { Reporter::new(Rc::from(path)) } -pub(crate) fn property(name: &str, required: bool, ty: SchemaType) -> SchemaProperty { +pub(crate) fn property(name: &str, required: bool, schema: SchemaType) -> SchemaProperty { SchemaProperty { name: name.into(), required, - ty, + schema, description: None, deprecated: false, } } -pub(crate) fn nullable_property(name: &str, required: bool, ty: SchemaType) -> SchemaProperty { +pub(crate) fn nullable_property(name: &str, required: bool, schema: SchemaType) -> SchemaProperty { SchemaProperty { name: name.into(), required, - ty: SchemaType::Nullable(Box::new(ty)), + schema: SchemaType::Nullable(Box::new(schema)), description: None, deprecated: false, } } -pub(crate) fn string_ty() -> SchemaType { +pub(crate) fn string_schema() -> SchemaType { SchemaType::Scalar(SchemaScalar::String) } -pub(crate) fn path_field<'a>(name: &str, ty: &'a SchemaType) -> PlannedRequestField<'a> { +pub(crate) fn path_field<'a>(name: &str, schema: &'a SchemaType) -> PlannedRequestField<'a> { PlannedRequestField { name: name.into(), optional: false, - ty, + schema, kind: RequestFieldKind::Path, } } @@ -62,12 +62,12 @@ pub(crate) fn path_field<'a>(name: &str, ty: &'a SchemaType) -> PlannedRequestFi pub(crate) fn query_field<'a>( name: &str, optional: bool, - ty: &'a SchemaType, + schema: &'a SchemaType, ) -> PlannedRequestField<'a> { PlannedRequestField { name: name.into(), optional, - ty, + schema, kind: RequestFieldKind::Query, } } @@ -76,18 +76,18 @@ pub(crate) fn query_field<'a>( pub(crate) fn body_field<'a>( name: &str, optional: bool, - ty: &'a SchemaType, + schema: &'a SchemaType, ) -> PlannedRequestField<'a> { PlannedRequestField { name: name.into(), optional, - ty, + schema, kind: RequestFieldKind::Body, } } -pub(crate) fn nested_body(ty: &SchemaType, optional: bool) -> PlannedRequestBody<'_> { - PlannedRequestBody::Nested { ty, optional } +pub(crate) fn nested_body(schema: &SchemaType, optional: bool) -> PlannedRequestBody<'_> { + PlannedRequestBody::Nested { schema, optional } } /// A hoisted JSON body, `required` being the envelope's own flag. @@ -139,7 +139,7 @@ pub(crate) fn op_with<'a>( /// An operation carrying `errors` and nothing else. pub(crate) fn op_with_errors<'a>( operation_id: &str, - errors: &'a [crate::ir::canonical::ErrorResponse], + errors: &'a [crate::api_model::canonical::ErrorResponse], ) -> PlannedOperation<'a> { let method_name = MethodName::new(operation_id.to_string()); PlannedOperation { @@ -162,15 +162,15 @@ fn build_form_fields<'a>( ) -> Vec> { fields .into_iter() - .map(|(name, optional, ty)| PlannedFormField { - name: Ident::parse(name).expect("test form-field name is an identifier"), + .map(|(name, optional, field_type)| PlannedFormField { + name: Identifier::parse(name).expect("test form-field name is an identifier"), optional, - ty, + field_type, }) .collect() } -/// An operation whose body is a multipart form of `(name, optional, ty)` +/// An operation whose body is a multipart form of `(name, optional, schema)` /// fields. pub(crate) fn op_with_multipart_fields<'a>( fields: Vec<(&str, bool, &'a BodyFieldType)>, diff --git a/website/scripts/bundle-engine.ts b/website/scripts/bundle-engine.ts index abf80f3..9973647 100644 --- a/website/scripts/bundle-engine.ts +++ b/website/scripts/bundle-engine.ts @@ -49,22 +49,23 @@ fs.mkdirSync(outDir, { recursive: true }); const loaderName = 'openapi-ng.wasi-browser.js'; const loaderSource = fs.readFileSync(path.join(packageDir, loaderName), 'utf8'); -await build({ - ...shared, - stdin: { - contents: localiseWorkerUrl(loaderSource), - resolveDir: packageDir, - sourcefile: loaderName, - loader: 'js', - }, - outfile: path.join(outDir, loaderName), -}); - -await build({ - ...shared, - entryPoints: [path.join(packageDir, 'wasi-worker-browser.mjs')], - outfile: path.join(outDir, 'wasi-worker-browser.mjs'), -}); +await Promise.all([ + build({ + ...shared, + stdin: { + contents: localiseWorkerUrl(loaderSource), + resolveDir: packageDir, + sourcefile: loaderName, + loader: 'js', + }, + outfile: path.join(outDir, loaderName), + }), + build({ + ...shared, + entryPoints: [path.join(packageDir, 'wasi-worker-browser.mjs')], + outfile: path.join(outDir, 'wasi-worker-browser.mjs'), + }), +]); fs.copyFileSync( path.join(packageDir, 'openapi-ng.wasm32-wasi.wasm'), From 5c92860ded84dbdeefa4b1f3dd94da702e6f0dbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andrzej=20=C5=9Awi=C4=99tek?= Date: Wed, 9 Sep 2026 23:31:33 +0200 Subject: [PATCH 11/11] Shortened the doc comments to one sentence and dropped the stale phase references --- .../src/form-non-json-proof.ts | 16 ++-------- .../binary-field-rejects-string.ts | 11 +------ .../src/negative-proof/negative.ts | 12 ++----- .../validate-rejects-bad-debounce.ts | 12 ++----- .../validate-rejects-mismatched-request.ts | 23 ++------------ .../validate-rejects-mismatched-response.ts | 12 ++----- __test__/generate.spec.ts | 31 +++---------------- benchmark/bench.ts | 2 +- bin/lib/parse.js | 25 +++++---------- bin/openapi-ng.js | 18 ++++------- lib/diagnostic.js | 4 +-- lib/fetch-input.js | 22 ++++--------- lib/wrapper-core.js | 22 +++++-------- src/api_model/canonical.rs | 8 +---- src/api_model/normalize/mod.rs | 8 ++--- src/api_model/normalize/schema/map.rs | 9 ++---- src/api_model/normalize/schema/mod.rs | 9 ++---- src/api_model/normalize/semantic.rs | 11 +++---- src/api_model/normalize/walk.rs | 7 ++--- src/emit/angular/request.rs | 7 ----- src/emit/angular/service.rs | 11 ++----- src/emit/model/mod.rs | 8 ++--- src/emit/ts/literal.rs | 8 ++--- src/emit/ts/writer.rs | 7 ++--- src/error.rs | 28 +++++++---------- src/plan/naming/mod.rs | 8 ++--- src/plan/naming/template.rs | 9 ++---- 27 files changed, 84 insertions(+), 264 deletions(-) diff --git a/__test__/angular-consumer/src/form-non-json-proof.ts b/__test__/angular-consumer/src/form-non-json-proof.ts index 629f0f6..24b61be 100644 --- a/__test__/angular-consumer/src/form-non-json-proof.ts +++ b/__test__/angular-consumer/src/form-non-json-proof.ts @@ -1,17 +1,5 @@ -// Compile-time proofs for the request bodies and non-JSON responses -// surfaced by Phase 7. Lives next to service-proof.ts (the petstore-rich -// JSON proof) and compiles against a separate combined fixture -// (`consumer-forms-and-non-json.openapi.yaml`) generated into -// `__test__/angular-consumer/generated/` by the matching ava test. -// -// Each block asserts: -// 1. The request type accepts the right field shapes (Blob | File, -// number[], etc.). -// 2. `.observable(...)` and `.resource(...)` carry the right Response -// generic through to `Observable` / `HttpResourceRef<...>`. -// -// A regression that collapses any of these to `any` or rejects a valid -// call-site shape fails this file under `tsc --noEmit`. +// Compile-time proofs for form request bodies and non-JSON responses, +// against the fixture `consumer-forms-and-non-json.openapi.yaml`. import type { HttpResourceRef } from '@angular/common/http'; import type { Observable } from 'rxjs'; diff --git a/__test__/angular-consumer/src/negative-proof/binary-field-rejects-string.ts b/__test__/angular-consumer/src/negative-proof/binary-field-rejects-string.ts index dfb85ed..6530e43 100644 --- a/__test__/angular-consumer/src/negative-proof/binary-field-rejects-string.ts +++ b/__test__/angular-consumer/src/negative-proof/binary-field-rejects-string.ts @@ -1,13 +1,4 @@ -// This file is INTENDED TO FAIL TypeScript compilation. -// It exists so the test suite catches type-soundness regressions on the -// multipart form-body surface: the binary field's request-interface -// type MUST stay `Blob | File`, never widen to `string`/`any`. If a -// future change accidentally collapses the binary field type, this -// assignment would succeed and tsc would exit 0 — causing the -// negative-compile test to fail and alerting us. -// -// Expected error: TS2322 — `'string-not-blob'` (a literal string) is not -// assignable to `Blob | File`. +// Must not compile: a multipart binary field stays `Blob | File`. import type { UpdatePetAvatarParams } from '../../generated/rest/pet.rest.generated'; // Construct an UpdatePetAvatarParams whose `avatar` field is a string, diff --git a/__test__/angular-consumer/src/negative-proof/negative.ts b/__test__/angular-consumer/src/negative-proof/negative.ts index f20350b..dbe49a9 100644 --- a/__test__/angular-consumer/src/negative-proof/negative.ts +++ b/__test__/angular-consumer/src/negative-proof/negative.ts @@ -1,15 +1,7 @@ -// This file is INTENDED TO FAIL TypeScript compilation. -// It exists so the test suite catches type-soundness regressions -// (e.g. if a future change accidentally collapses a tagged union to `any`). -// -// Expected error: TS2322 — the `kind` literal type 'dog' is not assignable to -// 'cat', so assigning an object with `kind: 'dog'` to a Cat-typed slot fails. -// If the union ever degrades to `any`, this assignment would succeed and tsc -// would exit 0 — causing the negative-compile test to fail and alerting us. +// Must not compile: a discriminated union keeps its `kind` literal types. import type { Cat } from '../../generated/model.generated'; -// Construct an object whose `kind` discriminant is 'dog', not 'cat'. -// This is structurally compatible with Cat except for the literal type on `kind`. +// Structurally a Cat but for the `kind` literal. const dogKind = { kind: 'dog' as const, lives: 9 }; export const shouldFail: Cat = dogKind; diff --git a/__test__/angular-consumer/src/negative-proof/validate-rejects-bad-debounce.ts b/__test__/angular-consumer/src/negative-proof/validate-rejects-bad-debounce.ts index fe70e92..b6c1df5 100644 --- a/__test__/angular-consumer/src/negative-proof/validate-rejects-bad-debounce.ts +++ b/__test__/angular-consumer/src/negative-proof/validate-rejects-bad-debounce.ts @@ -1,13 +1,5 @@ -// This file is INTENDED TO FAIL TypeScript compilation. -// It exists so the test suite catches type-soundness regressions on the -// `debounce` option, which `RestValidatorOptions` inherits from Angular's own -// `AsyncValidatorOptions` via `Omit`. That indirection is what keeps the emitted -// template compiling on @angular/forms 21 (where the key is absent), but it -// would also silently swallow a bad value if the inherited type ever widened to -// `any`. A string is not a `DebounceTimer`, so tsc must reject it. -// -// Expected error: TS2322 — `string` is not assignable to -// `DebounceTimer` (i.e. `number` or a function). +// Must not compile: `debounce` keeps the `DebounceTimer` type it inherits +// from Angular's `AsyncValidatorOptions`. import { schema } from '@angular/forms/signals'; import type { PetRest } from '../../generated/rest/pet.rest.generated'; import { validateRest } from '../../generated/rest.validate'; diff --git a/__test__/angular-consumer/src/negative-proof/validate-rejects-mismatched-request.ts b/__test__/angular-consumer/src/negative-proof/validate-rejects-mismatched-request.ts index e56dde9..e6da663 100644 --- a/__test__/angular-consumer/src/negative-proof/validate-rejects-mismatched-request.ts +++ b/__test__/angular-consumer/src/negative-proof/validate-rejects-mismatched-request.ts @@ -1,30 +1,11 @@ -// This file is INTENDED TO FAIL TypeScript compilation. -// It exists so the test suite catches type-soundness regressions on the -// validateRest surface: the `request` callback's return type MUST stay -// pinned to the endpoint's Request shape (here, UpdatePetParams), never -// widen to `any`/`unknown`. If the typing widened, the -// `{ wrong: 'value' }` literal below would be accepted and tsc would -// exit 0 — causing the negative-compile test to fail and alerting us. -// -// Expected error: TS2322 — `{ wrong: string }` is not assignable to -// `UpdatePetParams | undefined`. -// -// Note: we let `TRequest` be inferred from `service.updatePet` (which -// pins it to `UpdatePetParams`) rather than supplying it explicitly, -// so the type conflict surfaces as a TS2322 assignability error on the -// `request` property of the option-bag — exactly the surface this -// proof is meant to lock down — instead of a TS2345 argument-type -// error on the `service.updatePet` position. +// Must not compile: `validateRest`'s `request` callback stays pinned to +// the endpoint's request shape. import { schema } from '@angular/forms/signals'; import type { PetRest } from '../../generated/rest/pet.rest.generated'; import { validateRest } from '../../generated/rest.validate'; declare const service: PetRest; -// The mismatched value is annotated with an unrelated interface so the -// failure becomes an unambiguous TS2322 assignability error (named-type -// vs named-type) rather than the more specialised TS2739 "missing -// properties from object literal" diagnostic. interface WrongRequest { wrong: string; } diff --git a/__test__/angular-consumer/src/negative-proof/validate-rejects-mismatched-response.ts b/__test__/angular-consumer/src/negative-proof/validate-rejects-mismatched-response.ts index 34bc027..e4e8688 100644 --- a/__test__/angular-consumer/src/negative-proof/validate-rejects-mismatched-response.ts +++ b/__test__/angular-consumer/src/negative-proof/validate-rejects-mismatched-response.ts @@ -1,13 +1,5 @@ -// This file is INTENDED TO FAIL TypeScript compilation. -// It exists so the test suite catches type-soundness regressions on the -// validateRest surface: the `onSuccess` callback's `result` parameter -// MUST be typed as the endpoint's Response (here, Pet), never widen to -// `any`/`unknown`. If `result` were widened, the `result.nonExistentField` -// access below would be accepted and tsc would exit 0 — causing the -// negative-compile test to fail and alerting us. -// -// Expected error: TS2339 — property 'nonExistentField' does not exist on -// type 'Pet'. +// Must not compile: `onSuccess`'s `result` stays typed as the endpoint's +// response. import { schema } from '@angular/forms/signals'; import type { PetRest, UpdatePetParams } from '../../generated/rest/pet.rest.generated'; import type { Pet } from '../../generated/model.generated.ts'; diff --git a/__test__/generate.spec.ts b/__test__/generate.spec.ts index a8baf82..6b2c61a 100644 --- a/__test__/generate.spec.ts +++ b/__test__/generate.spec.ts @@ -17,31 +17,10 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); // returns a short, machine-independent path in banners and diagnostics. const fixture = (name: string) => path.join('test', 'fixtures', name); -// ── angular-consumer/generated/ convention ────────────────────────────────── -// -// `__test__/angular-consumer/generated/` is the SHARED working directory -// for any test that needs to emit a real generator output and run `tsc` -// over it against the consumer's tsconfig (the per-purpose -// `tsconfig.*.json` files in that directory each `include` a subset of -// this generated tree). The directory is shared — not per-test — so -// the tsconfigs can stay declarative (each one names a stable -// directory; tests don't have to thread a temp path into a generated -// tsconfig file). -// -// Two contributor-facing rules follow from that: -// -// 1. Tests that emit into `generated/` MUST call -// `resetAngularConsumerGeneratedDir()` before generating, or -// leftover files from a previous test will be compiled too and -// surface as a confusing tsc diagnostic. Ava runs each test file -// serially by default but the order between tests in the same -// file is implementation-defined — never assume a clean state. -// -// 2. The snapshot-suite tsc gate writes into the sibling subtree -// `__test__/angular-consumer/__snapshot_compile__/` and cleans it on -// every run. The reset helper below wipes `generated/` whole, and -// AVA runs the two files concurrently, so a tree that must survive -// belongs in its own sibling directory with its own tsconfig. +// `angular-consumer/generated/` is shared by every test that emits and +// type-checks real output, so a test writing there must first call +// `resetAngularConsumerGeneratedDir()`, and a tree that must survive the +// wipe belongs in its own sibling directory with its own tsconfig. const angularConsumerGeneratedDir = path.join(__dirname, 'angular-consumer', 'generated'); function resetAngularConsumerGeneratedDir() { @@ -856,7 +835,7 @@ test.serial( }, ); -// Compile gate for Phase 7's request-body (multipart + urlencoded) and +// Compile gate for the request-body (multipart + urlencoded) and // non-JSON response (Blob / string / ArrayBuffer) surfaces. The proof // file (src/form-non-json-proof.ts) asserts call-site typing on each // service and pins the carrier type of `observable` / `resource` via diff --git a/benchmark/bench.ts b/benchmark/bench.ts index a9f6f96..a3dbf8a 100644 --- a/benchmark/bench.ts +++ b/benchmark/bench.ts @@ -121,7 +121,7 @@ bench.add('generate (petstore-minimal, yaml)', async () => { }); }); -// E11: large-spec benchmark — 30 paths × 2 ops = 60 operations and 90 schemas +// Large-spec benchmark: 30 paths × 2 ops = 60 operations and 90 schemas // (30 entities × {Resource, ResourceStatus, ResourceList}), grouped under // 6 tags (Resource1..Resource6, 10 ops each). Synthetic but shaped like a // real REST API; useful for catching phase-level regressions diff --git a/bin/lib/parse.js b/bin/lib/parse.js index 1d90cf2..52de4b1 100644 --- a/bin/lib/parse.js +++ b/bin/lib/parse.js @@ -1,5 +1,4 @@ -// Argument and config parsing for the CLI. The exported records mirror -// the NAPI `MappedType` shape one-to-one. +// Argument and config parsing for the CLI. const fs = require('node:fs'); const path = require('node:path'); @@ -57,11 +56,8 @@ const DEFAULT_EMIT = Object.freeze(['models', 'angular']); const VALID_INIT_FORMATS = Object.freeze(new Set(['yaml', 'json', 'ts', 'js'])); -// Rejects a flag or end-of-args in the value position: without this, -// `--config --input spec.yaml` consumes `--input` as -// the config path, leaving the user staring at a config-not-found error -// without ever seeing their `--input` argument honoured. Treat any token -// starting with `-` (long `--foo` or short `-f`) as a flag, never a value. +// Any token starting with `-` is a flag, never a value, so +// `--config --input spec.yaml` cannot swallow `--input`. /** * @param {readonly string[]} argv * @param {number} i Index of the flag itself. @@ -80,12 +76,9 @@ function requireValue(argv, i, flagName) { return value; } -// Normalize one user-supplied emit list (CLI comma-string or YAML -// array) into a deduped array of recognised targets. Unknown entries -// fail fast with a config-file hint. /** - * Normalises one emit list — a CLI comma-string or a config array — into - * a deduped array of recognised targets. `null` when nothing was given. + * Normalises a CLI comma-string or config array into a deduped array of + * recognised targets, `null` when nothing was given. * * @param {unknown} value * @returns {EmitTarget[] | null} @@ -422,12 +415,8 @@ function parseArgs(argv) { const [command, ...rest] = filteredArgv; - // Distinguish bare `openapi-ng` (no command + no global help flag) from - // explicit `--help`/`-h`. CI scripts like `openapi-ng generate ... && - // next-step` would silently run `next-step` if the `generate` argv got - // eaten; bare invocation is a usage error and must exit non-zero. We - // still print help so the user can recover — only the exit code differs. - // `explicit: false` is the signal for the caller to set `process.exitCode = 2`. + // `explicit: false` prints help but tells the caller to exit 2, so a + // bare invocation cannot pass for success in a CI script. if (!command) { return { kind: 'help', subcommand: null, explicit: false }; } diff --git a/bin/openapi-ng.js b/bin/openapi-ng.js index 8bc6c2a..c11b5f7 100755 --- a/bin/openapi-ng.js +++ b/bin/openapi-ng.js @@ -1,7 +1,7 @@ #!/usr/bin/env node -// Loaded inside the generate handler, not at module top: requiring the -// wrapper loads the native binding, which --help and --version must not. +// Loaded in the generate handler only: requiring the wrapper loads the +// native binding, which --help and --version must not. function loadLibrary() { return require('../lib/index.js'); } @@ -344,11 +344,8 @@ async function main(argv) { * @returns {string} */ function formatParseFailure(error) { - // Honour error.code when set (e.g. loadConfigFile tags ENOENT and - // YAML/JSON parse failures with E_INPUT_INVALID — those are user - // input problems, not CLI option-parsing problems). Fall back to - // E_INVALID_OPTION only when no code is set, which is the - // parseArgs-raised case for genuinely bad flags. + // A code the thrower set wins; `parseArgs` sets none, so a bad flag + // falls back to E_INVALID_OPTION. const declared = field(error, 'code'); const detail = field(error, 'message'); const code = typeof declared === 'string' ? declared : 'E_INVALID_OPTION'; @@ -383,11 +380,8 @@ function formatFailure(error) { return `${c.bold(c.red('Error'))} ${c.red('[E_UNEXPECTED]')}\n ${String(error)}`; } -// Defense-in-depth: surface any error escaping `main` (e.g. a future -// `await` added without a local try/catch) as a single human-readable -// stderr line and exit 1 — never the Node default "[UnhandledPromise -// Rejection]" multi-line stack dump. Today the inner paths all catch -// their own failures; this is the last-line guard. +// Anything escaping `main` prints one stderr line instead of Node's +// unhandled-rejection stack dump. main(process.argv.slice(2)).catch(err => { process.stderr.write(`openapi-ng: ${field(err, 'message') ?? err}\n`); process.exitCode = 1; diff --git a/lib/diagnostic.js b/lib/diagnostic.js index 4f84303..878c06c 100644 --- a/lib/diagnostic.js +++ b/lib/diagnostic.js @@ -1,11 +1,9 @@ 'use strict'; -// Errors carrying a diagnostic code, and field reads for caught values -// that may carry nothing at all. +// Errors carrying a diagnostic code, and field reads for caught values. /** * The value at `key`, or `undefined` when `value` holds no properties. - * A function counts as holding properties. * * @param {unknown} value * @param {string} key diff --git a/lib/fetch-input.js b/lib/fetch-input.js index 7b79aa8..1268873 100644 --- a/lib/fetch-input.js +++ b/lib/fetch-input.js @@ -5,7 +5,7 @@ const net = require('node:net'); const { field, inputError } = require('./diagnostic.js'); /** - * A spec fetched over https, and what its headers said about the format. + * A spec fetched over https, with the format its headers named. * * @typedef {object} FetchedInput * @property {string} contents @@ -19,8 +19,7 @@ const DEFAULT_MAX_BYTES = 16 * 1024 * 1024; const DEFAULT_TIMEOUT_MS = 30_000; const MAX_REDIRECTS = 5; -// Matches https and http alike: `fetchInput` turns the http case into a -// typed error. This decides only whether the input is a URL or a path. +// Whether the input is a URL or a path; `fetchInput` rejects http itself. /** * @param {unknown} value * @returns {boolean} @@ -101,14 +100,10 @@ function _resolveDnsLookup() { return (host, options) => dns.lookup(host, options); } -// Walk each hop's URL through the BlockList before letting `fetchImpl` -// see it. Re-checking inside the redirect loop closes the "redirect -// from a public host to 169.254.169.254" loophole — a CNAME or 302 -// chain that starts public but lands on metadata would otherwise slip -// past a one-shot check at entry. /** - * Fails when any address `urlStr`'s host resolves to is private, - * loopback or link-local. Re-checked per redirect hop. + * Fails when any address `urlStr`'s host resolves to is private, loopback + * or link-local, re-checked per redirect hop so a 302 or CNAME chain + * cannot land on metadata. * * @param {string} urlStr * @returns {Promise} @@ -193,13 +188,8 @@ function formatFromUrlPath(urlStr) { const FOLLOWED_STATUSES = new Set([301, 302, 303, 307, 308]); -// Test affordance: allow indirect callers (like `lib/index.js`) to route -// through `fetchInput` without surfacing `fetchImpl` as a public option. -// The setter mutates a module-level slot consulted by `_resolveFetchImpl`, -// which is the new default when `fetchImpl` is not passed explicitly. /** - * The subset of `fetch` this module calls. Narrower than the global on - * purpose: a test stub only has to accept what is actually passed. + * The subset of `fetch` this module calls, narrow enough for a test stub. * * @typedef {( * url: string, diff --git a/lib/wrapper-core.js b/lib/wrapper-core.js index 588d53a..c93ceee 100644 --- a/lib/wrapper-core.js +++ b/lib/wrapper-core.js @@ -17,8 +17,8 @@ const { field } = require('./diagnostic.js'); /** @typedef {import('../index.js').GenerateOptions} GenerateOptions */ /** - * A chain item as a caller may write it. Wider than the published - * `NamingRule`: `parse` also accepts an already-split `{ source, flags }`. + * A chain item as a caller may write it, whose `parse` also accepts an + * already-split `{ source, flags }`. * * @typedef {object} NamingEntryInput * @property {string} [from] @@ -30,17 +30,15 @@ const { field } = require('./diagnostic.js'); /** @typedef {string | NamingEntryInput} NamingItemInput */ /** - * Options after `prepareOptions`. `naming` is the lowered boundary shape - * once a caller supplied one, and the declared shape stays in the union - * for the absent case. + * Options after `prepareOptions`, whose `naming` is lowered to the + * boundary shape. * * @typedef {Omit & { * naming?: import('../index.js').NamingOptions | import('../index.js').NamingConfig * }} PreparedOptions */ -// Recognised option keys, which the native binding would otherwise -// ignore silently. Mirrors `GenerateOptions` in `src/bindings.rs`. +// Recognised option keys, mirroring `GenerateOptions` in `src/bindings.rs`. /** @type {ReadonlySet} */ const GENERATE_OPTION_KEYS = Object.freeze( new Set([ @@ -56,8 +54,7 @@ const GENERATE_OPTION_KEYS = Object.freeze( ]), ); -// Recognised `EmitTarget` values. The CLI validates its own copy in -// `bin/lib/parse.js`; both mirror `EmitTarget` in `index.d.ts`. +// Recognised `EmitTarget` values, mirroring `index.d.ts`. /** @type {ReadonlySet} */ const VALID_EMIT = Object.freeze(new Set(['models', 'angular'])); @@ -81,8 +78,6 @@ const DEFAULT_EMIT = Object.freeze(['models', 'angular']); * Lowers one chain item into the exclusive `{ string }` or `{ rule }` * shape the boundary carries. * - * The runtime guards stay for a JS caller who ignores the declared type. - * * @param {NamingItemInput} entry * @param {string} path Config path, for the failure message. * @returns {import('../index.js').NamingChainItem} @@ -358,9 +353,8 @@ function normalizeNaming(options) { } /** - * Normalises and validates the options both entries pass to the binding: - * applies the CLI-parity `emit` default, rewrites an `https` input into - * `inputContents`, and runs the shape validator. + * Applies the `emit` default, rewrites an `https` input into + * `inputContents`, and validates the shape. * * @param {GenerateOptions} options * @param {(url: string) => Promise} fetchInputFn diff --git a/src/api_model/canonical.rs b/src/api_model/canonical.rs index d683173..bdbeaa1 100644 --- a/src/api_model/canonical.rs +++ b/src/api_model/canonical.rs @@ -1,13 +1,7 @@ use crate::api_model::schema::{SchemaScalar, SchemaType}; use crate::identifier::Identifier; -/// A named, top-level schema declaration. -/// -/// Interface, enum and alias shapes share one `body` carrier: -/// -/// * `SchemaType::InlineObject { properties }` → `export interface X { … }` -/// * `SchemaType::StringLiterals { values }` → `export type X = 'a' | 'b'` -/// * any other variant → `export type X = …` +/// A named, top-level schema declaration, whose shape `body` carries. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct ModelSymbol { pub(crate) name: Box, diff --git a/src/api_model/normalize/mod.rs b/src/api_model/normalize/mod.rs index f789595..a6f6fae 100644 --- a/src/api_model/normalize/mod.rs +++ b/src/api_model/normalize/mod.rs @@ -16,12 +16,8 @@ use operations::normalize_operations; use schema::normalize_schemas; pub(crate) use walk::SchemaWalk; -/// Hard cap on `Schema` nesting, enforced by [`SchemaWalk::check_depth`]. -/// -/// Real specs nest a handful of levels — the deepest committed fixture is -/// 5 layers of `allOf` — and the cap sits below serde's own recursion -/// limit of roughly 60, so a spec that reaches it is an unsupported shape -/// and not a parser-rejected one. +/// Hard cap on `Schema` nesting, enforced by [`SchemaWalk::check_depth`] +/// and set below serde's own recursion limit. pub(crate) const MAX_NORMALIZE_DEPTH: u16 = 32; pub(crate) fn normalize_api_model( diff --git a/src/api_model/normalize/schema/map.rs b/src/api_model/normalize/schema/map.rs index fa8e666..110f764 100644 --- a/src/api_model/normalize/schema/map.rs +++ b/src/api_model/normalize/schema/map.rs @@ -7,12 +7,9 @@ use crate::parse::openapi_model::{AdditionalProperties, Schema}; use super::super::{SchemaWalk, bail_unsupported_rule}; use super::normalize_schema; -/// Lowers a schema whose `additionalProperties` constrains emission into -/// [`SchemaType::Map`]. -/// -/// The supported shape is `additionalProperties` alone. Combining it with -/// `properties`, `required`, `$ref`, a composition keyword or a non-object -/// `type` fails, naming the rule it broke. +/// Lowers a bare `additionalProperties` into [`SchemaType::Map`], failing +/// when it is combined with `properties`, `required`, `$ref`, a +/// composition keyword or a non-object `type`. pub(super) fn normalize_additional_properties( schema: &Schema, additional: &AdditionalProperties, diff --git a/src/api_model/normalize/schema/mod.rs b/src/api_model/normalize/schema/mod.rs index ec9c069..5604db2 100644 --- a/src/api_model/normalize/schema/mod.rs +++ b/src/api_model/normalize/schema/mod.rs @@ -1,9 +1,6 @@ -//! OpenAPI schema → canonical `SchemaType`. -//! -//! Entry points are [`normalize_schemas`] for `components.schemas` and -//! [`normalize_schema`] / [`normalize_properties`] for the schemas embedded -//! in operations. Discriminator narrowing is not done here — it runs in -//! [`super::semantic`] once operation lowering has finished. +//! OpenAPI schema to canonical `SchemaType`, entered through +//! [`normalize_schemas`], [`normalize_schema`] and +//! [`normalize_properties`]. mod composition; mod enums; diff --git a/src/api_model/normalize/semantic.rs b/src/api_model/normalize/semantic.rs index 40827f1..e342dc8 100644 --- a/src/api_model/normalize/semantic.rs +++ b/src/api_model/normalize/semantic.rs @@ -24,13 +24,10 @@ pub(super) fn finalize(model: &mut ApiModel, reporter: &Reporter) -> Result<(), } /// Narrows each discriminated union member's discriminator property to a -/// single-value string literal. -/// -/// Fails with `missing-discriminator-property` when a member does not -/// declare the property, and `discriminator-property-must-be-string` when -/// it declares it with a non-string type. -/// The literal each discriminated member narrows its property to, keyed by -/// member schema name then property name. +/// single-value string literal, failing with +/// `missing-discriminator-property` or +/// `discriminator-property-must-be-string`. +/// Member schema name, then property name, to the literal it narrows to. type Narrowings = BTreeMap, BTreeMap, Box>>; fn narrow_discriminator_properties( diff --git a/src/api_model/normalize/walk.rs b/src/api_model/normalize/walk.rs index 36508c0..187e3bd 100644 --- a/src/api_model/normalize/walk.rs +++ b/src/api_model/normalize/walk.rs @@ -5,10 +5,9 @@ use crate::error::{Context, Diagnostic, Reporter}; use super::{MAX_NORMALIZE_DEPTH, unsupported_rule}; -/// One position in a schema tree. -/// -/// Every constructor except [`SchemaWalk::root`] descends exactly one -/// level. Call [`SchemaWalk::check_depth`] before recursing. +/// One position in a schema tree; every constructor but +/// [`SchemaWalk::root`] descends a level. Call +/// [`SchemaWalk::check_depth`] before recursing. #[derive(Clone, Copy)] pub(crate) struct SchemaWalk<'a> { context: Context<'a>, diff --git a/src/emit/angular/request.rs b/src/emit/angular/request.rs index 80f6147..65a38cc 100644 --- a/src/emit/angular/request.rs +++ b/src/emit/angular/request.rs @@ -135,13 +135,6 @@ fn body_members<'a>(body: Option<&'a PlannedRequestBody<'a>>) -> impl Iterator, diff --git a/src/emit/angular/service.rs b/src/emit/angular/service.rs index 18a50ba..1603fe6 100644 --- a/src/emit/angular/service.rs +++ b/src/emit/angular/service.rs @@ -70,15 +70,8 @@ fn render_operation_property(buffer: &mut Writer, operation: &PlannedOperation<' buffer.line(");"); } -/// Writes the helper call prefix. The operation's arity (does it take a -/// typed request?) and its response variant pick one of four call shapes: -/// -/// | | Requestful | Zero-arg | -/// |----------------|-----------------------------------|--------------------------------------| -/// | JSON / void | `requestFactory` | `requestFactory.zeroArg` | -/// | Blob | `requestFactory.blob` | `requestFactory.zeroArg.blob` | -/// | Text | `requestFactory.text` | `requestFactory.zeroArg.text` | -/// | ArrayBuffer | `requestFactory.arrayBuffer` | `requestFactory.zeroArg.arrayBuffer` | +/// Writes the `requestFactory` call prefix the operation's arity and +/// response variant select. fn write_response_call_site( buffer: &mut Writer, response: Option<&ResponseContent>, diff --git a/src/emit/model/mod.rs b/src/emit/model/mod.rs index 3749651..a1cb119 100644 --- a/src/emit/model/mod.rs +++ b/src/emit/model/mod.rs @@ -100,12 +100,8 @@ mod tests { #[test] fn emit_model_uses_reexport_for_self_alias_to_avoid_identifier_collision() { - // schema=UserId, type=ExternalUserId, alias=UserId — the binding - // would otherwise be both imported as `UserId` AND aliased to - // `UserId` in the same file (`export type UserId = UserId;`), a - // duplicate-identifier error. The emitter sidesteps this by - // collapsing to a single `export type { ExternalUserId as UserId } - // from './shared/user-id';` re-export. + // schema=UserId, type=ExternalUserId, alias=UserId collapses to one + // re-export rather than a duplicate `export type UserId = UserId;`. let model_symbols = vec![ModelSymbol { name: "UserId".into(), description: None, diff --git a/src/emit/ts/literal.rs b/src/emit/ts/literal.rs index a03ed64..4d81263 100644 --- a/src/emit/ts/literal.rs +++ b/src/emit/ts/literal.rs @@ -35,12 +35,8 @@ pub(crate) fn quoted(value: &str) -> String { out } -/// Quotes `name` when it is not a bare identifier. -/// -/// Reserved words such as `class` or `default` are legal in property -/// position — an interface member is a `PropertyName`, which accepts any -/// `IdentifierName` — so only names outside the -/// `[A-Za-z_$][A-Za-z0-9_$]*` shape get quoted. +/// Quotes `name` when it falls outside `[A-Za-z_$][A-Za-z0-9_$]*`, which +/// leaves a reserved word like `class` unquoted in property position. pub(crate) fn safe_property_name(name: &str) -> Cow<'_, str> { if is_identifier(name) { Cow::Borrowed(name) diff --git a/src/emit/ts/writer.rs b/src/emit/ts/writer.rs index bbfcba8..54f9c1a 100644 --- a/src/emit/ts/writer.rs +++ b/src/emit/ts/writer.rs @@ -1,10 +1,7 @@ //! The output buffer every emitter writes through. -/// Indent-aware string writer. -/// -/// Tracks line-start state, so consecutive [`Writer::push`] calls share one -/// indent prefix without the caller threading it. Every method is -/// infallible: the sink is an in-memory `String`. +/// Indent-aware string writer; consecutive [`Writer::push`] calls share +/// one indent prefix. #[derive(Debug, Default)] pub(crate) struct Writer { buf: String, diff --git a/src/error.rs b/src/error.rs index b12f2ea..3f6065b 100644 --- a/src/error.rs +++ b/src/error.rs @@ -7,25 +7,23 @@ use serde::Serialize; const SEVERITY_WARNING: &str = "warning"; const SEVERITY_ERROR: &str = "error"; -/// Every code a fatal or a warning can carry: -/// -/// * `InputInvalid` — read or decode failed (`E_INPUT_INVALID`). -/// * `UnsupportedSemantic` — accepted spec uses a shape outside the supported -/// subset (`E_UNSUPPORTED_SEMANTIC`). -/// * `InvalidReference` — `$ref` does not resolve (`E_INVALID_REFERENCE`). -/// * `InvalidOption` — caller-supplied option is invalid (`E_INVALID_OPTION`). -/// * `PolicyViolation` — IR-level rule (missing tag, missing operationId, -/// request-field collision, planner refusal) (`E_POLICY_VIOLATION`). -/// * `WriteFailed` — output file write failed (`E_WRITE_FAILED`). -/// * `Unexpected` — a panic crossed the NAPI boundary (`E_UNEXPECTED`). +/// Every code a fatal or a warning can carry. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum DiagnosticCode { + /// Reading or decoding the input failed. InputInvalid, + /// An accepted spec uses a shape outside the supported subset. UnsupportedSemantic, + /// A `$ref` does not resolve. InvalidReference, + /// A caller-supplied option is invalid. InvalidOption, + /// A missing tag or operationId, a request-field collision, or a + /// planner refusal. PolicyViolation, + /// Writing an output file failed. WriteFailed, + /// A panic crossed the NAPI boundary. Unexpected, } @@ -43,12 +41,8 @@ impl DiagnosticCode { } } -/// One diagnostic. Severity is implicit: a fatal travels as `Err`, a -/// warning through [`Reporter::warning`]. -/// -/// `message` leads with a stage-gerund subject ("Failed to decode -/// input"), then the detail, then advice when there is any. `subcode` -/// is set for `PolicyViolation`. +/// One diagnostic; a fatal travels as `Err`, a warning through +/// [`Reporter::warning`]. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Diagnostic { pub code: DiagnosticCode, diff --git a/src/plan/naming/mod.rs b/src/plan/naming/mod.rs index ce8173b..90e77dc 100644 --- a/src/plan/naming/mod.rs +++ b/src/plan/naming/mod.rs @@ -1,9 +1,5 @@ -//! Derives each operation's `methodName` and `group`, and formats the -//! type names built from them. -//! -//! [`fixed`] holds the formatting the project fixes; every other submodule -//! belongs to the caller-configurable rule engine, whose entry point is -//! [`NamingResolver`]. +//! Derives each operation's `methodName` and `group` through +//! [`NamingResolver`], with the project-fixed formatting in [`fixed`]. mod case; mod config; diff --git a/src/plan/naming/template.rs b/src/plan/naming/template.rs index e247874..677f560 100644 --- a/src/plan/naming/template.rs +++ b/src/plan/naming/template.rs @@ -1,10 +1,5 @@ -//! Template expander for `Rule.from` and `Rule.format`. -//! -//! Three productions, and nothing else: -//! * `{fieldName}` — a context field -//! * `{arrayField[N]}` — an array element, negative indexes counting from -//! the tail -//! * `{capture.name}` — a named capture from the rule's `parse` +//! Expands `{fieldName}`, `{arrayField[N]}` and `{capture.name}` in +//! `Rule.from` and `Rule.format`, and nothing else. use std::collections::HashMap;