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