Skip to main content

taskfast_codegen/
lib.rs

1// SPDX-License-Identifier: MIT
2// TODO: doc the public surface; taskfast-codegen is published but internal-priority so keep this deferred.
3#![allow(missing_docs, clippy::doc_markdown)]
4
5//! TaskFast OpenAPI spec tooling.
6//!
7//! The authoritative spec lives at `spec/openapi.yaml`. The platform team
8//! evolves it against Elixir routes; clients generate from it. A small set of
9//! structural redundancies would cause progenitor to emit distinct Rust types
10//! for schemas that are semantically identical (e.g. multiple `{error, message}`
11//! objects under different names). This crate's [`normalize_spec`] folds those
12//! aliases into a single canonical shape *in memory*, leaving the on-disk spec
13//! untouched.
14//!
15//! Consumers:
16//!  - `cargo xtask sync-spec` (binary) — writes a normalized artifact for inspection.
17//!  - `taskfast-client/build.rs` — pipes the normalized YAML into progenitor.
18//!
19//! ## Rewrite rules
20//!
21//! Today: every name in [`ERROR_ALIASES`] is a structural clone of
22//! `#/components/schemas/Error`. The normalizer:
23//!   1. Asserts each alias is byte-for-byte structurally equal to `Error`
24//!      (ignoring `description`/`example` doc-only fields) — drift fails loud.
25//!   2. Rewrites every `$ref: '#/components/schemas/<alias>'` to point at
26//!      `#/components/schemas/Error`.
27//!   3. Removes the alias definitions from `components.schemas`.
28//!
29//! Adding a new alias ⇒ append to [`ERROR_ALIASES`]. If a schema grows a real
30//! distinguishing field, remove it from [`ERROR_ALIASES`]; the drift check
31//! will refuse to normalize otherwise.
32//!
33//! ## Multipart strip
34//!
35//! progenitor 0.9 can't codegen `multipart/form-data` request bodies. Rather
36//! than maintain a patched fork, the normalizer drops operations whose request
37//! body declares only multipart variants — today that is
38//! `POST /tasks/{task_id}/artifacts` (uploadArtifact). The upload path is
39//! hand-rolled in `taskfast-client` using `reqwest::multipart` directly; the
40//! Artifact *response* schema still comes through codegen because it's
41//! referenced by the surviving `GET /tasks/{task_id}/artifacts` operation.
42//! The removed operationIds are reported via [`Report::stripped_operations`]
43//! so callers can verify nothing unexpected disappeared.
44//!
45//! ## Multi-media-type request collapse
46//!
47//! progenitor 0.x panics (`more media types than expected`) when a request
48//! body declares more than one `content` media type. The OAuth endpoints
49//! (`POST /oauth2/token`, `POST /oauth2/revoke`) accept both
50//! `application/json` and `application/x-www-form-urlencoded`, each pointing
51//! at the *same* request schema. The normalizer collapses such bodies to a
52//! single preferred media type (`application/json` when present, else the
53//! first declared), which is lossless here because the variants share a
54//! schema. Runs *after* the multipart strip so multipart-bearing operations
55//! are already gone and never get collapsed instead of removed. Count surfaced
56//! via [`Report::request_media_collapsed`].
57//!
58//! ## Error response strip
59//!
60//! progenitor 0.9 asserts `response_types.len() <= 1` for both the success
61//! and error response sets (see `progenitor-impl/src/method.rs` —
62//! `extract_responses`). Nearly every TaskFast operation declares 2–5
63//! distinct error response shapes (`Error`, `ValidationError`, `ClaimFailure`,
64//! etc.), which trips the error-side assertion. Rather than synthesize a
65//! union error type that we'd throw away anyway, the normalizer drops every
66//! non-2xx (and non-`default`) response before handing the spec to progenitor.
67//! Surfacing of 4xx/5xx failures is the job of `taskfast-client::errors::Error`,
68//! which reads the response body manually on the way up.
69//!
70//! This means the generated client sees every unhappy status as
71//! `progenitor_client::Error::UnexpectedResponse(reqwest::Response)` — we
72//! re-classify into our typed `taskfast_client::errors::Error` in the calling layer.
73//!
74//! ## Closed-object strip
75//!
76//! Every `additionalProperties: false` is removed so generated types ignore
77//! unknown keys — an additive server change must not break installed CLIs
78//! (gh#172).
79
80use anyhow::{anyhow, bail, Context, Result};
81use serde_yaml::{Mapping, Value};
82
83/// Schemas known to be structural clones of `#/components/schemas/Error`.
84///
85/// Empty since the server's `Error` schema grew optional envelope fields
86/// (`details`, `status`, `fallback`, `errors`) that the minimal
87/// `WalletBalanceNotFoundError` / `WebhookNoWebhookError` shapes don't carry.
88/// Those two are still `{error, message}`-only and are referenced solely from
89/// non-2xx responses, which the error-response strip pass removes before
90/// progenitor — so they no longer need folding. Re-add a name here if a future
91/// schema is a byte-for-byte structural clone of `Error`.
92pub const ERROR_ALIASES: &[&str] = &[];
93
94const NULL_VARIANT_SCHEMA_KEYS: &[&str] = &["oneOf", "anyOf"];
95
96/// Normalize an OpenAPI YAML document: collapse [`ERROR_ALIASES`] into `Error`.
97///
98/// Returns the rewritten YAML as a string. Idempotent: running it on already
99/// normalized output is a no-op (aliases are absent, so nothing rewrites).
100pub fn normalize_spec(yaml: &str) -> Result<String> {
101    let (out, report) = normalize_with_aliases(yaml, ERROR_ALIASES)?;
102    tracing_nothing(&report); // suppress unused_variable warning when no tracing is wired
103    Ok(out)
104}
105
106/// As [`normalize_spec`] but also returns a summary of what changed.
107pub fn normalize_spec_with_report(yaml: &str) -> Result<(String, Report)> {
108    normalize_with_aliases(yaml, ERROR_ALIASES)
109}
110
111/// Core of [`normalize_spec`], parameterized on the alias list. Production
112/// callers pass [`ERROR_ALIASES`] (currently empty); the unit tests pass an
113/// explicit list so the fold and drift-check passes stay covered regardless of
114/// what the live spec needs today.
115fn normalize_with_aliases(yaml: &str, aliases: &[&str]) -> Result<(String, Report)> {
116    let mut doc: Value = serde_yaml::from_str(yaml).context("parse spec YAML")?;
117    let report = normalize_in_place(&mut doc, aliases)?;
118    let out = serde_yaml::to_string(&doc).context("re-serialize normalized spec")?;
119    Ok((out, report))
120}
121
122#[derive(Debug, Default, Clone)]
123pub struct Report {
124    /// Aliases that were folded into `Error`. Populated only for aliases that
125    /// were actually present in the input — missing aliases are silently skipped.
126    pub folded_aliases: Vec<String>,
127    /// Count of `$ref` rewrites performed across the document.
128    pub refs_rewritten: usize,
129    /// `operationId`s stripped because their only request-body variant was
130    /// multipart/form-data (progenitor 0.9 limitation).
131    pub stripped_operations: Vec<String>,
132    /// Count of non-2xx response entries removed across all operations
133    /// (progenitor 0.9 `response_types.len() <= 1` assertion).
134    pub error_responses_stripped: usize,
135    /// Count of `{type: "null"}` variants stripped from `oneOf` / `anyOf`
136    /// arrays anywhere in the document (progenitor 0.9 doesn't support
137    /// the OpenAPI 3.1 nullable-via-null-type pattern).
138    pub null_variants_stripped: usize,
139    /// Count of request bodies whose multiple `content` media types were
140    /// collapsed to a single preferred type (progenitor 0.x can't pick among
141    /// `>1` request media types — "more media types than expected").
142    pub request_media_collapsed: usize,
143}
144
145fn normalize_in_place(doc: &mut Value, aliases: &[&str]) -> Result<Report> {
146    let mut report = Report::default();
147
148    let schemas_path = ["components", "schemas"];
149    let canonical_shape = {
150        let error = get_mapping_path(doc, &schemas_path)
151            .and_then(|m| m.get(Value::from("Error")))
152            .ok_or_else(|| anyhow!("canonical schema `Error` not found in components.schemas"))?;
153        structural_shape(error)
154    };
155
156    // Pass 1: drift-check every alias that is present.
157    let present_aliases: Vec<String> = {
158        let schemas = get_mapping_path(doc, &schemas_path)
159            .ok_or_else(|| anyhow!("components.schemas not a mapping"))?;
160        aliases
161            .iter()
162            .copied()
163            .filter(|a| schemas.contains_key(Value::from(*a)))
164            .map(str::to_owned)
165            .collect()
166    };
167
168    for alias in &present_aliases {
169        let schema = get_mapping_path(doc, &schemas_path)
170            .and_then(|m| m.get(Value::from(alias.as_str())))
171            .expect("alias presence confirmed above");
172        let alias_shape = structural_shape(schema);
173        if alias_shape != canonical_shape {
174            bail!(
175                "alias `{alias}` has drifted from canonical `Error` shape — \
176                 either restore structural equality or remove `{alias}` from ERROR_ALIASES.\n\
177                 canonical: {:#?}\nalias:     {:#?}",
178                canonical_shape,
179                alias_shape
180            );
181        }
182    }
183
184    // Pass 2: rewrite every $ref pointing at an alias.
185    for alias in &present_aliases {
186        let from = format!("#/components/schemas/{alias}");
187        let to = "#/components/schemas/Error";
188        report.refs_rewritten += rewrite_refs(doc, &from, to);
189    }
190
191    // Pass 3: drop alias definitions from components.schemas.
192    if let Some(schemas) = get_mapping_path_mut(doc, &schemas_path) {
193        for alias in &present_aliases {
194            schemas.remove(Value::from(alias.as_str()));
195        }
196    }
197    report.folded_aliases = present_aliases;
198
199    // Pass 4: strip multipart-only operations (progenitor 0.9 limitation).
200    report.stripped_operations = strip_multipart_only_operations(doc);
201
202    // Pass 4b: collapse request bodies with >1 media type to a single
203    // preferred type. Must run after the multipart strip so multipart ops are
204    // already removed rather than collapsed onto their JSON variant.
205    report.request_media_collapsed = collapse_multi_media_request_bodies(doc);
206
207    // Pass 5: strip non-2xx responses from every surviving operation
208    // (progenitor 0.9 extract_responses assertion, error side).
209    report.error_responses_stripped = strip_non_success_responses(doc);
210
211    // Pass 6: strip OpenAPI 3.1 `{type: "null"}` variants from oneOf/anyOf
212    // arrays. Progenitor 0.9 panics on this nullable pattern; the caller's
213    // hand-rolled JSON-RPC forwarder uses serde_json::Value and doesn't
214    // need a generated type for the null branch.
215    report.null_variants_stripped = strip_null_type_variants(doc);
216
217    // Pass 7: open closed objects (gh#172). See [`strip_closed_objects`].
218    strip_closed_objects(doc);
219
220    Ok(report)
221}
222
223/// Recursively remove every `additionalProperties: false`.
224///
225/// typify turns that keyword into `#[serde(deny_unknown_fields)]`, so a single
226/// field the server adds to a response would fail decode in every released
227/// CLI. Stripping it everywhere (not just responses) is safe: the attribute
228/// only affects deserialization, and the CLI only ever serializes request
229/// bodies. Non-`false` values (`true`, a schema) are left alone — they change
230/// the generated type shape, not just strictness.
231fn strip_closed_objects(v: &mut Value) {
232    match v {
233        Value::Mapping(map) => {
234            if map.get("additionalProperties") == Some(&Value::Bool(false)) {
235                map.remove("additionalProperties");
236            }
237            for (_, child) in map.iter_mut() {
238                strip_closed_objects(child);
239            }
240        }
241        Value::Sequence(seq) => seq.iter_mut().for_each(strip_closed_objects),
242        _ => {}
243    }
244}
245
246/// Recursively walk `doc` and drop any `{type: "null"}` entries from `oneOf`
247/// and `anyOf` arrays. Returns the total count stripped.
248///
249/// A schema is considered a null-type variant when its `type` field equals
250/// the string `"null"` — sibling keys like `description` are tolerated so
251/// codegen tools that re-emit the spec with extra annotations still match.
252///
253/// If stripping leaves an empty `oneOf`/`anyOf`, the keyword is removed
254/// outright: an empty schema array is invalid OpenAPI and would trip the
255/// downstream generator harder than the original null-variant did.
256fn strip_null_type_variants(doc: &mut Value) -> usize {
257    fn is_null_type_schema(v: &Value) -> bool {
258        v.as_mapping()
259            .and_then(|m| m.get("type"))
260            .and_then(Value::as_str)
261            == Some("null")
262    }
263    fn walk(v: &mut Value, count: &mut usize) {
264        match v {
265            Value::Mapping(map) => {
266                for key in NULL_VARIANT_SCHEMA_KEYS {
267                    let Some(seq) = map.get_mut(*key).and_then(Value::as_sequence_mut) else {
268                        continue;
269                    };
270                    let before = seq.len();
271                    seq.retain(|item| !is_null_type_schema(item));
272                    *count += before - seq.len();
273                    if before > 0 && seq.is_empty() {
274                        map.remove(*key);
275                    }
276                }
277                for (_, child) in map.iter_mut() {
278                    walk(child, count);
279                }
280            }
281            Value::Sequence(seq) => {
282                for child in seq.iter_mut() {
283                    walk(child, count);
284                }
285            }
286            _ => {}
287        }
288    }
289    let mut count = 0;
290    walk(doc, &mut count);
291    count
292}
293
294/// Remove operations whose request body has exactly one content variant,
295/// `multipart/form-data`. Returns the removed `operationId`s (stable order).
296///
297/// We don't touch operations that declare multipart *alongside* another
298/// variant — those are handled by a preferred-content-type collapse pass that
299/// doesn't exist yet; today no such operations exist in the TaskFast spec.
300fn strip_multipart_only_operations(doc: &mut Value) -> Vec<String> {
301    let mut stripped = Vec::new();
302    let Some(paths) = doc
303        .as_mapping_mut()
304        .and_then(|m| m.get_mut(Value::from("paths")))
305        .and_then(Value::as_mapping_mut)
306    else {
307        return stripped;
308    };
309
310    const HTTP_VERBS: &[&str] = &["get", "put", "post", "delete", "patch", "options", "head"];
311    for (_path_key, path_item) in paths.iter_mut() {
312        let Some(ops) = path_item.as_mapping_mut() else {
313            continue;
314        };
315        let mut to_remove = Vec::new();
316        for verb in HTTP_VERBS {
317            if let Some(op) = ops.get(Value::from(*verb)) {
318                if operation_is_multipart_only(op) {
319                    let op_id = op
320                        .get(Value::from("operationId"))
321                        .and_then(Value::as_str)
322                        .unwrap_or("<anonymous>")
323                        .to_string();
324                    to_remove.push(*verb);
325                    stripped.push(op_id);
326                }
327            }
328        }
329        for verb in to_remove {
330            ops.remove(Value::from(verb));
331        }
332    }
333    stripped
334}
335
336/// Collapse any `requestBody.content` mapping with more than one media type
337/// down to a single preferred entry: `application/json` if present, otherwise
338/// the first declared media type (insertion order preserved). Returns the
339/// number of request bodies collapsed.
340///
341/// progenitor 0.x cannot pick among multiple request media types and panics
342/// with `more media types than expected`. Today the only affected operations
343/// are the OAuth endpoints, whose JSON and form-urlencoded variants reference
344/// the same schema, so the collapse is lossless. Multipart-bearing operations
345/// must already be stripped before this runs (see [`strip_multipart_only_operations`]).
346fn collapse_multi_media_request_bodies(doc: &mut Value) -> usize {
347    let mut collapsed = 0usize;
348    let Some(paths) = doc
349        .as_mapping_mut()
350        .and_then(|m| m.get_mut(Value::from("paths")))
351        .and_then(Value::as_mapping_mut)
352    else {
353        return 0;
354    };
355
356    const HTTP_VERBS: &[&str] = &["get", "put", "post", "delete", "patch", "options", "head"];
357    for (_path_key, path_item) in paths.iter_mut() {
358        let Some(ops) = path_item.as_mapping_mut() else {
359            continue;
360        };
361        for verb in HTTP_VERBS {
362            let Some(content) = ops
363                .get_mut(Value::from(*verb))
364                .and_then(Value::as_mapping_mut)
365                .and_then(|op| op.get_mut(Value::from("requestBody")))
366                .and_then(Value::as_mapping_mut)
367                .and_then(|body| body.get_mut(Value::from("content")))
368                .and_then(Value::as_mapping_mut)
369            else {
370                continue;
371            };
372            if content.len() <= 1 {
373                continue;
374            }
375            let keep = if content.contains_key(Value::from("application/json")) {
376                "application/json".to_string()
377            } else {
378                content
379                    .keys()
380                    .next()
381                    .and_then(Value::as_str)
382                    .map(str::to_owned)
383                    .expect("content has >1 entry")
384            };
385            content.retain(|k, _| k.as_str() == Some(keep.as_str()));
386            collapsed += 1;
387        }
388    }
389    collapsed
390}
391
392/// For each operation under `paths.*.<verb>.responses`, remove every entry
393/// whose key is not a 2xx status code or the literal `default`. Returns the
394/// total count of removed entries across the document.
395///
396/// Rationale: progenitor 0.9 asserts that both the success and error response
397/// sets have at most one distinct body type. TaskFast endpoints declare
398/// multiple distinct error shapes (`Error`, `ValidationError`, feature-specific
399/// failure envelopes). Surfacing those as typed variants in the generated
400/// client is not the generator's job here — `taskfast-client::errors::Error`
401/// reconstructs error semantics from the raw response body. Dropping non-2xx
402/// response definitions short-circuits the assertion without hiding anything
403/// the client layer doesn't already re-implement.
404fn strip_non_success_responses(doc: &mut Value) -> usize {
405    let mut removed = 0usize;
406    let Some(paths) = doc
407        .as_mapping_mut()
408        .and_then(|m| m.get_mut(Value::from("paths")))
409        .and_then(Value::as_mapping_mut)
410    else {
411        return 0;
412    };
413
414    const HTTP_VERBS: &[&str] = &["get", "put", "post", "delete", "patch", "options", "head"];
415    for (_path_key, path_item) in paths.iter_mut() {
416        let Some(ops) = path_item.as_mapping_mut() else {
417            continue;
418        };
419        for verb in HTTP_VERBS {
420            let Some(op) = ops.get_mut(Value::from(*verb)) else {
421                continue;
422            };
423            let Some(responses) = op
424                .as_mapping_mut()
425                .and_then(|m| m.get_mut(Value::from("responses")))
426                .and_then(Value::as_mapping_mut)
427            else {
428                continue;
429            };
430
431            let to_remove: Vec<Value> = responses
432                .iter()
433                .filter_map(|(k, _)| {
434                    let key = k.as_str()?;
435                    if is_success_or_default(key) {
436                        None
437                    } else {
438                        Some(k.clone())
439                    }
440                })
441                .collect();
442            for k in to_remove {
443                if responses.remove(&k).is_some() {
444                    removed += 1;
445                }
446            }
447        }
448    }
449    removed
450}
451
452fn is_success_or_default(code: &str) -> bool {
453    if code == "default" {
454        return true;
455    }
456    // 2XX ranges (e.g. "2XX") and explicit codes 200..=299.
457    match code.chars().next() {
458        Some('2') => true,
459        _ => false,
460    }
461}
462
463fn operation_is_multipart_only(op: &Value) -> bool {
464    let Some(body) = op
465        .as_mapping()
466        .and_then(|m| m.get(Value::from("requestBody")))
467    else {
468        return false;
469    };
470    let Some(content) = body
471        .as_mapping()
472        .and_then(|m| m.get(Value::from("content")))
473        .and_then(Value::as_mapping)
474    else {
475        return false;
476    };
477    let keys: Vec<&str> = content.keys().filter_map(Value::as_str).collect();
478    // Match either (a) sole multipart variant or (b) any operation that
479    // declares multipart alongside other variants — progenitor 0.9 trips on
480    // multi-variant bodies too (`more media types than expected`), and the
481    // upload path is hand-rolled in taskfast-client regardless.
482    keys.contains(&"multipart/form-data")
483}
484
485/// Structural projection: strip doc-only fields so two schemas differing only
486/// in `description`/`example` compare equal.
487fn structural_shape(v: &Value) -> Value {
488    match v {
489        Value::Mapping(m) => {
490            let mut out = Mapping::new();
491            for (k, val) in m {
492                let keep = match k.as_str() {
493                    Some("description") | Some("example") | Some("summary") | Some("default")
494                    | Some("deprecated") | Some("title") => false,
495                    _ => true,
496                };
497                if keep {
498                    out.insert(k.clone(), structural_shape(val));
499                }
500            }
501            Value::Mapping(out)
502        }
503        Value::Sequence(s) => Value::Sequence(s.iter().map(structural_shape).collect()),
504        other => other.clone(),
505    }
506}
507
508/// Walk `doc`, replacing every `$ref` whose value equals `from` with `to`.
509/// Returns the number of rewrites performed.
510fn rewrite_refs(doc: &mut Value, from: &str, to: &str) -> usize {
511    let mut count = 0;
512    rewrite_refs_inner(doc, from, to, &mut count);
513    count
514}
515
516fn rewrite_refs_inner(v: &mut Value, from: &str, to: &str, count: &mut usize) {
517    match v {
518        Value::Mapping(m) => {
519            // A $ref object is typically `{"$ref": "..."}` — a single-key mapping.
520            // We still walk siblings because spec-extension keys may coexist.
521            if let Some(target) = m.get_mut(Value::from("$ref")) {
522                if target.as_str() == Some(from) {
523                    *target = Value::from(to);
524                    *count += 1;
525                }
526            }
527            for (_, val) in m.iter_mut() {
528                rewrite_refs_inner(val, from, to, count);
529            }
530        }
531        Value::Sequence(s) => {
532            for val in s.iter_mut() {
533                rewrite_refs_inner(val, from, to, count);
534            }
535        }
536        _ => {}
537    }
538}
539
540fn get_mapping_path<'a>(v: &'a Value, path: &[&str]) -> Option<&'a Mapping> {
541    let mut cur = v;
542    for key in path {
543        cur = cur.as_mapping()?.get(Value::from(*key))?;
544    }
545    cur.as_mapping()
546}
547
548fn get_mapping_path_mut<'a>(v: &'a mut Value, path: &[&str]) -> Option<&'a mut Mapping> {
549    let mut cur = v;
550    for key in path {
551        cur = cur.as_mapping_mut()?.get_mut(Value::from(*key))?;
552    }
553    cur.as_mapping_mut()
554}
555
556fn tracing_nothing(_r: &Report) {}
557
558#[cfg(test)]
559mod tests {
560    use super::*;
561
562    const BASE_SPEC: &str = r#"
563openapi: 3.0.0
564info:
565  title: test
566  version: 0.0.0
567paths:
568  /foo:
569    get:
570      responses:
571        '200':
572          description: ok
573        '404':
574          description: not found
575          content:
576            application/json:
577              schema:
578                $ref: '#/components/schemas/WalletBalanceNotFoundError'
579        '503':
580          description: unavailable
581          content:
582            application/json:
583              schema:
584                $ref: '#/components/schemas/Error'
585components:
586  schemas:
587    Error:
588      type: object
589      required:
590        - error
591        - message
592      properties:
593        error:
594          type: string
595          description: Error code
596        message:
597          type: string
598          description: Human message
599    WalletBalanceNotFoundError:
600      type: object
601      required:
602        - error
603        - message
604      properties:
605        error:
606          type: string
607          description: Error code
608          example: "wallet_not_applicable"
609        message:
610          type: string
611          description: Human message
612          example: "not a tempo agent"
613    Untouched:
614      type: object
615"#;
616
617    #[test]
618    fn normalize_folds_alias_refs_and_drops_schema() {
619        let (out, report) =
620            normalize_with_aliases(BASE_SPEC, &["WalletBalanceNotFoundError"]).unwrap();
621        assert_eq!(
622            report.folded_aliases,
623            vec!["WalletBalanceNotFoundError".to_string()]
624        );
625        assert_eq!(report.refs_rewritten, 1);
626
627        // The canonical Error schema definition still lives in components
628        // (non-2xx response strip removes the *usages*, not the shared type).
629        assert!(
630            out.contains("Error:\n      type: object"),
631            "canonical Error schema missing:\n{out}"
632        );
633        assert!(
634            !out.contains("WalletBalanceNotFoundError"),
635            "alias name fully removed, got:\n{out}"
636        );
637
638        // Siblings untouched.
639        assert!(out.contains("Untouched:"));
640    }
641
642    #[test]
643    fn idempotent_when_no_aliases_present() {
644        let (out1, _) = normalize_spec_with_report(BASE_SPEC).unwrap();
645        let (out2, report2) = normalize_spec_with_report(&out1).unwrap();
646        assert_eq!(out1, out2);
647        assert!(report2.folded_aliases.is_empty());
648        assert_eq!(report2.refs_rewritten, 0);
649    }
650
651    #[test]
652    fn drift_check_rejects_mismatched_alias() {
653        // Same as BASE_SPEC but WalletBalanceNotFoundError gets an extra required field.
654        let drifted = BASE_SPEC.replace(
655            "WalletBalanceNotFoundError:\n      type: object\n      required:\n        - error\n        - message",
656            "WalletBalanceNotFoundError:\n      type: object\n      required:\n        - error\n        - message\n        - drift_field",
657        );
658        let err = normalize_with_aliases(&drifted, &["WalletBalanceNotFoundError"]).unwrap_err();
659        let msg = format!("{err:#}");
660        assert!(msg.contains("drifted from canonical"), "got: {msg}");
661    }
662
663    #[test]
664    fn missing_error_schema_is_error() {
665        let bad = BASE_SPEC.replace("Error:", "NotError:");
666        let err = normalize_spec(bad.as_str()).unwrap_err();
667        assert!(format!("{err:#}").contains("canonical schema `Error` not found"));
668    }
669
670    const MULTIPART_SPEC: &str = r#"
671openapi: 3.0.0
672info: { title: test, version: 0.0.0 }
673paths:
674  /upload:
675    post:
676      operationId: uploadThing
677      requestBody:
678        required: true
679        content:
680          multipart/form-data:
681            schema:
682              type: object
683              required: [file]
684              properties:
685                file: { type: string, format: binary }
686      responses:
687        '201': { description: ok }
688    get:
689      operationId: listThings
690      responses:
691        '200': { description: ok }
692  /json_only:
693    post:
694      operationId: postJsonThing
695      requestBody:
696        required: true
697        content:
698          application/json:
699            schema: { type: object }
700      responses:
701        '201': { description: ok }
702components:
703  schemas:
704    Error:
705      type: object
706      required: [error, message]
707      properties:
708        error: { type: string }
709        message: { type: string }
710"#;
711
712    #[test]
713    fn non_success_responses_are_stripped() {
714        let (out, report) = normalize_spec_with_report(BASE_SPEC).unwrap();
715        // BASE_SPEC has one op with 200/404/503 — 404 and 503 must go.
716        assert_eq!(report.error_responses_stripped, 2);
717        // The success code survives.
718        assert!(out.contains("'200':"));
719        // Error codes are gone.
720        assert!(!out.contains("'404':"), "404 not stripped:\n{out}");
721        assert!(!out.contains("'503':"), "503 not stripped:\n{out}");
722    }
723
724    #[test]
725    fn multi_media_request_body_collapses_to_json() {
726        const SPEC: &str = r#"
727openapi: 3.0.0
728info: { title: test, version: 0.0.0 }
729paths:
730  /oauth2/token:
731    post:
732      operationId: oauthToken
733      requestBody:
734        required: true
735        content:
736          application/json:
737            schema: { $ref: '#/components/schemas/Tok' }
738          application/x-www-form-urlencoded:
739            schema: { $ref: '#/components/schemas/Tok' }
740      responses:
741        '200': { description: ok }
742  /forms:
743    post:
744      operationId: formOnly
745      requestBody:
746        content:
747          application/x-www-form-urlencoded:
748            schema: { type: object }
749          text/plain:
750            schema: { type: string }
751      responses:
752        '200': { description: ok }
753components:
754  schemas:
755    Error:
756      type: object
757      required: [error, message]
758      properties:
759        error: { type: string }
760        message: { type: string }
761    Tok:
762      type: object
763"#;
764        let (out, report) = normalize_spec_with_report(SPEC).unwrap();
765        // Both multi-media request bodies collapse to a single type.
766        assert_eq!(report.request_media_collapsed, 2);
767        // oauthToken: JSON preferred — exactly one json media type remains
768        // (its form variant is gone).
769        assert_eq!(
770            out.matches("application/json:").count(),
771            1,
772            "expected oauth body kept as json:\n{out}"
773        );
774        // formOnly: no JSON, so the first declared type (form) wins and the
775        // text/plain variant is dropped.
776        assert_eq!(
777            out.matches("application/x-www-form-urlencoded:").count(),
778            1,
779            "expected form-only body kept as form-urlencoded:\n{out}"
780        );
781        assert!(
782            !out.contains("text/plain"),
783            "lower-priority text/plain variant not collapsed:\n{out}"
784        );
785    }
786
787    #[test]
788    fn multipart_only_operation_is_stripped() {
789        let (out, report) = normalize_spec_with_report(MULTIPART_SPEC).unwrap();
790        assert_eq!(report.stripped_operations, vec!["uploadThing".to_string()]);
791        assert!(!out.contains("uploadThing"), "uploadThing not stripped");
792        // Siblings untouched.
793        assert!(out.contains("listThings"), "sibling GET removed by mistake");
794        assert!(
795            out.contains("postJsonThing"),
796            "JSON-only POST removed by mistake"
797        );
798        // The path entry itself survives because listThings still lives there.
799        assert!(out.contains("/upload:"));
800    }
801
802    const NULL_VARIANT_SPEC: &str = r#"
803openapi: 3.0.0
804info: { title: test, version: 0.0.0 }
805paths:
806  /thing:
807    get:
808      responses:
809        '200':
810          description: ok
811          content:
812            application/json:
813              schema:
814                oneOf:
815                  - { type: object, properties: { name: { type: string } } }
816                  - { type: "null" }
817components:
818  schemas:
819    Error:
820      type: object
821      required: [error, message]
822      properties:
823        error: { type: string }
824        message: { type: string }
825    Nullable:
826      anyOf:
827        - { type: integer }
828        - { type: "null", description: "explicit absence" }
829    AllNull:
830      oneOf:
831        - { type: "null" }
832        - { type: "null" }
833    Untouched:
834      oneOf:
835        - { type: integer }
836        - { type: string }
837"#;
838
839    #[test]
840    fn null_type_variants_are_stripped_and_counted() {
841        let (out, report) = normalize_spec_with_report(NULL_VARIANT_SPEC).unwrap();
842        // 1 in /thing oneOf + 1 in Nullable.anyOf + 2 in AllNull.oneOf = 4.
843        assert_eq!(report.null_variants_stripped, 4);
844
845        // Surviving variants kept.
846        assert!(out.contains("Nullable:"));
847        assert!(out.contains("Untouched:"));
848        // Sibling keys (description) on a null-type variant don't shield it.
849        assert!(
850            !out.contains("explicit absence"),
851            "null variant with sibling description not stripped:\n{out}"
852        );
853        // Untouched non-null oneOf preserved verbatim.
854        assert!(
855            out.contains("Untouched:") && out.matches("type: string").count() >= 2,
856            "Untouched.oneOf altered:\n{out}"
857        );
858    }
859
860    #[test]
861    fn empty_oneof_after_strip_is_removed_outright() {
862        let (out, _report) = normalize_spec_with_report(NULL_VARIANT_SPEC).unwrap();
863        // AllNull had only null variants; the oneOf key must be gone, leaving
864        // the (now-empty) schema definition without an invalid `oneOf: []`.
865        // Find the AllNull block and assert no oneOf inside it.
866        let idx = out.find("AllNull:").expect("AllNull schema present");
867        let tail = &out[idx..];
868        // Stop at the next sibling schema or end-of-doc.
869        let block_end = tail[8..].find("\n    ").map_or(tail.len(), |n| n + 8);
870        let block = &tail[..block_end];
871        assert!(
872            !block.contains("oneOf"),
873            "empty oneOf not removed from AllNull:\n{block}"
874        );
875    }
876
877    #[test]
878    fn null_strip_is_idempotent() {
879        let (out1, _) = normalize_spec_with_report(NULL_VARIANT_SPEC).unwrap();
880        let (out2, report2) = normalize_spec_with_report(&out1).unwrap();
881        assert_eq!(out1, out2);
882        assert_eq!(report2.null_variants_stripped, 0);
883    }
884
885    const CLOSED_OBJECT_SPEC: &str = r#"
886openapi: 3.0.0
887info:
888  title: test
889  version: 0.0.0
890paths:
891  /thing:
892    get:
893      responses:
894        '200':
895          description: ok
896          content:
897            application/json:
898              schema:
899                type: object
900                additionalProperties: false
901                properties:
902                  id:
903                    type: string
904components:
905  schemas:
906    Error:
907      type: object
908      additionalProperties: false
909      properties:
910        error:
911          type: string
912    Open:
913      type: object
914      additionalProperties: true
915"#;
916
917    #[test]
918    fn closed_objects_are_opened_everywhere() {
919        let (out, _) = normalize_spec_with_report(CLOSED_OBJECT_SPEC).unwrap();
920        assert!(
921            !out.contains("additionalProperties: false"),
922            "closed object survived:\n{out}"
923        );
924        assert!(
925            out.contains("additionalProperties: true"),
926            "non-false value must be left alone:\n{out}"
927        );
928    }
929}