From e46cbf1b457bbd9adddc27ea80804e7fb19f3a39 Mon Sep 17 00:00:00 2001 From: Niels Swimberghe Date: Fri, 23 Oct 2026 18:51:24 -0400 Subject: [PATCH 01/25] docs: add RFC 0001 for the metadata resolution layer (FORGE-1) --- rfcs/0001-metadata-resolution-layer.md | 157 +++++++++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 rfcs/0001-metadata-resolution-layer.md diff --git a/rfcs/0001-metadata-resolution-layer.md b/rfcs/0001-metadata-resolution-layer.md new file mode 100644 index 000000000..1c095258d --- /dev/null +++ b/rfcs/0001-metadata-resolution-layer.md @@ -0,0 +1,157 @@ +# RFC 0001: The metadata resolution layer + +- **Status:** Draft +- **Date:** 2026-10-23 +- **Ticket:** FORGE-1 — *section off the fern stuff into a compatibility layer* +- **Related:** FORGE-3 (vocabulary renames) · FORGE-4 (versioning RFC) · FORGE-5 (unread vendor key lint) + +## Summary + +Forge's engine reads vendor metadata extensions (`x-fern-*`, `x-stainless-skip`) directly, so everything downstream of the linked model inherits the vendors' vocabulary, formats, and limitations. This RFC adds a **metadata resolution layer** with three parts: + +1. a native `x-forge-*` vocabulary, designed on Forge's own terms rather than mirroring any vendor's; +2. a pure library that resolves keys to primitives — native first, vendor adapters as fallback; +3. a quarantined, feature-gated **compatibility layer** that is the only code holding vendor knowledge, and whose designed end state is wholesale deletion. + +After the boundary, the engine — reader, builder, linked model, plugin host, npm runtime — acts only on Forge primitives. Output for existing specs is unchanged, and the release is non-breaking. + +## Motivation + +Today the engine couples to vendors in five places: + +1. **Wire keys** — vendor names are hardcoded in the serde key enums (`DefinitionKey`, `ParameterKey::FieldName`, `SchemaKey::{ModelName, FieldName, Skip}` in `src/link/openapi.rs`). +2. **Vendor value formats** — Fern's dotted-or-array group strings, Fern's five-value availability vocabulary, Stainless's `x-stainless-skip` (where a list of target names means *false*). +3. **Error strings** — validation messages name vendor keys ("`x-fern-availability "ga" is not …`"). +4. **The consumed-key set** — the seven names excluded from the plugin passthrough are hand-listed in the deserializers and re-documented in the model docs. +5. **Documentation** — the public model, the WIT, and the JS runtime attribute primitives to vendor keys; the WIT's doc comments ship in the npm package. + +Anything downstream — including the cf codebase — therefore depends on a vendor's extension format. Adding another vendor (Stainless, Scalar) means touching the parser, the linker, the docs, and the filter again. + +### What the production specs actually use + +Audit of the released aggregate (`openapi.forge.json`, 27.6 MB, sha `99544617…`): + +| Read by forge today | Uses | Present, **not** read | Uses | +|---|---|---|---| +| `x-fern-sdk-method-name` | 3,638 | `x-fern-enum` | 3,652 | +| `x-fern-sdk-group-name` | 3,638 | `x-stainless-terraform-configurability` | 407 | +| `x-fern-availability` | 3,444 | `x-stainless-deprecation-message` | 281 | +| `x-fern-ignore` | 449 | `x-stability` | 240 | +| `x-fern-parameter-name` | 108 | `x-stainless-collection-type` | 110 | +| `x-fern-property-name` | 38 | `x-fern-audiences` | 80 | +| `x-fern-type-name` | 2 | ~10 further Stainless / Fern keys | ~30 | +| `x-stainless-skip` | 114 | `x-fern-sdk-variables` | 0 | + +## Goals + +- Nothing outside the resolution layer references `x-fern-*` or `x-stainless-*` — enforced mechanically. +- Output for existing specs is unchanged, proven by byte-equality tests on the real aggregate. +- Tests cover each supported primitive, from each accepted key. +- No meaningful performance regression; parse throughput is benchmarked before and after. +- All Fern (and Stainless) compat code and tests live in one folder, imported only through an aggregating library. +- Native metadata is applied first; the compatibility layer is a fallback. +- The compatibility layer can be completely deleted or turned off without affecting any `x-forge-*` extension. +- The native vocabulary is not constrained by vendor naming, structure, or limitations — native keys are designed for Forge's model, and adapters do the translating. + +## Non-goals (this ticket) + +- Fern's structural behavior (allOf/anyOf handling, inline request types) — unchanged. +- Operation ID prefixing. +- Renaming Fern→Forge across the cf codebase (existing follow-up). +- Dotted `operationId` derivation (`a.b.c.d` → group `[a,b,c]` + method `d`) — a follow-up that will land *in this layer* behind an opt-in flag. +- New primitives for `x-fern-enum` (enum-value names — needs a model primitive that does not exist yet), `x-fern-audiences` (a visibility concept Forge has not designed), `x-stability`, `x-forge-sunset`, `x-stainless-deprecation-message`. These are flagged (FORGE-5) and stay unread; each would be a one-row additive addition later. +- Treating the `fern-forge` docs package's own `x-fern-*` reading (a downstream consumer, like cf) — follow-up. + +## The native vocabulary + +| Native key | Form | Meaning | Compat source (the adapter translates) | +|---|---|---|---| +| `x-forge-method` | string | the method's name | `x-fern-sdk-method-name` | +| `x-forge-group` | **array of strings**, top level first | the group path | `x-fern-sdk-group-name` (dotted string is Fern's format; the split lives in the adapter. Native has no dot syntax) | +| `x-forge-ignore` | bool, positional | operation/alias → no method; property schema → no field | `x-fern-ignore` (operation) · `x-stainless-skip` (property; a list of targets counts as `false` — inside the adapter) | +| `x-forge-availability` | `alpha` \| `beta` \| `preview` \| `generally-available` \| `deprecated` | lifecycle stage. The values are Forge's shipped vocabulary (public enum across Rust, WIT, JS); Fern merely coincides | `x-fern-availability` | +| `x-forge-model-name` | string | the model's name (components / `$defs` schema) | `x-fern-type-name` | +| `x-forge-property-name` | string | the property's field name | `x-fern-property-name` | +| `x-forge-parameter-name` | string | the parameter's field name | `x-fern-parameter-name` | +| `x-forge-hidden`, `x-forge-require-confirmation`, `x-forge-aliases`, `x-forge-commands`, `x-sensitive` | *(existing, unchanged)* | | — no vendor source; now owned by the native table | + +Design notes: + +- **Explicit position keys over one positional name key.** Fern used three name keys because its reader architecture had one visitor per OpenAPI object kind. We use explicit keys for a different, real reason: tools in the wild mangle `$ref` structure (inlining targets, dropping siblings), and when placement lies, the key name still carries the role — `x-forge-model-name` on a property schema can be ignored or flagged, where a positional `x-forge-name` would silently become a field name. Explicitness here is corruption-detection, not verbosity. +- **One exclusion word, two positions.** "Excluded from generated surfaces" is one concept at two scopes; two words (`ignore` + `skip`) would be a memory tax for no disambiguation, and `skip` is Stainless's word. +- **Arrays for groups.** JSON's native way to write a path. Accepting dotted strings natively would permanently import Fern's format into the native vocabulary; array-only now keeps the option to loosen later (the reverse — tightening later — would break specs). Fern's dotted strings keep working through the adapter for as long as Fern keys do. +- **No misplacement validation in v1.** A known native key in a wrong position is silently ignored, exactly as Fern's keys are today. Adding validation later is a pure tightening that breaks no valid spec. + +## Resolution rules + +1. **Native applied, compat fallback** — per primitive, independently (a spec mid-migration may take the group from native and availability from Fern). +2. **Silent native-wins** — when both a native key and a compat key are present, the compat value is ignored without comparison machinery. Consequence, to be documented in the metadata docs: a stale compat key silently diverges, and editing the compat key when a native key is present appears to do nothing. +3. **Same key twice** → error (today's `duplicate field` behavior, unchanged). +4. **Empty or punctuation-only values** fall back to the positional wire name, as today; validation errors name the key the author actually wrote (provenance), so existing Fern-keyed error expectations hold. +5. **Dual-writing** (native and compat keys carrying the same value) is the supported pattern for keeping specs portable across engine versions during the transition: old engines read the compat key, new engines apply native. + +## Architecture + +``` +src/ +├─ metadata/ THE NATIVE LIBRARY — pure +│ │ The x-forge-* table, the resolve() entry (native first), +│ │ pure value readers, the consumed-key set. +│ │ Never references compat. No serde, no arena coupling: +│ │ its inputs are &str keys and small pure values. +├─ compat/ THE COMPATIBILITY LAYER — feature "compat" (default on) +│ ├─ mod.rs the aggregator; its only pub(crate) item is resolve(). +│ │ The designed end state: delete this folder + the feature. +│ ├─ fern.rs every x-fern-* literal; Fern formats (dotted group +│ │ split, availability vocabulary); Fern unit tests. +│ └─ stainless.rs the x-stainless-skip reader; its unit tests. +└─ link/ + ├─ openapi.rs OpenAPI's own keys only; every x- key asks metadata. + └─ read.rs serde helpers; the keys! macro hoisted to crate + visibility, since the library sits beside link/. +``` + +Properties, all deliberate: + +- **One-way dependencies**: `link → metadata → compat`, and nothing else imports compat. The reader asks native first, compat second — the fallback arm *is* the D4 rule. +- **Purity**: the library's contract is `&str` keys and small values (`Vec` of group names, an availability enum, bools). All serde wiring and arena writes stay in `link`. This is what makes the library pluggable, standalone-consumable (a future `forge-ci validate` needs the key table, not the engine), and mechanically liftable into a real separate crate when the first standalone consumer appears (FORGE-5's work). +- **Deletion is the designed end state.** `cargo build --no-default-features` proves the native vocabulary stands alone; when vendor keys reach end-of-life, deleting `compat/` touches nothing else — no `x-forge-*` extension changes, in behavior, docs, or WIT. +- **Dispatch, not semantics**: the layer maps keys to primitives and normalizes simple values. Structural behavior — `x-forge-aliases` becoming methods, `x-forge-commands` describing groups — stays in the builder. + +## Enforcement + +- **Structural boundary — compiler-enforced (built-in Rust):** every vendor key constant is private to its adapter; the only thing outside `compat/` that can touch vendor knowledge is the aggregator's `resolve()`, which the reader calls. The feature gate makes compat removable, and `--no-default-features` CI builds keep proving it. +- **Textual boundary — `tests/boundary.rs`:** scans `src/`, `tests/`, and `packages/forge/` (including the shipped `plugin/forge.wit`) for `x-fern` / `x-stainless` outside `src/compat/` and `tests/compat/`. Doc comments count — moving the WIT's "from `x-fern-availability`" attribution to `x-forge-availability` is part of the work. The test runs wherever `cargo test` runs; a `just boundary` alias targets it. +- **Criterion mapping:** (1) nothing outside the layer references `x-fern-*` → the two mechanisms above; (2) output unchanged → zero model-shape changes, Fern-keyed specs resolve identically (equivalence tests + the full-aggregate native-vs-wasm byte-equality bench); (3) tests per primitive → below. + +## Test plan + +- `tests/parse/` — every fixture rewrites to native keys. Not find-and-replace: group values `"a.b"` become `["a","b"]`; the three Fern name keys become their explicit native keys; error assertions naming Fern keys move out. +- `tests/compat/` — the vendor suite, mirroring `tests/parse`'s harness: for each primitive, parse(Fern-keyed fixture) == parse(native-keyed fixture); Fern format specifics (dotted vs array groups, duplicate-key errors, provenance error strings, Stainless's skip-list semantics). +- `tests/boundary.rs` — the guard. +- Existing tripwires keep working: `wit_is_current` (WIT regeneration is mechanical via `wit.mjs`), the wasm dump equality, native-vs-wasm generated-output byte equality. +- Performance: divan parse benches before/after on the real aggregate (`FORGE_SPEC`); `tests/wasm.rs::bench` re-run; forge-bench re-pinned upstream after merge. + +## Release shape + +Non-breaking. The WIT changes are doc comments only — `forge:plugin@0.3.0` stays, bindings regenerate identically, no plugin rebuilds anywhere; npm gets an additive release (new native keys accepted, compat module added); the spec surface grows append-only; generated output is byte-identical. Vocabulary renames that *are* breaking (group→namespace, the availability word) are deliberately deferred to FORGE-3 so one breaking release absorbs all of them; the versioning policy that governs this is FORGE-4's RFC. + +## Alternatives considered + +- **Resolve-after-parse** (keep all `x-` keys as raw JSON, normalize in a second pass): most literal reading of "a resolution step", trivially provenance-aware — but it must retain every schema-level extension on the 27 MB aggregate (5,366 `x-auditable` alone) unless the reader consults a key table anyway, at which point it collapses into the chosen design with extra memory and a reimplementation of duplicate-key errors. Rejected on performance and diff size. +- **Vendor-trait machinery** (a `Vendor` trait with Fern/Stainless/Scalar impls): no primitive overlaps across vendors today; the key table grows rows when one does. Rejected as over-engineering for now. +- **One positional `x-forge-name`**: mirrors the model's single `Name` concept and is cleaner in the abstract — but when toolchains inline `$ref` targets, the model's name key rides into property positions and silently renames fields. Explicit keys turn that corruption into something ignorable or flaggable. Rejected on robustness. +- **Separate `x-forge-skip` for properties**: two words for one concept, and `skip` is Stainless's word. Rejected. +- **Accepting dotted group strings natively**: ergonomic continuity, but it permanently imports Fern's format into the native vocabulary; array-only now is the reversible choice. Rejected. +- **Error on native/compat disagreement**: the strictest conflict rule, and consistent with the engine's general strictness — deferred as an open question in FORGE-4 in favor of silent native-wins (zero comparison machinery, no cost for correct specs; the drift risk is documented). +- **Full crate split now** (workspace with a separate metadata crate): delivers "separate library" literally today at the cost of restructuring the OSS repo mid-beta and moving `Availability` into the library. Deferred: the pure contract makes the split a `git mv` when the first standalone consumer exists. +- **Renaming `Group`/`Availability` now**: both are Fern-inherited words, but both renames break the plugin contract. Deferred to FORGE-3's single breaking wave. + +## Follow-ups + +| Ticket | What | +|---|---| +| FORGE-3 | Vocabulary renames in one breaking release: `group` → `namespace`, plus the availability word review; `x-forge-group`/`x-forge-availability` become permanent alias keys (one table row each). | +| FORGE-4 | The versioning RFC: the four contracts, the rules this RFC assumes, and open questions (1.0 timing, warning channel, vendor-key EoL policy). | +| FORGE-5 | Informational Forge CI lint flagging vendor keys the engine doesn't read, sourced from this layer's key table. | +| (FORGE-1 follow-ups) | cf codebase Fern→Forge rename; dotted `operationId` derivation behind an opt-in flag. | From b5cb379d582c7ab8855dc81339aa057c16056609 Mon Sep 17 00:00:00 2001 From: Niels Swimberghe Date: Sun, 25 Oct 2026 22:00:51 -0400 Subject: [PATCH 02/25] docs: fix RFC 0001 dependency direction, vocabulary ownership and ignored-value rules --- rfcs/0001-metadata-resolution-layer.md | 44 +++++++++++++++----------- 1 file changed, 26 insertions(+), 18 deletions(-) diff --git a/rfcs/0001-metadata-resolution-layer.md b/rfcs/0001-metadata-resolution-layer.md index 1c095258d..0b95958cd 100644 --- a/rfcs/0001-metadata-resolution-layer.md +++ b/rfcs/0001-metadata-resolution-layer.md @@ -59,7 +59,8 @@ Audit of the released aggregate (`openapi.forge.json`, 27.6 MB, sha `99544617… - Operation ID prefixing. - Renaming Fern→Forge across the cf codebase (existing follow-up). - Dotted `operationId` derivation (`a.b.c.d` → group `[a,b,c]` + method `d`) — a follow-up that will land *in this layer* behind an opt-in flag. -- New primitives for `x-fern-enum` (enum-value names — needs a model primitive that does not exist yet), `x-fern-audiences` (a visibility concept Forge has not designed), `x-stability`, `x-forge-sunset`, `x-stainless-deprecation-message`. These are flagged (FORGE-5) and stay unread; each would be a one-row additive addition later. +- Audiences. The ticket lists audiences as a lower-priority primitive; this RFC deliberately leaves `x-fern-audiences` unread (it needs a visibility concept Forge has not designed), flagged by FORGE-5. This is a scope deviation from the ticket text and needs the reviewers' agreement. +- New primitives for `x-fern-enum` (enum-value names — needs a model primitive that does not exist yet), `x-stability`, `x-forge-sunset`, `x-stainless-deprecation-message`. These are flagged (FORGE-5) and stay unread; each would be a one-row additive addition later. - Treating the `fern-forge` docs package's own `x-fern-*` reading (a downstream consumer, like cf) — follow-up. ## The native vocabulary @@ -85,7 +86,7 @@ Design notes: ## Resolution rules 1. **Native applied, compat fallback** — per primitive, independently (a spec mid-migration may take the group from native and availability from Fern). -2. **Silent native-wins** — when both a native key and a compat key are present, the compat value is ignored without comparison machinery. Consequence, to be documented in the metadata docs: a stale compat key silently diverges, and editing the compat key when a native key is present appears to do nothing. +2. **Silent native-wins** — when both a native key and a compat key are present, the compat value is ignored without comparison machinery. Only the winning value is validated: an invalid compat `availability` does not error when a native one wins. A value of the wrong JSON type (a number where a string is expected) is still a document-shape error at read time, whichever key it is on and in whatever order the keys are written, so errors never depend on key order. Consequence, to be documented in the metadata docs: a stale compat key silently diverges, and editing the compat key when a native key is present appears to do nothing. 3. **Same key twice** → error (today's `duplicate field` behavior, unchanged). 4. **Empty or punctuation-only values** fall back to the positional wire name, as today; validation errors name the key the author actually wrote (provenance), so existing Fern-keyed error expectations hold. 5. **Dual-writing** (native and compat keys carrying the same value) is the supported pattern for keeping specs portable across engine versions during the transition: old engines read the compat key, new engines apply native. @@ -95,32 +96,39 @@ Design notes: ``` src/ ├─ metadata/ THE NATIVE LIBRARY — pure -│ │ The x-forge-* table, the resolve() entry (native first), -│ │ pure value readers, the consumed-key set. -│ │ Never references compat. No serde, no arena coupling: -│ │ its inputs are &str keys and small pure values. +│ │ The x-forge-* table (per kind of object: definition, +│ │ parameter, schema, document), the primitives and format +│ │ descriptors, the availability vocabulary, the consumed-key +│ │ set. Imports neither compat nor link. No serde, no arena +│ │ coupling: its inputs are &str keys, its outputs small +│ │ pure values. ├─ compat/ THE COMPATIBILITY LAYER — feature "compat" (default on) -│ ├─ mod.rs the aggregator; its only pub(crate) item is resolve(). +│ ├─ mod.rs the aggregator: one resolver per kind of object, trying +│ │ each vendor in turn. The only thing link imports from here. │ │ The designed end state: delete this folder + the feature. -│ ├─ fern.rs every x-fern-* literal; Fern formats (dotted group -│ │ split, availability vocabulary); Fern unit tests. -│ └─ stainless.rs the x-stainless-skip reader; its unit tests. +│ ├─ fern.rs every x-fern-* literal, mapped onto metadata's primitives; +│ │ Fern's value formats, as data (e.g. "a string splits on +│ │ dots"); Fern unit tests. +│ └─ stainless.rs the x-stainless-skip mapping and its format-as-data +│ (a list of targets counts as false); unit tests. └─ link/ - ├─ openapi.rs OpenAPI's own keys only; every x- key asks metadata. - └─ read.rs serde helpers; the keys! macro hoisted to crate - visibility, since the library sits beside link/. + ├─ resolve.rs the orchestrator: native first, then compat behind + │ #[cfg(feature = "compat")] — the only cfg point. + ├─ openapi.rs OpenAPI's own keys only; every x- key asks resolve.rs. + └─ read.rs serde helpers, as today. ``` Properties, all deliberate: -- **One-way dependencies**: `link → metadata → compat`, and nothing else imports compat. The reader asks native first, compat second — the fallback arm *is* the D4 rule. -- **Purity**: the library's contract is `&str` keys and small values (`Vec` of group names, an availability enum, bools). All serde wiring and arena writes stay in `link`. This is what makes the library pluggable, standalone-consumable (a future `forge-ci validate` needs the key table, not the engine), and mechanically liftable into a real separate crate when the first standalone consumer appears (FORGE-5's work). -- **Deletion is the designed end state.** `cargo build --no-default-features` proves the native vocabulary stands alone; when vendor keys reach end-of-life, deleting `compat/` touches nothing else — no `x-forge-*` extension changes, in behavior, docs, or WIT. +- **Dependencies**: `link → metadata` and `link → compat` (behind the feature), and `compat → metadata` (an adapter produces metadata's primitives). `metadata` imports neither of the others, so compat sits *beside* the native table rather than under it. `link/resolve.rs` asks native first, compat second — that fallback arm *is* the D4 rule. +- **Purity**: the library's contract is `&str` keys in and small values out: primitives, key names, and *format descriptors as data* (for example "a string splits on `.`" or "a list of targets counts as `false`"). The serde reading and the arena writes stay in `link` and are generic, driven by those descriptors. This is what makes the library pluggable, standalone-consumable (a future `forge-ci validate` needs the key table, not the engine), and mechanically liftable into a real separate crate when the first standalone consumer appears (FORGE-5's work). +- **The availability vocabulary is native.** The five values live in the native table (they are shipped public API); `fern.rs` only maps `x-fern-availability` onto that primitive. +- **Deletion is the designed end state.** `cargo build --no-default-features --features cli` proves the native vocabulary stands alone; when vendor keys reach end-of-life, deleting `compat/` and the one cfg arm in `link/resolve.rs` touches nothing else — no `x-forge-*` extension changes, in behavior, docs, or WIT. - **Dispatch, not semantics**: the layer maps keys to primitives and normalizes simple values. Structural behavior — `x-forge-aliases` becoming methods, `x-forge-commands` describing groups — stays in the builder. ## Enforcement -- **Structural boundary — compiler-enforced (built-in Rust):** every vendor key constant is private to its adapter; the only thing outside `compat/` that can touch vendor knowledge is the aggregator's `resolve()`, which the reader calls. The feature gate makes compat removable, and `--no-default-features` CI builds keep proving it. +- **Structural boundary — compiler-enforced (built-in Rust):** every vendor key literal is private to its adapter; the only thing outside `compat/` that can touch vendor knowledge is the aggregator's per-object resolvers, which `link/resolve.rs` calls. The feature gate makes compat removable, and builds without the `compat` feature keep proving it. - **Textual boundary — `tests/boundary.rs`:** scans `src/`, `tests/`, and `packages/forge/` (including the shipped `plugin/forge.wit`) for `x-fern` / `x-stainless` outside `src/compat/` and `tests/compat/`. Doc comments count — moving the WIT's "from `x-fern-availability`" attribution to `x-forge-availability` is part of the work. The test runs wherever `cargo test` runs; a `just boundary` alias targets it. - **Criterion mapping:** (1) nothing outside the layer references `x-fern-*` → the two mechanisms above; (2) output unchanged → zero model-shape changes, Fern-keyed specs resolve identically (equivalence tests + the full-aggregate native-vs-wasm byte-equality bench); (3) tests per primitive → below. @@ -144,7 +152,7 @@ Non-breaking. The WIT changes are doc comments only — `forge:plugin@0.3.0` sta - **Separate `x-forge-skip` for properties**: two words for one concept, and `skip` is Stainless's word. Rejected. - **Accepting dotted group strings natively**: ergonomic continuity, but it permanently imports Fern's format into the native vocabulary; array-only now is the reversible choice. Rejected. - **Error on native/compat disagreement**: the strictest conflict rule, and consistent with the engine's general strictness — deferred as an open question in FORGE-4 in favor of silent native-wins (zero comparison machinery, no cost for correct specs; the drift risk is documented). -- **Full crate split now** (workspace with a separate metadata crate): delivers "separate library" literally today at the cost of restructuring the OSS repo mid-beta and moving `Availability` into the library. Deferred: the pure contract makes the split a `git mv` when the first standalone consumer exists. +- **Full crate split now** (a separate `metadata` member crate): delivers "separate library" literally today. The root `Cargo.toml` is already a `[workspace]`, so adding a member does not mean moving the engine; the real cost is that a separate crate cannot depend on the engine, so `Availability` and the other primitive value types would move into it and be re-exported by `forge`. Deferred: the pure contract keeps that move mechanical for when the first standalone consumer exists. - **Renaming `Group`/`Availability` now**: both are Fern-inherited words, but both renames break the plugin contract. Deferred to FORGE-3's single breaking wave. ## Follow-ups From c14688baffe0d7d448b610ef514bd8228e2f1944 Mon Sep 17 00:00:00 2001 From: Niels Swimberghe Date: Sun, 25 Oct 2026 22:57:32 -0400 Subject: [PATCH 03/25] feat: resolve extensions through a native table with a compat fallback (FORGE-1) Add the metadata resolution layer of RFC 0001. - src/metadata: the native x-forge-* vocabulary, per kind of object, as pure key tables, primitives and value-format descriptors. - src/compat: the compatibility layer, behind the default-on `compat` feature. The Fern and Stainless adapters map their keys onto the same primitives; every vendor name lives here. - src/link/resolve.rs: native first, compat second. A native key's value is kept over a compat one's, whatever order they are written in. - Errors name the key the document wrote. - Docs and the WIT speak the native keys; the WIT copies are regenerated (doc comments only, so forge:plugin stays at 0.3.0). Output is unchanged: the unmodified Fern-keyed suite passes against it, and the real aggregate builds the same files and the same model dump. --- Cargo.toml | 10 +- packages/forge/plugin/forge.wit | 2 +- packages/forge/src/index.ts | 4 +- src/compat/fern.rs | 118 ++++++++++ src/compat/mod.rs | 36 +++ src/compat/stainless.rs | 45 ++++ src/lib.rs | 45 ++-- src/link.rs | 53 ++--- src/link/openapi.rs | 374 ++++++++++++++++++++++---------- src/link/resolve.rs | 91 ++++++++ src/metadata/mod.rs | 306 ++++++++++++++++++++++++++ src/plugin/wasm.rs | 2 +- src/types.rs | 8 +- tests/compat/common.rs | 105 +++++++++ tests/compat/fern.rs | 280 ++++++++++++++++++++++++ tests/compat/main.rs | 15 ++ tests/compat/native_first.rs | 196 +++++++++++++++++ tests/compat/stainless.rs | 90 ++++++++ tests/wasm/ts/wit/forge.wit | 2 +- 19 files changed, 1611 insertions(+), 171 deletions(-) create mode 100644 src/compat/fern.rs create mode 100644 src/compat/mod.rs create mode 100644 src/compat/stainless.rs create mode 100644 src/link/resolve.rs create mode 100644 src/metadata/mod.rs create mode 100644 tests/compat/common.rs create mode 100644 tests/compat/fern.rs create mode 100644 tests/compat/main.rs create mode 100644 tests/compat/native_first.rs create mode 100644 tests/compat/stainless.rs diff --git a/Cargo.toml b/Cargo.toml index 184531c7a..46373a6f5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,7 +23,11 @@ name = "parse" harness = false [features] -default = ["cli"] +default = ["cli", "compat"] +# The compatibility layer: it reads the extensions other tools' metadata is +# written with in place of the native `x-forge-*` ones, when a document has +# no native one. Without it, only the native extensions mean anything. +compat = [] # The `forge` command. Library users can turn it off with # `default-features = false`. cli = ["dep:clap", "dep:serde_json"] @@ -49,6 +53,10 @@ required-features = ["cli"] name = "wasm" required-features = ["cli", "wasm"] +[[test]] +name = "compat" +required-features = ["compat"] + [lints.clippy] # https://rust-lang.github.io/rust-clippy/rust-1.88.0/index.html diff --git a/packages/forge/plugin/forge.wit b/packages/forge/plugin/forge.wit index af93aa10e..7a489f74e 100644 --- a/packages/forge/plugin/forge.wit +++ b/packages/forge/plugin/forge.wit @@ -82,7 +82,7 @@ interface forge { json: option, } - /// The stage of the API a method is in, from `x-fern-availability`. + /// The stage of the API a method is in, from `x-forge-availability`. enum availability { alpha, beta, preview, generally-available, deprecated, } diff --git a/packages/forge/src/index.ts b/packages/forge/src/index.ts index 9b374eab9..983a4bb14 100644 --- a/packages/forge/src/index.ts +++ b/packages/forge/src/index.ts @@ -144,7 +144,7 @@ export interface Method { * way around. */ readonly deprecated: boolean; - /** The stage of the API the method is in, from `x-fern-availability`, or undefined if it doesn't say. */ + /** The stage of the API the method is in, from `x-forge-availability`, or undefined if it doesn't say. */ readonly availability: Availability | undefined; /** * Whether to leave the method out of listings of methods, such as a CLI's @@ -197,7 +197,7 @@ export interface Method { */ export type Location = { readonly in: 'path' } | { readonly in: 'query'; readonly exploded: boolean }; -/** The stage of the API a method is in, as `x-fern-availability` writes it. */ +/** The stage of the API a method is in, as `x-forge-availability` writes it. */ export type Availability = 'alpha' | 'beta' | 'preview' | 'generally-available' | 'deprecated'; /** diff --git a/src/compat/fern.rs b/src/compat/fern.rs new file mode 100644 index 000000000..5c57b4690 --- /dev/null +++ b/src/compat/fern.rs @@ -0,0 +1,118 @@ +//! The adapter for Fern's `x-fern-*` extensions. +//! +//! | Key | Says | Format | +//! |---|---|---| +//! | `x-fern-sdk-method-name` | the name of the method | a string | +//! | `x-fern-sdk-group-name` | the group path of the method | an array of group names, or one string of them separated by dots | +//! | `x-fern-ignore` | the definition makes no method | a boolean | +//! | `x-fern-availability` | the stage of the API | a string, one of the native spellings | +//! | `x-fern-parameter-name` | the name of a parameter's field | a string | +//! | `x-fern-property-name` | the name of a property's field | a string | +//! | `x-fern-type-name` | the name of a model | a string | +//! +//! Its other extensions, such as `x-fern-enum` and `x-fern-audiences`, are +//! not read: they say things forge has no primitive for. + +use crate::metadata::{BoolFormat, DefinitionField, GroupFormat, Hit, ParameterField, SchemaField}; + +const METHOD_NAME: &str = "x-fern-sdk-method-name"; +const GROUP_NAME: &str = "x-fern-sdk-group-name"; +const IGNORE: &str = "x-fern-ignore"; +const AVAILABILITY: &str = "x-fern-availability"; +const PARAMETER_NAME: &str = "x-fern-parameter-name"; +const PROPERTY_NAME: &str = "x-fern-property-name"; +const TYPE_NAME: &str = "x-fern-type-name"; + +pub(super) fn definition(key: &str) -> Option> { + Some(match key { + METHOD_NAME => Hit::compat(DefinitionField::Method, METHOD_NAME, 0), + GROUP_NAME => Hit::compat( + DefinitionField::Group(GroupFormat::StringOrArray { separator: '.' }), + GROUP_NAME, + 1, + ), + IGNORE => Hit::compat(DefinitionField::Ignore(BoolFormat::Bool), IGNORE, 2), + AVAILABILITY => Hit::compat(DefinitionField::Availability, AVAILABILITY, 3), + _ => return None, + }) +} + +pub(super) fn parameter(key: &str) -> Option> { + Some(match key { + PARAMETER_NAME => Hit::compat(ParameterField::Name, PARAMETER_NAME, 0), + _ => return None, + }) +} + +/// Slots 0 and 1 of the compatibility keys of a Schema Object are Fern's; +/// the next adapter's start at 2. +pub(super) fn schema(key: &str) -> Option> { + Some(match key { + TYPE_NAME => Hit::compat(SchemaField::ModelName, TYPE_NAME, 0), + PROPERTY_NAME => Hit::compat(SchemaField::PropertyName, PROPERTY_NAME, 1), + _ => return None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn each_key_is_read_as_itself_and_as_not_native() { + for key in [METHOD_NAME, GROUP_NAME, IGNORE, AVAILABILITY] { + let hit = definition(key).unwrap_or_else(|| panic!("{key} is not a definition key")); + assert_eq!((hit.key, hit.native), (key, false)); + } + let hit = parameter(PARAMETER_NAME).unwrap(); + assert_eq!((hit.key, hit.native), (PARAMETER_NAME, false)); + for key in [TYPE_NAME, PROPERTY_NAME] { + let hit = schema(key).unwrap_or_else(|| panic!("{key} is not a schema key")); + assert_eq!((hit.key, hit.native), (key, false)); + } + } + + #[test] + fn a_key_is_read_only_where_it_says_something() { + assert!(definition(PARAMETER_NAME).is_none()); + assert!(definition(PROPERTY_NAME).is_none()); + assert!(parameter(METHOD_NAME).is_none()); + assert!(schema(IGNORE).is_none()); + assert!(schema(METHOD_NAME).is_none()); + } + + #[test] + fn slots_are_unique_among_the_keys_of_an_object() { + let mut slots: Vec = [METHOD_NAME, GROUP_NAME, IGNORE, AVAILABILITY] + .iter() + .map(|key| definition(key).unwrap().slot) + .collect(); + slots.sort_unstable(); + slots.dedup(); + assert_eq!(slots.len(), 4); + assert!(slots.iter().all(|&slot| slot >= 32), "slots of other keys start at 32"); + } + + #[test] + fn the_group_is_a_string_split_on_dots_or_an_array() { + let hit = definition(GROUP_NAME).unwrap(); + assert!(matches!( + hit.field, + DefinitionField::Group(GroupFormat::StringOrArray { separator: '.' }) + )); + } + + #[test] + fn unread_extensions_are_not_read() { + for key in [ + "x-fern-enum", + "x-fern-audiences", + "x-fern-pagination", + "x-fern-sdk-variables", + ] { + assert!(definition(key).is_none(), "{key}"); + assert!(parameter(key).is_none(), "{key}"); + assert!(schema(key).is_none(), "{key}"); + } + } +} diff --git a/src/compat/mod.rs b/src/compat/mod.rs new file mode 100644 index 000000000..2545a0124 --- /dev/null +++ b/src/compat/mod.rs @@ -0,0 +1,36 @@ +//! The compatibility layer: the keys other tools' metadata is written with, +//! read as the native extensions of [`metadata`](crate::metadata) say the +//! same things. +//! +//! Each adapter, [`fern`] and [`stainless`], maps its tool's keys onto the +//! native primitives and says, as data, what format each value is written +//! in. This module is what `link` asks, once per kind of object, after the +//! native keys have said no. It is the only way in: every key an adapter +//! reads is private to it. +//! +//! The layer is built only with the `compat` feature, and is meant to go: +//! once nobody writes these keys, deleting this folder and the calls to it in +//! `link/resolve.rs` changes nothing about what any native extension means. The keys are read in place of a native key only when there is none, +//! as `link` decides. + +mod fern; +mod stainless; + +use crate::metadata::{DefinitionField, Hit, ParameterField, SchemaField}; + +/// Returns what `key` says on an operation or an alias, if an adapter reads +/// it there. +pub(crate) fn definition(key: &str) -> Option> { + fern::definition(key) +} + +/// Returns what `key` says on a Parameter Object, if an adapter reads it +/// there. +pub(crate) fn parameter(key: &str) -> Option> { + fern::parameter(key) +} + +/// Returns what `key` says on a Schema Object, if an adapter reads it there. +pub(crate) fn schema(key: &str) -> Option> { + fern::schema(key).or_else(|| stainless::schema(key)) +} diff --git a/src/compat/stainless.rs b/src/compat/stainless.rs new file mode 100644 index 000000000..9338545a0 --- /dev/null +++ b/src/compat/stainless.rs @@ -0,0 +1,45 @@ +//! The adapter for Stainless's `x-stainless-*` extensions. +//! +//! | Key | Says | Format | +//! |---|---|---| +//! | `x-stainless-skip` | the property makes no field | a boolean, or an array of the targets to skip it for, which counts as `false` | +//! +//! Its other extensions, such as `x-stainless-terraform-configurability` and +//! `x-stainless-deprecation-message`, are not read: they say things forge has +//! no primitive for. + +use crate::metadata::{BoolFormat, Hit, SchemaField}; + +const SKIP: &str = "x-stainless-skip"; + +/// Slot 2 among the compatibility keys of a Schema Object: Fern's are the +/// first two. +pub(super) fn schema(key: &str) -> Option> { + Some(match key { + SKIP => Hit::compat(SchemaField::Ignore(BoolFormat::BoolOrTargets), SKIP, 2), + _ => return None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_skip_is_read_on_a_schema_as_a_boolean_or_targets_and_not_native() { + let hit = schema(SKIP).unwrap(); + assert_eq!((hit.key, hit.native), (SKIP, false)); + assert!(matches!(hit.field, SchemaField::Ignore(BoolFormat::BoolOrTargets))); + } + + #[test] + fn other_keys_are_not_read() { + for key in [ + "x-stainless-terraform-configurability", + "x-stainless-deprecation-message", + "x-fern-ignore", + ] { + assert!(schema(key).is_none(), "{key}"); + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 55e1611d5..3895b7e5b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,8 +3,11 @@ use std::{iter, ptr}; mod arena; +#[cfg(feature = "compat")] +mod compat; mod json; mod link; +mod metadata; mod method; mod name; mod plugin; @@ -223,7 +226,7 @@ impl std::fmt::Debug for Group<'_> { /// own group and name, with the `x-forge-aliases` extension: an array of /// objects, each an alias that is a method of the operation. The /// operation's own group and name then make no method, and its own -/// `x-fern-ignore` does not stop its aliases. An empty array makes no +/// `x-forge-ignore` does not stop its aliases. An empty array makes no /// method, an element that is not an object is ignored, and a value that /// is not an array is an error. Aliases do not nest: an `x-forge-aliases` /// in an alias is ignored, as are its keys that are not extensions. @@ -233,7 +236,7 @@ impl std::fmt::Debug for Group<'_> { /// [`availability`](Method::availability), whether it is /// [hidden](Method::is_hidden), its [`confirmation`](Method::confirmation) /// and its [`extensions`](Method::extensions), and whether it is ignored -/// with `x-fern-ignore`. None of these come from the operation: an alias +/// with `x-forge-ignore`. None of these come from the operation: an alias /// without an extension does not have the operation's. The rest, from the /// HTTP method and path to the responses, is the operation's, and every /// alias has it. Each alias is a method of its own group, so a model one @@ -250,7 +253,7 @@ pub struct Method<'f> { impl<'f> Method<'f> { /// Returns the name of the method, which is unique within its group. /// - /// Corresponds to the `x-fern-sdk-method-name` extension of the + /// Corresponds to the `x-forge-method` extension of the /// operation, or of the alias the method comes from. An operation or /// alias without one, or with an empty one, is not a method, and one /// with no letters or digits is an error. Two methods in a group with @@ -261,12 +264,12 @@ impl<'f> Method<'f> { /// Returns the group the method is in. /// - /// Corresponds to the `x-fern-sdk-group-name` extension of the - /// operation, or of the alias the method comes from: a string of group - /// names separated by dots, such as `accounts.members`, or an array - /// with one group name per element, top level first. An operation or - /// alias without one, with an empty one, or with `x-fern-ignore: true` - /// is not a method. A group name with no letters or digits is an error. + /// Corresponds to the `x-forge-group` extension of the operation, or + /// of the alias the method comes from: an array with one group name per + /// element, top level first, such as `[accounts, members]`. An + /// operation or alias without one, with an empty one, or with + /// `x-forge-ignore: true` is not a method. A group name with no letters + /// or digits is an error. pub fn group(self) -> Group<'f> { self.forge.group(self.data().group) } @@ -314,7 +317,7 @@ impl<'f> Method<'f> { /// Returns the stage of the API the method is in, such as /// [`Availability::Beta`], or `None` if the operation does not say. /// - /// Corresponds to the `x-fern-availability` extension of the + /// Corresponds to the `x-forge-availability` extension of the /// operation, a string that is one of `alpha`, `beta`, `preview`, /// `generally-available` and `deprecated`, as [`Availability`] lists. /// Any other value, including one that differs only in case, is an @@ -323,7 +326,7 @@ impl<'f> Method<'f> { /// /// It is apart from [`is_deprecated`](Method::is_deprecated), which /// comes from OpenAPI's own `deprecated`: an operation with - /// `x-fern-availability: deprecated` and without `deprecated: true` is + /// `x-forge-availability: deprecated` and without `deprecated: true` is /// [`Availability::Deprecated`] but not deprecated, and one with /// `deprecated: true` has whatever availability it says, or none. pub fn availability(self) -> Option { @@ -335,7 +338,7 @@ impl<'f> Method<'f> { /// /// Corresponds to the `x-forge-hidden` extension of the operation, a /// boolean, which is false without it. A value that is not a boolean - /// is an error, as for `x-fern-ignore`. Unlike `x-fern-ignore`, a + /// is an error, as for `x-forge-ignore`. Unlike `x-forge-ignore`, a /// hidden operation is still a method. pub fn is_hidden(self) -> bool { self.data().hidden @@ -384,7 +387,7 @@ impl<'f> Method<'f> { /// client sends the fields that [`Field::is_read_only`] reports. A /// parameter's field is never read-only. /// - /// A parameter's field is named after the `x-fern-parameter-name` + /// A parameter's field is named after the `x-forge-parameter-name` /// extension of the parameter, or else its wire name. Its description /// is the parameter's, or else its schema's, following `$ref`s, as /// [`Field::description`] says; from OpenAPI 3.1, the `description` of @@ -494,11 +497,13 @@ impl<'f> Method<'f> { /// /// An extension is a key of the /// [Operation Object](https://spec.openapis.org/oas/latest.html#operation-object) - /// that starts with `x-`. Those forge reads, `x-fern-sdk-method-name`, - /// `x-fern-sdk-group-name`, `x-fern-ignore`, `x-fern-availability`, + /// that starts with `x-`. Those forge reads, `x-forge-method`, + /// `x-forge-group`, `x-forge-ignore`, `x-forge-availability`, /// `x-forge-hidden`, `x-forge-require-confirmation` and - /// `x-forge-aliases`, are left out, and other keys forge does not read - /// are not kept. An extension written more than once is there once, + /// `x-forge-aliases`, are left out, and so are the keys the + /// compatibility layer reads in their place, when forge is built with + /// it. Other keys forge does not read are not kept. An extension + /// written more than once is there once, /// with the last value written, in the place of the first, as with /// `JSON.parse`. Extensions of the path item, the parameters, the body, /// the responses and the schemas are not kept. @@ -588,7 +593,7 @@ impl HttpMethod { /// The stage of the API a [`Method`] is in, as /// [`Method::availability`] gives it. /// -/// Corresponds to the value of the `x-fern-availability` extension of an +/// Corresponds to the value of the `x-forge-availability` extension of an /// operation, which each variant names. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum Availability { @@ -606,7 +611,7 @@ pub enum Availability { } impl Availability { - /// Returns the availability as `x-fern-availability` writes it, such + /// Returns the availability as `x-forge-availability` writes it, such /// as `generally-available`. pub fn as_str(self) -> &'static str { match self { @@ -646,7 +651,7 @@ impl<'f> Model<'f> { /// Returns the name of the model. /// - /// Corresponds to the `x-fern-type-name` extension of the schema. A + /// Corresponds to the `x-forge-model-name` extension of the schema. A /// schema without one is named after its schema name, leaving out /// `$defs`, so `Tree/$defs/Node` has the name `Tree Node`. pub fn name(self) -> &'f Name { diff --git a/src/link.rs b/src/link.rs index e77314894..fcca41f55 100644 --- a/src/link.rs +++ b/src/link.rs @@ -8,6 +8,7 @@ mod openapi; mod read; +mod resolve; use std::borrow::Cow; use std::collections::HashMap; @@ -20,13 +21,14 @@ use serde::de::{self, Deserializer}; use crate::arena::Id; use crate::arena::{List, Span, Str, Strings}; use crate::json::Json; +use crate::metadata; use crate::method::{BodyData, ContentData, ResponseData, is_json}; use crate::types::{ ArrayData, DiscriminatorData, EnumData, FieldData, NameId, ObjectData, TypeData, TypeId, UnionData, }; use crate::{ - Availability, EnumValue, Forge, GroupData, GroupId, HttpMethod, Location, MethodData, MethodId, ModelData, ModelId, - Name, Number, Scalar, + EnumValue, Forge, GroupData, GroupId, HttpMethod, Location, MethodData, MethodId, ModelData, ModelId, Name, Number, + Scalar, }; use openapi::{ Commands, ContentEncoding, Definition, Discriminator, Doc, Format, Info, Item, Literal, Operation, Parameter, @@ -543,8 +545,8 @@ impl<'d> Builder<'d> { self.add_method(path, item, operation, &operation.definition, http_method); return; }; - // The operation's own definition makes no method, so its - // `x-fern-ignore` does not stop its aliases. + // The operation's own definition makes no method, so its being + // ignored does not stop its aliases. for (i, alias) in doc.list(aliases).iter().enumerate() { self.context = Context::Alias(method, path, i); self.add_method(path, item, operation, alias, http_method.clone()); @@ -563,35 +565,39 @@ impl<'d> Builder<'d> { http_method: HttpMethod, ) { let doc = self.doc; - if definition.ignore { + if definition.ignore.value { return; } - if definition.method_name.is_empty() { + if definition.method_name.value.is_empty() { return; } - let group_names = doc.list(definition.group_names); + let group_names = doc.list(definition.group_names.value); if group_names.is_empty() { return; } let group_names: Vec = group_names.iter().map(|&name| Name::new(doc.str(name))).collect(); for name in &group_names { if name.is_empty() { - self.error("x-fern-sdk-group-name has a group name with no letters or digits"); + self.error(format_args!( + "{} has a group name with no letters or digits", + definition.group_names.key + )); return; } } - let name = Name::new(doc.str(definition.method_name)); + let name = Name::new(doc.str(definition.method_name.value)); if name.is_empty() { - self.error("x-fern-sdk-method-name has no letters or digits"); + self.error(format_args!("{} has no letters or digits", definition.method_name.key)); return; } let mut availability = None; - if let Some(value) = definition.availability { + if let Some(value) = definition.availability.value { let value = doc.str(value); - availability = parse_availability(value); + availability = metadata::availability(value); if availability.is_none() { self.error(format_args!( - "x-fern-availability {value:?} is not alpha, beta, preview, generally-available or deprecated" + "{} {value:?} is not alpha, beta, preview, generally-available or deprecated", + definition.availability.key )); } } @@ -950,7 +956,7 @@ impl<'d> Builder<'d> { } /// Returns the name of the field of a property or parameter written as - /// `wire_name`, whose name extension, such as `x-fern-property-name`, + /// `wire_name`, whose name extension, such as `x-forge-property-name`, /// is `extension`. The extension gives the name if it has letters or /// digits. fn field_name(&mut self, extension: Str, wire_name: &'d str) -> NameId { @@ -1267,19 +1273,6 @@ fn fixed_method(method: &str) -> Option { } } -/// Returns the availability `value` is as `x-fern-availability` writes -/// it, such as `beta`, or `None` if it is none of them. Case matters. -fn parse_availability(value: &str) -> Option { - match value { - "alpha" => Some(Availability::Alpha), - "beta" => Some(Availability::Beta), - "preview" => Some(Availability::Preview), - "generally-available" => Some(Availability::GenerallyAvailable), - "deprecated" => Some(Availability::Deprecated), - _ => None, - } -} - /// Reports whether `a` and `b` are the same parameter, so that one replaces /// the other: they have the same location and name. Header names are /// compared ignoring ASCII case, since HTTP field names are @@ -1599,7 +1592,7 @@ impl<'d> Builder<'d> { Schema::Bool(true) => {} Schema::Object(object) => { let object = doc.get(object); - if object.skip { + if object.ignore { continue; } extension = object.field_name; @@ -2162,7 +2155,7 @@ fn item(list: &[Schema], index: &str) -> Option { list.get(index.parse::().ok()?).copied() } -/// Returns the name an extension such as `x-fern-type-name` gives, or +/// Returns the name an extension such as `x-forge-model-name` gives, or /// `None` if there is none, or it has no letters or digits. fn name_of(doc: &Doc, extension: Str) -> Option { if extension.is_empty() { @@ -2212,7 +2205,7 @@ fn decode(segment: &str) -> Cow<'_, str> { Cow::Owned(decoded.replace("~1", "/").replace("~0", "~")) } -/// Returns the name of a model without `x-fern-type-name`: its schema +/// Returns the name of a model without `x-forge-model-name`: its schema /// name without `$defs`. fn model_name(schema_name: &str) -> Name { let mut words = String::with_capacity(schema_name.len()); diff --git a/src/link/openapi.rs b/src/link/openapi.rs index ba7fa4906..877b6df47 100644 --- a/src/link/openapi.rs +++ b/src/link/openapi.rs @@ -24,9 +24,13 @@ use serde::Deserialize; use serde::de::{self, DeserializeSeed, Deserializer, IgnoredAny, MapAccess, SeqAccess, Visitor}; use super::read::{first, keys}; +use super::resolve; use crate::Name; use crate::arena::{Id, List, Span, Str, Strings}; use crate::json::{self, Json}; +use crate::metadata::{ + self, BoolFormat, DefinitionField, DocumentField, GroupFormat, Hit, ParameterField, SchemaField, +}; /// An OpenAPI document: the fields of the OpenAPI Object at its root, and /// the arenas of the objects in it. @@ -236,26 +240,57 @@ pub(super) struct Operation { pub(super) responses: Span<(Str, Id)>, } +/// A value an extension gives, and the key it came from, so that an error can +/// name the key the document wrote. A native key's value is kept over any +/// other's, whatever order they are written in. +#[derive(Default)] +pub(super) struct Val { + pub(super) value: T, + /// The key as written, or empty if no key gave the value. + pub(super) key: &'static str, + native: bool, +} + +impl Val { + /// Keeps `value`, which `hit` gave, unless a native key gave the value + /// held and `hit` is not native. + fn set(&mut self, hit: &Hit, value: T) { + if takes(hit, &mut self.native) { + self.value = value; + self.key = hit.key; + } + } +} + +/// Reports whether a value from the key of `hit` replaces the one read so +/// far, which came from a native key if `native` is set, and records where +/// the value kept came from. +fn takes(hit: &Hit, native: &mut bool) -> bool { + if hit.native || !*native { + *native = hit.native; + return true; + } + false +} + /// The extensions that make an Operation Object, or an alias of one in its /// `x-forge-aliases`, a method. #[derive(Default)] pub(super) struct Definition { - /// The `x-fern-sdk-method-name`, or empty for none. - pub(super) method_name: Str, - /// The `x-fern-sdk-group-name`: the group path, top level first, or - /// empty for none. - pub(super) group_names: Span, - /// `x-fern-ignore`. - pub(super) ignore: bool, - /// `x-forge-hidden`. + /// The name of the method, or empty for none. + pub(super) method_name: Val, + /// The group path, top level first, or empty for none. + pub(super) group_names: Val>, + /// Whether the definition makes no method. + pub(super) ignore: Val, + /// Whether the method is left out of listings. pub(super) hidden: bool, - /// The `x-forge-require-confirmation`, or `None` for none. An empty - /// one is `Some`. + /// The sentence to confirm with, or `None` for none. An empty one is + /// `Some`. pub(super) confirmation: Option, - /// The `x-fern-availability` as written, or `None` for none. It is - /// checked as the method is built, so that an error can name the - /// operation. - pub(super) availability: Option, + /// The availability as written, or `None` for none. It is checked as + /// the method is built, so that an error can name the operation. + pub(super) availability: Val>, /// Each other extension, which is not read, in `Doc::extensions`, in /// the order they are written. One written more than once has the last /// value written, in the place of the first. @@ -280,8 +315,10 @@ pub(super) struct Parameter { /// The schema of each media type of a parameter described by /// `content` rather than `schema`, in the order they are written. pub(super) content: Span<(Str, Schema)>, - /// The `x-fern-parameter-name`, or empty for none. + /// The name of the parameter's field, or empty for none. pub(super) field_name: Str, + /// Whether a native key gave `field_name`. + field_name_native: bool, } /// Where a parameter is sent: the `in` of a Parameter Object. @@ -399,15 +436,19 @@ pub(super) struct SchemaObject { /// Whether the schema is marked `x-sensitive: true`: the value is a /// secret, such as a password or a token. pub(super) sensitive: bool, - /// The `x-fern-type-name`: the name of the model, for a schema in - /// `components.schemas`, or empty for none. + /// The name of the model, for a schema in `components.schemas`, or + /// empty for none. pub(super) model_name: Str, - /// The `x-fern-property-name`: the name of the field, for a property's - /// schema, or empty for none. + /// The name of the field, for a property's schema, or empty for none. pub(super) field_name: Str, - /// Whether a property's schema is marked `x-stainless-skip: true`. A - /// list of the targets to skip it for is ignored. - pub(super) skip: bool, + /// Whether a property's schema is marked to be left out: it makes no + /// field. + pub(super) ignore: bool, + /// Whether a native key gave `model_name`, `field_name` and `ignore`, + /// each, so that no other key replaces it. + model_name_native: bool, + field_name_native: bool, + ignore_native: bool, } /// A value of `type` in a Schema Object. @@ -851,29 +892,36 @@ impl Read for Types { } } -/// Reads an `x-fern-sdk-group-name`: a string split on dots, or an array of -/// strings that are not split. The empty string is no group. -struct GroupNames; +/// What reads a group path into a `Doc`, in the format it is written in: +/// an array of strings, which are not split, and, if the format says so, a +/// string split on its separator, where the empty string is no group. +struct GroupNames<'a>(&'a mut Doc, GroupFormat); -impl Read for GroupNames { - type Out = Span; +impl<'de> DeserializeSeed<'de> for GroupNames<'_> { + type Value = Span; - fn read<'de, D: Deserializer<'de>>(doc: &mut Doc, deserializer: D) -> Result, D::Error> { - struct GroupNamesVisitor<'a>(&'a mut Doc); + fn deserialize>(self, deserializer: D) -> Result, D::Error> { + struct GroupNamesVisitor<'a>(&'a mut Doc, GroupFormat); impl<'de> Visitor<'de> for GroupNamesVisitor<'_> { type Value = Span; fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str("a string or an array of strings") + match self.1 { + GroupFormat::Array => f.write_str("an array of strings"), + GroupFormat::StringOrArray { .. } => f.write_str("a string or an array of strings"), + } } fn visit_str(self, v: &str) -> Result, E> { + let GroupFormat::StringOrArray { separator } = self.1 else { + return Err(E::invalid_type(de::Unexpected::Str(v), &self)); + }; if v.is_empty() { return Ok(Span::default()); } let doc = self.0; - Ok(doc.strings.push_split(v, '.', &mut doc.str_lists)) + Ok(doc.strings.push_split(v, separator, &mut doc.str_lists)) } fn visit_seq>(self, seq: A) -> Result, A::Error> { @@ -882,7 +930,16 @@ impl Read for GroupNames { } } - deserializer.deserialize_any(GroupNamesVisitor(doc)) + deserializer.deserialize_any(GroupNamesVisitor(self.0, self.1)) + } +} + +/// Reads a boolean in the format it is written in: `true` or `false`, and, +/// if the format says so, an array of targets, which is `false`. +fn read_bool<'de, A: MapAccess<'de>>(map: &mut A, format: BoolFormat) -> Result { + match format { + BoolFormat::Bool => map.next_value(), + BoolFormat::BoolOrTargets => Ok(map.next_value::()?.0), } } @@ -1036,6 +1093,47 @@ impl<'de> Deserialize<'de> for FirstServer { } } +/// Declares `$name`, a key of an object that has extensions: what `$resolve` +/// says if it is an `x-` key that means something there, and else one of the +/// OpenAPI keys `$keys` declares, or `Other`. The extensions are kept apart +/// from `$keys`, since they have their own way of being written twice. +macro_rules! entry { + ($name:ident, $keys:ident, $field:ty, $resolve:path) => { + enum $name { + Key($keys), + Extension(Hit<$field>), + } + + impl<'de> Deserialize<'de> for $name { + fn deserialize>(deserializer: D) -> Result<$name, D::Error> { + struct EntryVisitor; + + impl Visitor<'_> for EntryVisitor { + type Value = $name; + + fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("a key") + } + + fn visit_str(self, v: &str) -> Result<$name, E> { + // The OpenAPI keys first: most keys of a document are + // one of them, and none is an extension. + Ok(match $keys::from_key(v) { + $keys::Other => match $resolve(v) { + Some(hit) => $name::Extension(hit), + None => $name::Key($keys::Other), + }, + key => $name::Key(key), + }) + } + } + + deserializer.deserialize_identifier(EntryVisitor) + } + } + }; +} + keys! { DocumentKey { Openapi = "openapi", @@ -1043,10 +1141,11 @@ keys! { Servers = "servers", Paths = "paths", Components = "components", - Commands = "x-forge-commands", } } +entry!(DocumentEntry, DocumentKey, DocumentField, resolve::document); + struct DocumentVisitor<'a>(&'a mut Doc); impl<'de> Visitor<'de> for DocumentVisitor<'_> { @@ -1059,7 +1158,18 @@ impl<'de> Visitor<'de> for DocumentVisitor<'_> { fn visit_map>(self, mut map: A) -> Result<(), A::Error> { let doc = self.0; let mut seen = 0; - while let Some(key) = map.next_key::()? { + let mut seen_extensions = 0; + while let Some(entry) = map.next_key::()? { + let key = match entry { + DocumentEntry::Extension(hit) => { + resolve::once(&hit, &mut seen_extensions)?; + match hit.field { + DocumentField::Commands => doc.commands = map.next_value()?, + } + continue; + } + DocumentEntry::Key(key) => key, + }; key.once(&mut seen)?; match key { DocumentKey::Openapi => doc.openapi = map.next_value()?, @@ -1071,7 +1181,6 @@ impl<'de> Visitor<'de> for DocumentVisitor<'_> { DocumentKey::Components => { doc.components = map.next_value_seed(seed::(doc))?; } - DocumentKey::Commands => doc.commands = map.next_value()?, DocumentKey::Other => skip(&mut map)?, } } @@ -1182,38 +1291,37 @@ impl ReadObject for PathItem { } } -keys! { - /// The keys of a [`Definition`], in an Operation Object or an alias. - DefinitionKey { - MethodName = "x-fern-sdk-method-name", - GroupNames = "x-fern-sdk-group-name", - Ignore = "x-fern-ignore", - Hidden = "x-forge-hidden", - Confirmation = "x-forge-require-confirmation", - Availability = "x-fern-availability", - } -} - impl Definition { - /// Reads the value of `key`, the key of the entry `map` is reading. + /// Reads the value of the key `hit` says, the key of the entry `map` is + /// reading. A value of a key that is not native is kept only until a + /// native key gives one. fn read_value<'de, A: MapAccess<'de>>( &mut self, doc: &mut Doc, map: &mut A, - key: DefinitionKey, + hit: Hit, ) -> Result<(), A::Error> { - match key { - DefinitionKey::MethodName => self.method_name = map.next_value_seed(seed::(doc))?, - DefinitionKey::GroupNames => self.group_names = map.next_value_seed(seed::(doc))?, - DefinitionKey::Ignore => self.ignore = map.next_value()?, - DefinitionKey::Hidden => self.hidden = map.next_value()?, - DefinitionKey::Confirmation => { - self.confirmation = Some(map.next_value_seed(seed::(doc))?); + match hit.field { + DefinitionField::Method => { + let name = map.next_value_seed(seed::(doc))?; + self.method_name.set(&hit, name); } - DefinitionKey::Availability => { - self.availability = Some(map.next_value_seed(seed::(doc))?); + DefinitionField::Group(format) => { + let names = map.next_value_seed(GroupNames(doc, format))?; + self.group_names.set(&hit, names); + } + DefinitionField::Ignore(format) => { + let ignore = read_bool(map, format)?; + self.ignore.set(&hit, ignore); + } + DefinitionField::Availability => { + let availability = map.next_value_seed(seed::(doc))?; + self.availability.set(&hit, Some(availability)); + } + DefinitionField::Hidden => self.hidden = map.next_value()?, + DefinitionField::Confirmation => { + self.confirmation = Some(map.next_value_seed(seed::(doc))?); } - DefinitionKey::Other => skip(map)?, } Ok(()) } @@ -1236,9 +1344,9 @@ impl ReadObject for Alias { let mut extensions = Vec::new(); while let Some(field) = map.next_key::()? { match field { - AliasField::Definition(key) => { - key.once(&mut seen)?; - definition.read_value(doc, &mut map, key)?; + AliasField::Definition(hit) => { + resolve::once(&hit, &mut seen)?; + definition.read_value(doc, &mut map, hit)?; } AliasField::Extension(name) => json::insert(&mut extensions, name, map.next_value()?), AliasField::Other => skip(&mut map)?, @@ -1346,7 +1454,7 @@ impl<'de> Visitor<'de> for MaybeAlias<'_> { /// which is kept by its name, or any other key, which is skipped, as /// [`Alias`] says. enum AliasField { - Definition(DefinitionKey), + Definition(Hit), Extension(String), Other, } @@ -1363,13 +1471,13 @@ impl<'de> Deserialize<'de> for AliasField { } fn visit_str(self, v: &str) -> Result { - Ok(match DefinitionKey::from_key(v) { - DefinitionKey::Other if !v.starts_with("x-") => AliasField::Other, - DefinitionKey::Other => match OperationKey::from_key(v) { - OperationKey::Aliases => AliasField::Other, - _ => AliasField::Extension(v.to_owned()), - }, - key => AliasField::Definition(key), + if !v.starts_with("x-") { + return Ok(AliasField::Other); + } + Ok(match resolve::definition(v) { + Some(hit) => AliasField::Definition(hit), + None if metadata::is_aliases(v) => AliasField::Other, + None => AliasField::Extension(v.to_owned()), }) } } @@ -1380,7 +1488,6 @@ impl<'de> Deserialize<'de> for AliasField { keys! { OperationKey { - Aliases = "x-forge-aliases", Summary = "summary", Description = "description", Deprecated = "deprecated", @@ -1404,9 +1511,16 @@ impl ReadObject for Operation { while let Some(field) = map.next_key::()? { let key = match field { OperationField::Key(key) => key, - OperationField::Definition(key) => { - key.once(&mut seen_definition)?; - operation.definition.read_value(doc, &mut map, key)?; + OperationField::Definition(hit) => { + resolve::once(&hit, &mut seen_definition)?; + operation.definition.read_value(doc, &mut map, hit)?; + continue; + } + OperationField::Aliases => { + if operation.aliases.is_some() { + return Err(de::Error::duplicate_field(metadata::ALIASES)); + } + operation.aliases = Some(map.next_value_seed(seed::(doc))?); continue; } OperationField::Extension(name) => { @@ -1416,9 +1530,6 @@ impl ReadObject for Operation { }; key.once(&mut seen)?; match key { - OperationKey::Aliases => { - operation.aliases = Some(map.next_value_seed(seed::(doc))?); - } OperationKey::Summary => operation.summary = map.next_value_seed(seed::(doc))?, OperationKey::Description => { operation.description = map.next_value_seed(seed::(doc))?; @@ -1441,11 +1552,12 @@ impl ReadObject for Operation { } } -/// A key of an Operation Object: one of its definition, another that is -/// read, or an extension that is not, which is kept by its name. Any other -/// key is `OperationKey::Other`. +/// A key of an Operation Object: one of its definition, its aliases, +/// another that is read, or an extension that is not, which is kept by its +/// name. Any other key is `OperationKey::Other`. enum OperationField { - Definition(DefinitionKey), + Definition(Hit), + Aliases, Key(OperationKey), Extension(String), } @@ -1462,12 +1574,13 @@ impl<'de> Deserialize<'de> for OperationField { } fn visit_str(self, v: &str) -> Result { - match DefinitionKey::from_key(v) { - DefinitionKey::Other => {} - key => return Ok(OperationField::Definition(key)), - } + // The OpenAPI keys first, as for any other object. Ok(match OperationKey::from_key(v) { - OperationKey::Other if v.starts_with("x-") => OperationField::Extension(v.to_owned()), + OperationKey::Other if v.starts_with("x-") => match resolve::definition(v) { + Some(hit) => OperationField::Definition(hit), + None if metadata::is_aliases(v) => OperationField::Aliases, + None => OperationField::Extension(v.to_owned()), + }, key => OperationField::Key(key), }) } @@ -1489,10 +1602,11 @@ keys! { Explode = "explode", Schema = "schema", Content = "content", - FieldName = "x-fern-parameter-name", } } +entry!(ParameterEntry, ParameterKey, ParameterField, resolve::parameter); + impl ReadObject for Parameter { type Out = Id; const EXPECTING: &'static str = "a Parameter Object"; @@ -1500,7 +1614,23 @@ impl ReadObject for Parameter { fn read_object<'de, A: MapAccess<'de>>(doc: &mut Doc, mut map: A) -> Result, A::Error> { let mut param = Parameter::default(); let mut seen = 0; - while let Some(key) = map.next_key::()? { + let mut seen_extensions = 0; + while let Some(entry) = map.next_key::()? { + let key = match entry { + ParameterEntry::Extension(hit) => { + resolve::once(&hit, &mut seen_extensions)?; + match hit.field { + ParameterField::Name => { + let name = map.next_value_seed(seed::(doc))?; + if takes(&hit, &mut param.field_name_native) { + param.field_name = name; + } + } + } + continue; + } + ParameterEntry::Key(key) => key, + }; key.once(&mut seen)?; match key { ParameterKey::Ref => param.reference = map.next_value_seed(seed::(doc))?, @@ -1517,7 +1647,6 @@ impl ReadObject for Parameter { ParameterKey::Content => { param.content = map.next_value_seed(seed::>(doc))?; } - ParameterKey::FieldName => param.field_name = map.next_value_seed(seed::(doc))?, ParameterKey::Other => skip(&mut map)?, } } @@ -1619,13 +1748,11 @@ keys! { Default = "default", Deprecated = "deprecated", ReadOnly = "readOnly", - Sensitive = "x-sensitive", - ModelName = "x-fern-type-name", - FieldName = "x-fern-property-name", - Skip = "x-stainless-skip", } } +entry!(SchemaEntry, SchemaKey, SchemaField, resolve::schema); + impl ReadObject for SchemaObject { type Out = Id; const EXPECTING: &'static str = "a Schema Object"; @@ -1633,7 +1760,36 @@ impl ReadObject for SchemaObject { fn read_object<'de, A: MapAccess<'de>>(doc: &mut Doc, mut map: A) -> Result, A::Error> { let mut schema = SchemaObject::default(); let mut seen = 0; - while let Some(key) = map.next_key::()? { + let mut seen_extensions = 0; + while let Some(entry) = map.next_key::()? { + let key = match entry { + SchemaEntry::Extension(hit) => { + resolve::once(&hit, &mut seen_extensions)?; + match hit.field { + SchemaField::ModelName => { + let name = map.next_value_seed(seed::(doc))?; + if takes(&hit, &mut schema.model_name_native) { + schema.model_name = name; + } + } + SchemaField::PropertyName => { + let name = map.next_value_seed(seed::(doc))?; + if takes(&hit, &mut schema.field_name_native) { + schema.field_name = name; + } + } + SchemaField::Ignore(format) => { + let ignore = read_bool(&mut map, format)?; + if takes(&hit, &mut schema.ignore_native) { + schema.ignore = ignore; + } + } + SchemaField::Sensitive => schema.sensitive = map.next_value()?, + } + continue; + } + SchemaEntry::Key(key) => key, + }; key.once(&mut seen)?; match key { SchemaKey::Ref => schema.reference = map.next_value_seed(seed::(doc))?, @@ -1676,10 +1832,6 @@ impl ReadObject for SchemaObject { SchemaKey::Default => schema.default = Some(doc.add(map.next_value::()?)), SchemaKey::Deprecated => schema.deprecated = map.next_value()?, SchemaKey::ReadOnly => schema.read_only = map.next_value()?, - SchemaKey::Sensitive => schema.sensitive = map.next_value()?, - SchemaKey::ModelName => schema.model_name = map.next_value_seed(seed::(doc))?, - SchemaKey::FieldName => schema.field_name = map.next_value_seed(seed::(doc))?, - SchemaKey::Skip => schema.skip = map.next_value::()?.0, SchemaKey::Other => skip(&mut map)?, } } @@ -1783,32 +1935,32 @@ impl<'de> Deserialize<'de> for Count { } } -/// An `x-stainless-skip`: `true` or `false`, or a list of the targets to -/// skip for, which is not `true`. -struct StainlessSkip(bool); +/// A boolean that can also be written as a list of targets, which is not +/// `true`: the targets it would hold for are not read. +struct BoolOrTargets(bool); -impl<'de> Deserialize<'de> for StainlessSkip { - fn deserialize>(deserializer: D) -> Result { - struct SkipVisitor; +impl<'de> Deserialize<'de> for BoolOrTargets { + fn deserialize>(deserializer: D) -> Result { + struct TargetsVisitor; - impl<'de> Visitor<'de> for SkipVisitor { - type Value = StainlessSkip; + impl<'de> Visitor<'de> for TargetsVisitor { + type Value = BoolOrTargets; fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str("a boolean or an array of targets") } - fn visit_bool(self, v: bool) -> Result { - Ok(StainlessSkip(v)) + fn visit_bool(self, v: bool) -> Result { + Ok(BoolOrTargets(v)) } - fn visit_seq>(self, mut seq: A) -> Result { + fn visit_seq>(self, mut seq: A) -> Result { while let Some(IgnoredAny) = seq.next_element()? {} - Ok(StainlessSkip(false)) + Ok(BoolOrTargets(false)) } } - deserializer.deserialize_any(SkipVisitor) + deserializer.deserialize_any(TargetsVisitor) } } diff --git a/src/link/resolve.rs b/src/link/resolve.rs new file mode 100644 index 000000000..87b2db0bc --- /dev/null +++ b/src/link/resolve.rs @@ -0,0 +1,91 @@ +//! Resolving the `x-` keys of the objects in [`openapi`](super::openapi). +//! +//! A key is native, when [`metadata`] reads it, or else, if forge is built +//! with the `compat` feature, one the compatibility layer reads in its place. +//! That is the whole fallback: native first, and the compatibility layer for +//! a key that is not native. This is the only place the layer is called, so +//! leaving it out takes one cfg arm for each kind of object, here. +//! +//! Only `x-` keys are asked about. Every other key is OpenAPI's own, which +//! `openapi` knows. + +use serde::de; + +use crate::metadata::{self, DefinitionField, DocumentField, Hit, ParameterField, SchemaField}; + +/// Returns what `key` says on an operation or an alias, if it is a key of +/// one. +pub(super) fn definition(key: &str) -> Option> { + if !key.starts_with("x-") { + return None; + } + metadata::definition(key).or_else(|| compat_definition(key)) +} + +/// Returns what `key` says on a Parameter Object, if it is a key of one. +pub(super) fn parameter(key: &str) -> Option> { + if !key.starts_with("x-") { + return None; + } + metadata::parameter(key).or_else(|| compat_parameter(key)) +} + +/// Returns what `key` says on a Schema Object, if it is a key of one. +pub(super) fn schema(key: &str) -> Option> { + if !key.starts_with("x-") { + return None; + } + metadata::schema(key).or_else(|| compat_schema(key)) +} + +/// Returns what `key` says at the root of the document, if it is a key of +/// it. No compatibility key says anything there. +pub(super) fn document(key: &str) -> Option> { + if !key.starts_with("x-") { + return None; + } + metadata::document(key) +} + +/// Returns an error if the key of `hit` is in `seen`, which has a bit for +/// each key read so far from the object, and else adds it. A native key and +/// a compatibility key of the same meaning are different keys, so both can +/// be written: the native one's value is kept. +pub(super) fn once(hit: &Hit, seen: &mut u64) -> Result<(), E> { + let bit = 1u64 << hit.slot; + if *seen & bit != 0 { + return Err(E::duplicate_field(hit.key)); + } + *seen |= bit; + Ok(()) +} + +#[cfg(feature = "compat")] +fn compat_definition(key: &str) -> Option> { + crate::compat::definition(key) +} + +#[cfg(not(feature = "compat"))] +fn compat_definition(_: &str) -> Option> { + None +} + +#[cfg(feature = "compat")] +fn compat_parameter(key: &str) -> Option> { + crate::compat::parameter(key) +} + +#[cfg(not(feature = "compat"))] +fn compat_parameter(_: &str) -> Option> { + None +} + +#[cfg(feature = "compat")] +fn compat_schema(key: &str) -> Option> { + crate::compat::schema(key) +} + +#[cfg(not(feature = "compat"))] +fn compat_schema(_: &str) -> Option> { + None +} diff --git a/src/metadata/mod.rs b/src/metadata/mod.rs new file mode 100644 index 000000000..559869b8f --- /dev/null +++ b/src/metadata/mod.rs @@ -0,0 +1,306 @@ +//! The native extensions: the `x-forge-*` keys a document uses to say what a +//! `Forge` is built from, and what each of them means. +//! +//! This is the whole vocabulary, for each kind of object that has any: +//! [`definition`] for an Operation Object or an alias in its +//! `x-forge-aliases`, [`parameter`], [`schema`] and [`document`]. Each reads +//! a key as the document writes it, and says what it is as a [`Hit`], or +//! `None` if the key is not one of them. +//! +//! Nothing here reads a document or allocates: the keys come in as `&str`s, +//! and what goes out is small values, including the formats a key's value is +//! written in, as data. Reading the values, and building a `Forge` from +//! them, is the work of `link`, which asks this module first and a +//! compatibility layer, if forge is built with one, second; so a native key +//! always has the last word, and the compatibility layer can be left out +//! without changing what any native key means. + +use crate::Availability; + +/// The `x-forge-aliases` key of an Operation Object. It is not in +/// [`definition`], since an alias cannot have one. +pub(crate) const ALIASES: &str = "x-forge-aliases"; + +/// A key that says something about the object it is on. +#[derive(Clone, Copy)] +pub(crate) struct Hit { + /// What the key says. + pub(crate) field: F, + /// The key as the document writes it, for errors. + pub(crate) key: &'static str, + /// Whether the key is native. A value from a native key is kept over one + /// from any other, whatever order they are written in. + pub(crate) native: bool, + /// The bit of the key in a set of the keys read from an object so far, + /// which is how a key written twice is found. Unique among the keys of + /// one kind of object, native or not. + pub(crate) slot: u8, +} + +/// The first slot of the keys that are not native, so that slots never +/// collide with the native ones, which count up from zero. +#[cfg(feature = "compat")] +const COMPAT_SLOTS: u8 = 32; + +impl Hit { + /// Returns a native key, in slot `slot`, which is under 32. + const fn native(field: F, key: &'static str, slot: u8) -> Hit { + Hit { + field, + key, + native: true, + slot, + } + } + + /// Returns a key that is not native, which a compatibility layer + /// reads in place of a native one. It is the `n`th of its kind of + /// object, counting from zero, `n` under 32. + #[cfg(feature = "compat")] + pub(crate) const fn compat(field: F, key: &'static str, n: u8) -> Hit { + Hit { + field, + key, + native: false, + slot: COMPAT_SLOTS + n, + } + } +} + +/// What a key says on an operation, or on an alias of one. +#[derive(Clone, Copy)] +pub(crate) enum DefinitionField { + /// The name of the method. + Method, + /// The group path of the method. + Group(GroupFormat), + /// Whether the definition makes no method. + Ignore(BoolFormat), + /// The stage of the API the method is in. + Availability, + /// Whether the method is left out of listings. + Hidden, + /// The sentence to show before calling the method. + Confirmation, +} + +/// What a key says on a Parameter Object. +#[derive(Clone, Copy)] +pub(crate) enum ParameterField { + /// The name of the parameter's field. + Name, +} + +/// What a key says on a Schema Object. +#[derive(Clone, Copy)] +pub(crate) enum SchemaField { + /// The name of the model, for a schema in `components.schemas`. + ModelName, + /// The name of a property's field. + PropertyName, + /// Whether the property makes no field. + Ignore(BoolFormat), + /// Whether the schema's value is a secret. + Sensitive, +} + +/// What a key says at the root of the document. +#[derive(Clone, Copy)] +pub(crate) enum DocumentField { + /// The descriptions of the groups. + Commands, +} + +/// How the value of a group path is written. +#[derive(Clone, Copy)] +pub(crate) enum GroupFormat { + /// An array of group names, top level first. + Array, + /// An array of group names, or one string of them separated by + /// `separator`. The empty string is no group. + #[cfg_attr(not(feature = "compat"), expect(dead_code))] + StringOrArray { separator: char }, +} + +/// How the value of a boolean is written. +#[derive(Clone, Copy)] +pub(crate) enum BoolFormat { + /// `true` or `false`. + Bool, + /// `true` or `false`, or an array of targets, which counts as `false`. + #[cfg_attr(not(feature = "compat"), expect(dead_code))] + BoolOrTargets, +} + +/// Returns what `key` says on an operation or an alias, if it is a native +/// key of one. +pub(crate) fn definition(key: &str) -> Option> { + const METHOD: &str = "x-forge-method"; + const GROUP: &str = "x-forge-group"; + const IGNORE: &str = "x-forge-ignore"; + const AVAILABILITY: &str = "x-forge-availability"; + const HIDDEN: &str = "x-forge-hidden"; + const CONFIRMATION: &str = "x-forge-require-confirmation"; + Some(match key { + METHOD => Hit::native(DefinitionField::Method, METHOD, 0), + GROUP => Hit::native(DefinitionField::Group(GroupFormat::Array), GROUP, 1), + IGNORE => Hit::native(DefinitionField::Ignore(BoolFormat::Bool), IGNORE, 2), + AVAILABILITY => Hit::native(DefinitionField::Availability, AVAILABILITY, 3), + HIDDEN => Hit::native(DefinitionField::Hidden, HIDDEN, 4), + CONFIRMATION => Hit::native(DefinitionField::Confirmation, CONFIRMATION, 5), + _ => return None, + }) +} + +/// Returns whether `key` is the `x-forge-aliases` of an operation. +pub(crate) fn is_aliases(key: &str) -> bool { + key == ALIASES +} + +/// Returns what `key` says on a Parameter Object, if it is a native key of +/// one. +pub(crate) fn parameter(key: &str) -> Option> { + const NAME: &str = "x-forge-parameter-name"; + Some(match key { + NAME => Hit::native(ParameterField::Name, NAME, 0), + _ => return None, + }) +} + +/// Returns what `key` says on a Schema Object, if it is a native key of one. +pub(crate) fn schema(key: &str) -> Option> { + const MODEL_NAME: &str = "x-forge-model-name"; + const PROPERTY_NAME: &str = "x-forge-property-name"; + const IGNORE: &str = "x-forge-ignore"; + const SENSITIVE: &str = "x-sensitive"; + Some(match key { + MODEL_NAME => Hit::native(SchemaField::ModelName, MODEL_NAME, 0), + PROPERTY_NAME => Hit::native(SchemaField::PropertyName, PROPERTY_NAME, 1), + IGNORE => Hit::native(SchemaField::Ignore(BoolFormat::Bool), IGNORE, 2), + SENSITIVE => Hit::native(SchemaField::Sensitive, SENSITIVE, 3), + _ => return None, + }) +} + +/// Returns what `key` says at the root of the document, if it is a native +/// key of it. +pub(crate) fn document(key: &str) -> Option> { + const COMMANDS: &str = "x-forge-commands"; + Some(match key { + COMMANDS => Hit::native(DocumentField::Commands, COMMANDS, 0), + _ => return None, + }) +} + +/// Returns the availability `value` is, such as `beta`, or `None` if it is +/// none of them. Case matters. +pub(crate) fn availability(value: &str) -> Option { + match value { + "alpha" => Some(Availability::Alpha), + "beta" => Some(Availability::Beta), + "preview" => Some(Availability::Preview), + "generally-available" => Some(Availability::GenerallyAvailable), + "deprecated" => Some(Availability::Deprecated), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Every native key of each kind of object, as `(key, slot)`. + fn native_keys() -> [&'static [&'static str]; 4] { + [ + &[ + "x-forge-method", + "x-forge-group", + "x-forge-ignore", + "x-forge-availability", + "x-forge-hidden", + "x-forge-require-confirmation", + ], + &["x-forge-parameter-name"], + &[ + "x-forge-model-name", + "x-forge-property-name", + "x-forge-ignore", + "x-sensitive", + ], + &["x-forge-commands"], + ] + } + + #[test] + fn each_native_key_is_read_as_itself_and_as_native() { + let [definitions, parameters, schemas, documents] = native_keys(); + for &key in definitions { + let hit = definition(key).unwrap_or_else(|| panic!("{key} is not a definition key")); + assert_eq!((hit.key, hit.native), (key, true)); + } + for &key in parameters { + let hit = parameter(key).unwrap_or_else(|| panic!("{key} is not a parameter key")); + assert_eq!((hit.key, hit.native), (key, true)); + } + for &key in schemas { + let hit = schema(key).unwrap_or_else(|| panic!("{key} is not a schema key")); + assert_eq!((hit.key, hit.native), (key, true)); + } + for &key in documents { + let hit = document(key).unwrap_or_else(|| panic!("{key} is not a document key")); + assert_eq!((hit.key, hit.native), (key, true)); + } + } + + #[test] + fn slots_are_unique_among_the_keys_of_an_object_and_under_the_compat_ones() { + let [definitions, _, schemas, _] = native_keys(); + let slots = |keys: &[&str], hit: &dyn Fn(&str) -> u8| { + let mut slots: Vec = keys.iter().map(|key| hit(key)).collect(); + slots.sort_unstable(); + let len = slots.len(); + slots.dedup(); + assert_eq!(slots.len(), len, "duplicate slot among {keys:?}"); + assert!(slots.iter().all(|&slot| slot < 32), "native slots are under 32"); + }; + slots(definitions, &|key| definition(key).map_or(u8::MAX, |hit| hit.slot)); + slots(schemas, &|key| schema(key).map_or(u8::MAX, |hit| hit.slot)); + } + + #[test] + fn other_keys_are_not_native() { + for key in [ + "x-other-method-name", + "x-other-skip", + "x-custom", + "summary", + "x-forge-nope", + ] { + assert!(definition(key).is_none(), "{key}"); + assert!(parameter(key).is_none(), "{key}"); + assert!(schema(key).is_none(), "{key}"); + assert!(document(key).is_none(), "{key}"); + } + } + + #[test] + fn aliases_are_not_a_definition_key() { + assert!(is_aliases("x-forge-aliases")); + assert!(definition("x-forge-aliases").is_none()); + } + + #[test] + fn availability_is_one_of_five_spellings_and_case_matters() { + for (value, availability) in [ + ("alpha", Availability::Alpha), + ("beta", Availability::Beta), + ("preview", Availability::Preview), + ("generally-available", Availability::GenerallyAvailable), + ("deprecated", Availability::Deprecated), + ] { + assert_eq!(super::availability(value), Some(availability)); + } + assert_eq!(super::availability("Beta"), None); + assert_eq!(super::availability("ga"), None); + } +} diff --git a/src/plugin/wasm.rs b/src/plugin/wasm.rs index 97220a570..6d5729cd7 100644 --- a/src/plugin/wasm.rs +++ b/src/plugin/wasm.rs @@ -143,7 +143,7 @@ interface forge { json: option, } - /// The stage of the API a method is in, from `x-fern-availability`. + /// The stage of the API a method is in, from `x-forge-availability`. enum availability { alpha, beta, preview, generally-available, deprecated, } diff --git a/src/types.rs b/src/types.rs index 34aab173f..2386d8467 100644 --- a/src/types.rs +++ b/src/types.rs @@ -293,7 +293,7 @@ impl<'f> Object<'f> { /// except in a [`Method::request`](crate::Method::request) and in /// [`Method::headers`](crate::Method::headers). /// - /// A property marked `x-stainless-skip: true`, or whose schema is + /// A property marked `x-forge-ignore: true`, or whose schema is /// `false`, has no field. A name in `required` that is not a property is /// ignored. Two fields with equal names are an error. pub fn fields(self) -> impl ExactSizeIterator> { @@ -330,7 +330,7 @@ impl<'f> Field<'f> { /// Returns the name of the field. /// - /// Corresponds to the `x-fern-property-name` extension of the + /// Corresponds to the `x-forge-property-name` extension of the /// property, or its wire name if it has none. pub fn name(self) -> &'f Name { &self.forge.names[self.data.name.index()] @@ -416,7 +416,7 @@ impl<'f> Field<'f> { /// `readOnly`, an `x-sensitive` next to a `$ref` counts in every /// version of OpenAPI, since tools commonly honour extensions there. A /// value that is not a boolean is an error, as it is for a schema's - /// `x-stainless-skip` and an operation's `x-forge-hidden`. + /// `x-forge-ignore` and an operation's `x-forge-hidden`. /// /// A field for a parameter, whether in the request or in /// [`Method::headers`](crate::Method::headers), is sensitive when the @@ -509,7 +509,7 @@ impl<'f> Discriminator<'f> { } /// Returns the name of the property's field: the - /// `x-fern-property-name` of the property in the members, or else its + /// `x-forge-property-name` of the property in the members, or else its /// wire name. pub fn name(self) -> &'f Name { &self.data.name diff --git a/tests/compat/common.rs b/tests/compat/common.rs new file mode 100644 index 000000000..4649783ea --- /dev/null +++ b/tests/compat/common.rs @@ -0,0 +1,105 @@ +//! Helpers to build documents and to compare what two of them make, so that +//! each test is about one thing. + +use forge::{Forge, Type}; + +/// Parses `json` as a whole document. +#[track_caller] +pub fn parse(json: &str) -> Forge { + match serde_json::from_str(json) { + Ok(forge) => forge, + Err(err) => panic!("parse: {err}"), + } +} + +/// Parses `json`, which must fail, and returns the error message. +#[track_caller] +pub fn parse_err(json: &str) -> String { + match serde_json::from_str::(json) { + Ok(_) => panic!("parse: want an error, got a Forge"), + Err(err) => err.to_string(), + } +} + +/// Returns an OpenAPI 3.0.3 document with one operation, `GET /a`, that has +/// the members `keys`, written as the inside of a JSON object, each followed +/// by a comma, such as `"x-forge-method": "get",`. +pub fn operation_with(keys: &str) -> String { + format!( + r#"{{"openapi": "3.0.3", "info": {{"title": "t", "version": "1"}}, + "paths": {{"/a": {{"get": {{{keys} "responses": {{"204": {{"description": "ok"}}}}}}}}}}}}"# + ) +} + +/// Returns an OpenAPI 3.0.3 document with one method, `a.get`, whose JSON +/// response has the schema `schema`, and `components.schemas` of `schemas`. +pub fn responding_with(schema: &str, schemas: &str) -> String { + format!( + r#"{{"openapi": "3.0.3", "info": {{"title": "t", "version": "1"}}, + "paths": {{"/a": {{"get": {{"x-forge-group": ["a"], "x-forge-method": "get", + "responses": {{"200": {{"description": "ok", "content": {{"application/json": {{"schema": {schema}}}}}}}}}}}}}}}, + "components": {{"schemas": {schemas}}}}}"# + ) +} + +/// Asserts that `a` and `b` build the same `Forge`, as far as `describe` +/// says. +#[track_caller] +pub fn assert_same(a: &str, b: &str) { + let (a, b) = (describe(&parse(a)), describe(&parse(b))); + assert_eq!(a, b, "the documents build different Forges"); +} + +/// Writes what a `Forge` says about every group, method and model, down to +/// the name of each field, so that two `Forge`s that differ in any name, +/// availability or shape differ in what this returns. +pub fn describe(forge: &Forge) -> String { + let mut out = String::new(); + for group in forge.groups() { + let path: Vec = group.path().map(|name| name.snake()).collect(); + out += &format!("group {}\n", path.join(".")); + for method in group.methods() { + let request = method.request().map_or_else(|| "-".to_string(), shape); + let response = method + .responses() + .map(|response| response.json().map_or_else(|| "-".to_string(), |json| shape(json.ty()))) + .collect::>() + .join(","); + out += &format!( + " method {} {} {} availability={:?} hidden={} request={request} response={response}\n", + method.name().snake(), + method.http_method().as_str(), + method.path(), + method.availability().map(|availability| availability.as_str()), + method.is_hidden(), + ); + } + for model in group.models() { + out += &format!(" model {} = {}\n", model.name().snake(), shape(model.ty())); + } + } + for model in forge.models() { + out += &format!("model {} = {}\n", model.name().snake(), shape(model.ty())); + } + out +} + +/// Writes a type, with the wire name and name of every field, and a model by +/// name. +fn shape(ty: Type<'_>) -> String { + match ty { + Type::Scalar(scalar) => format!("{scalar:?}"), + Type::Enum(values) => format!("enum{values:?}"), + Type::Array(array) => format!("[{}]", shape(array.elem())), + Type::Object(object) => { + let fields: Vec = object + .fields() + .map(|field| format!("{}:{}={}", field.wire_name(), field.name().snake(), shape(field.ty()))) + .collect(); + format!("{{{}}}", fields.join(",")) + } + Type::Union(union) => union.members().map(shape).collect::>().join("|"), + Type::Intersection(intersection) => intersection.members().map(shape).collect::>().join("&"), + Type::Model(model) => format!("Model({})", model.name().snake()), + } +} diff --git a/tests/compat/fern.rs b/tests/compat/fern.rs new file mode 100644 index 000000000..3f7eff586 --- /dev/null +++ b/tests/compat/fern.rs @@ -0,0 +1,280 @@ +//! Fern's `x-fern-*` extensions: each is read as the native extension that +//! says the same, and as nothing else. + +use super::common::{assert_same, describe, operation_with, parse, parse_err, responding_with}; + +#[test] +fn method_group_and_availability_are_read_as_native() { + assert_same( + &operation_with( + r#""x-fern-sdk-group-name": ["accounts"], "x-fern-sdk-method-name": "list", "x-fern-availability": "beta","#, + ), + &operation_with(r#""x-forge-group": ["accounts"], "x-forge-method": "list", "x-forge-availability": "beta","#), + ); +} + +#[test] +fn every_availability_is_read() { + for availability in ["alpha", "beta", "preview", "generally-available", "deprecated"] { + assert_same( + &operation_with(&format!( + r#""x-fern-sdk-group-name": ["a"], "x-fern-sdk-method-name": "get", "x-fern-availability": "{availability}","# + )), + &operation_with(&format!( + r#""x-forge-group": ["a"], "x-forge-method": "get", "x-forge-availability": "{availability}","# + )), + ); + } +} + +#[test] +fn a_group_written_as_a_string_is_split_on_dots() { + assert_same( + &operation_with(r#""x-fern-sdk-group-name": "a.b.c", "x-fern-sdk-method-name": "get","#), + &operation_with(r#""x-forge-group": ["a", "b", "c"], "x-forge-method": "get","#), + ); +} + +#[test] +fn a_group_written_as_an_array_is_not_split() { + assert_same( + &operation_with(r#""x-fern-sdk-group-name": ["a.b", "c"], "x-fern-sdk-method-name": "get","#), + &operation_with(r#""x-forge-group": ["a.b", "c"], "x-forge-method": "get","#), + ); +} + +#[test] +fn an_empty_group_string_is_no_group_and_so_no_method() { + let forge = parse(&operation_with( + r#""x-fern-sdk-group-name": "", "x-fern-sdk-method-name": "get","#, + )); + + assert_eq!(forge.groups().count(), 0); +} + +#[test] +fn a_group_that_is_neither_a_string_nor_an_array_is_an_error() { + let err = parse_err(&operation_with( + r#""x-fern-sdk-group-name": 1, "x-fern-sdk-method-name": "get","#, + )); + + assert!(err.contains("a string or an array of strings"), "{err}"); +} + +#[test] +fn ignore_makes_no_method() { + assert_same( + &operation_with(r#""x-fern-sdk-group-name": ["a"], "x-fern-sdk-method-name": "get", "x-fern-ignore": true,"#), + &operation_with(r#""x-forge-group": ["a"], "x-forge-method": "get", "x-forge-ignore": true,"#), + ); + assert_eq!( + parse(&operation_with( + r#""x-fern-sdk-group-name": ["a"], "x-fern-sdk-method-name": "get", "x-fern-ignore": true,"# + )) + .groups() + .count(), + 0 + ); +} + +#[test] +fn ignore_set_to_false_keeps_the_method() { + assert_same( + &operation_with(r#""x-fern-sdk-group-name": ["a"], "x-fern-sdk-method-name": "get", "x-fern-ignore": false,"#), + &operation_with(r#""x-forge-group": ["a"], "x-forge-method": "get","#), + ); +} + +#[test] +fn a_parameter_name_is_read_as_native() { + let parameters = |key: &str| { + operation_with(&format!( + r#""x-forge-group": ["a"], "x-forge-method": "list", + "parameters": [{{"name": "q", "in": "query", "{key}": "query", "schema": {{"type": "string"}}}}],"# + )) + }; + + assert_same( + ¶meters("x-fern-parameter-name"), + ¶meters("x-forge-parameter-name"), + ); + assert!(describe(&parse(¶meters("x-fern-parameter-name"))).contains("q:query=")); +} + +#[test] +fn a_property_name_is_read_as_native() { + let schema = |key: &str| { + responding_with( + &format!(r#"{{"type": "object", "properties": {{"owner": {{"type": "string", "{key}": "person"}}}}}}"#), + "{}", + ) + }; + + assert_same(&schema("x-fern-property-name"), &schema("x-forge-property-name")); + assert!(describe(&parse(&schema("x-fern-property-name"))).contains("owner:person=")); +} + +#[test] +fn a_type_name_is_read_as_the_name_of_a_model() { + let schemas = |key: &str| { + responding_with( + r##"{"$ref": "#/components/schemas/not_found"}"##, + &format!(r#"{{"not_found": {{"type": "object", "{key}": "Missing"}}}}"#), + ) + }; + + assert_same(&schemas("x-fern-type-name"), &schemas("x-forge-model-name")); + assert!(describe(&parse(&schemas("x-fern-type-name"))).contains("model missing =")); +} + +#[test] +fn the_keys_of_an_alias_are_read_too() { + let alias = |group: &str, method: &str, availability: &str, ignore: &str| { + operation_with(&format!( + r#""x-forge-aliases": [{{"{group}": {}, "{method}": "pause", "{availability}": "beta", "{ignore}": false}}],"#, + if group.starts_with("x-fern") { + r#""a.b""# + } else { + r#"["a", "b"]"# + }, + )) + }; + + assert_same( + &alias( + "x-fern-sdk-group-name", + "x-fern-sdk-method-name", + "x-fern-availability", + "x-fern-ignore", + ), + &alias( + "x-forge-group", + "x-forge-method", + "x-forge-availability", + "x-forge-ignore", + ), + ); +} + +#[test] +fn a_key_that_is_read_is_not_an_extension_of_the_method() { + let forge = parse(&operation_with( + r#""x-fern-sdk-group-name": ["a"], "x-fern-sdk-method-name": "get", "x-fern-ignore": false, + "x-fern-availability": "beta", "x-a": 1,"#, + )); + let method = forge.groups().next().unwrap().methods().next().unwrap(); + + assert_eq!(method.extensions().map(|(name, _)| name).collect::>(), ["x-a"]); + for key in [ + "x-fern-sdk-group-name", + "x-fern-sdk-method-name", + "x-fern-ignore", + "x-fern-availability", + ] { + assert_eq!(method.extension(key), None, "{key}"); + } +} + +#[test] +fn a_key_that_is_not_read_stays_an_extension_of_the_method() { + let forge = parse(&operation_with( + r#""x-forge-group": ["a"], "x-forge-method": "get", "x-fern-audiences": ["internal"], "x-fern-pagination": {"offset": "o"},"#, + )); + let method = forge.groups().next().unwrap().methods().next().unwrap(); + + assert_eq!( + method.extensions().map(|(name, _)| name).collect::>(), + ["x-fern-audiences", "x-fern-pagination"] + ); +} + +#[test] +fn an_extension_on_a_schema_that_is_not_read_has_no_effect() { + let with = responding_with( + r#"{"type": "string", "x-fern-enum": {"a": {"name": "A"}}, "x-fern-audiences": ["x"]}"#, + "{}", + ); + let without = responding_with(r#"{"type": "string"}"#, "{}"); + + assert_same(&with, &without); +} + +#[test] +fn an_error_names_the_key_the_document_wrote() { + let err = parse_err(&operation_with( + r#""x-fern-sdk-group-name": ["a"], "x-fern-sdk-method-name": "get", "x-fern-availability": "ga","#, + )); + assert!(err.contains(r#"GET /a: x-fern-availability "ga" is not"#), "{err}"); + + let err = parse_err(&operation_with( + r#""x-fern-sdk-group-name": "accounts..members", "x-fern-sdk-method-name": "get","#, + )); + assert!( + err.contains("GET /a: x-fern-sdk-group-name has a group name with no letters or digits"), + "{err}" + ); + + let err = parse_err(&operation_with( + r#""x-fern-sdk-group-name": ["a"], "x-fern-sdk-method-name": "-","#, + )); + assert!( + err.contains("GET /a: x-fern-sdk-method-name has no letters or digits"), + "{err}" + ); +} + +#[test] +fn an_error_in_an_alias_names_the_key_the_document_wrote() { + let err = parse_err(&operation_with( + r#""x-forge-aliases": [1, {"x-fern-sdk-group-name": ["a"], "x-fern-sdk-method-name": "pause", "x-fern-availability": "ga"}],"#, + )); + + assert!( + err.contains(r#"x-forge-aliases[1]: x-fern-availability "ga" is not"#), + "{err}" + ); +} + +#[test] +fn a_key_written_twice_is_an_error_naming_it() { + for (keys, key) in [ + (r#""x-fern-ignore": false, "x-fern-ignore": false,"#, "x-fern-ignore"), + ( + r#""x-fern-sdk-method-name": "a", "x-fern-sdk-method-name": "b","#, + "x-fern-sdk-method-name", + ), + ( + r#""x-fern-availability": "beta", "x-fern-availability": "beta","#, + "x-fern-availability", + ), + ( + r#""x-fern-sdk-group-name": ["a"], "x-fern-sdk-group-name": ["b"],"#, + "x-fern-sdk-group-name", + ), + ] { + let err = parse_err(&operation_with(keys)); + + assert!(err.contains(&format!("duplicate field `{key}`")), "{key}: {err}"); + } +} + +#[test] +fn the_vendor_keys_of_a_kind_of_object_are_read_only_there() { + // A name key on an operation, or a method key on a schema, says nothing. + let forge = parse(&operation_with( + r#""x-forge-group": ["a"], "x-forge-method": "get", "x-fern-property-name": "p", "x-fern-type-name": "T", + "x-fern-parameter-name": "q","#, + )); + let method = forge.groups().next().unwrap().methods().next().unwrap(); + assert_eq!(method.extensions().count(), 3); + + let schema = responding_with( + r#"{"type": "object", "properties": {"a": {"type": "string", "x-fern-sdk-method-name": "m", "x-fern-ignore": true}}}"#, + "{}", + ); + assert!( + describe(&parse(&schema)).contains("a:a="), + "{}", + describe(&parse(&schema)) + ); +} diff --git a/tests/compat/main.rs b/tests/compat/main.rs new file mode 100644 index 000000000..c44b04ea4 --- /dev/null +++ b/tests/compat/main.rs @@ -0,0 +1,15 @@ +//! Tests for the compatibility layer: the keys other tools write, read as the +//! native extensions say the same things. +//! +//! Each primitive is tested the same way: a document written with a tool's +//! keys must build the same `Forge` as its native twin. The tests that only +//! the compatibility layer can pass, such as a value format native keys do +//! not have, or a key a tool writes that forge does not read, are written out. +//! +//! They only build with the `compat` feature. The suite of `tests/parse`, +//! which has no vendor names in it, runs without it. + +mod common; +mod fern; +mod native_first; +mod stainless; diff --git a/tests/compat/native_first.rs b/tests/compat/native_first.rs new file mode 100644 index 000000000..9e97fe767 --- /dev/null +++ b/tests/compat/native_first.rs @@ -0,0 +1,196 @@ +//! A native key is applied, and the compatibility layer is the fallback: when +//! a document has both, the compat value is ignored, whatever order they are +//! written in and whatever the compat value is, so long as it is a value at +//! all. + +use super::common::{assert_same, describe, operation_with, parse, parse_err, responding_with}; + +/// Asserts that every ordering of `native` and `compat`, which are members of +/// an operation, builds what `expected` alone does. +#[track_caller] +fn native_wins(native: &str, compat: &str, expected: &str) { + for keys in [format!("{native} {compat}"), format!("{compat} {native}")] { + assert_same(&operation_with(&keys), &operation_with(expected)); + } +} + +#[test] +fn the_native_method_wins() { + native_wins( + r#""x-forge-group": ["a"], "x-forge-method": "native","#, + r#""x-fern-sdk-method-name": "vendor","#, + r#""x-forge-group": ["a"], "x-forge-method": "native","#, + ); + assert!( + describe(&parse(&operation_with( + r#""x-forge-group": ["a"], "x-forge-method": "native", "x-fern-sdk-method-name": "vendor","# + ))) + .contains("method native") + ); +} + +#[test] +fn the_native_group_wins() { + native_wins( + r#""x-forge-method": "get", "x-forge-group": ["native"],"#, + r#""x-fern-sdk-group-name": "vendor.group","#, + r#""x-forge-method": "get", "x-forge-group": ["native"],"#, + ); +} + +#[test] +fn the_native_availability_wins_and_an_invalid_compat_one_is_not_checked() { + native_wins( + r#""x-forge-group": ["a"], "x-forge-method": "get", "x-forge-availability": "beta","#, + r#""x-fern-availability": "ga","#, + r#""x-forge-group": ["a"], "x-forge-method": "get", "x-forge-availability": "beta","#, + ); +} + +#[test] +fn a_native_ignore_of_false_keeps_the_method_a_compat_one_would_drop() { + native_wins( + r#""x-forge-group": ["a"], "x-forge-method": "get", "x-forge-ignore": false,"#, + r#""x-fern-ignore": true,"#, + r#""x-forge-group": ["a"], "x-forge-method": "get","#, + ); +} + +#[test] +fn a_native_ignore_of_true_drops_the_method_a_compat_one_would_keep() { + native_wins( + r#""x-forge-group": ["a"], "x-forge-method": "get", "x-forge-ignore": true,"#, + r#""x-fern-ignore": false,"#, + r#""x-forge-group": ["a"], "x-forge-method": "get", "x-forge-ignore": true,"#, + ); +} + +#[test] +fn the_native_parameter_name_wins() { + let parameter = |keys: &str| { + operation_with(&format!( + r#""x-forge-group": ["a"], "x-forge-method": "list", + "parameters": [{{"name": "q", "in": "query", {keys} "schema": {{"type": "string"}}}}],"# + )) + }; + let native = parameter(r#""x-forge-parameter-name": "native","#); + + for keys in [ + r#""x-forge-parameter-name": "native", "x-fern-parameter-name": "vendor","#, + r#""x-fern-parameter-name": "vendor", "x-forge-parameter-name": "native","#, + ] { + assert_same(¶meter(keys), &native); + } + assert!(describe(&parse(&native)).contains("q:native=")); +} + +#[test] +fn the_native_property_and_model_names_win() { + let schema = |keys: &str| { + responding_with( + &format!(r#"{{"type": "object", "properties": {{"owner": {{"type": "string", {keys}}}}}}}"#), + "{}", + ) + }; + let native = schema(r#""x-forge-property-name": "native""#); + + for keys in [ + r#""x-forge-property-name": "native", "x-fern-property-name": "vendor""#, + r#""x-fern-property-name": "vendor", "x-forge-property-name": "native""#, + ] { + assert_same(&schema(keys), &native); + } + + let model = |keys: &str| { + responding_with( + r##"{"$ref": "#/components/schemas/m"}"##, + &format!(r#"{{"m": {{"type": "object", {keys}}}}}"#), + ) + }; + for keys in [ + r#""x-forge-model-name": "Native", "x-fern-type-name": "Vendor""#, + r#""x-fern-type-name": "Vendor", "x-forge-model-name": "Native""#, + ] { + assert_same(&model(keys), &model(r#""x-forge-model-name": "Native""#)); + } +} + +#[test] +fn the_native_ignore_of_a_property_wins_over_a_skip() { + let property = |keys: &str| { + responding_with( + &format!(r#"{{"type": "object", "properties": {{"a": {{"type": "string", {keys}}}}}}}"#), + "{}", + ) + }; + for keys in [ + r#""x-forge-ignore": false, "x-stainless-skip": true"#, + r#""x-stainless-skip": true, "x-forge-ignore": false"#, + ] { + assert!(describe(&parse(&property(keys))).contains("a:a="), "{keys}"); + } + for keys in [ + r#""x-forge-ignore": true, "x-stainless-skip": false"#, + r#""x-stainless-skip": false, "x-forge-ignore": true"#, + ] { + assert!(!describe(&parse(&property(keys))).contains("a:a="), "{keys}"); + } +} + +#[test] +fn both_keys_with_the_same_value_are_the_same_as_one() { + assert_same( + &operation_with( + r#""x-forge-group": ["a", "b"], "x-fern-sdk-group-name": "a.b", + "x-forge-method": "get", "x-fern-sdk-method-name": "get", + "x-forge-availability": "beta", "x-fern-availability": "beta","#, + ), + &operation_with(r#""x-forge-group": ["a", "b"], "x-forge-method": "get", "x-forge-availability": "beta","#), + ); +} + +#[test] +fn each_primitive_is_resolved_on_its_own() { + // The group is native and the method and availability are not. + assert_same( + &operation_with(r#""x-forge-group": ["a"], "x-fern-sdk-method-name": "get", "x-fern-availability": "beta","#), + &operation_with(r#""x-forge-group": ["a"], "x-forge-method": "get", "x-forge-availability": "beta","#), + ); +} + +#[test] +fn a_compat_value_of_the_wrong_type_is_an_error_even_when_a_native_key_wins() { + for keys in [ + r#""x-forge-availability": "beta", "x-fern-availability": 1,"#, + r#""x-fern-availability": 1, "x-forge-availability": "beta","#, + ] { + let err = parse_err(&operation_with(&format!( + r#""x-forge-group": ["a"], "x-forge-method": "get", {keys}"# + ))); + + assert!(err.contains("invalid type"), "{keys}: {err}"); + } +} + +#[test] +fn a_native_key_written_twice_is_an_error_naming_it() { + let err = parse_err(&operation_with(r#""x-forge-ignore": false, "x-forge-ignore": false,"#)); + + assert!(err.contains("duplicate field `x-forge-ignore`"), "{err}"); +} + +#[test] +fn a_native_group_written_as_a_string_is_an_error() { + let err = parse_err(&operation_with(r#""x-forge-group": "a.b", "x-forge-method": "get","#)); + + assert!(err.contains("an array of strings"), "{err}"); +} + +#[test] +fn an_error_names_the_native_key_when_the_native_key_gave_the_value() { + let err = parse_err(&operation_with( + r#""x-forge-group": ["a"], "x-forge-method": "get", "x-forge-availability": "ga","#, + )); + + assert!(err.contains(r#"GET /a: x-forge-availability "ga" is not"#), "{err}"); +} diff --git a/tests/compat/stainless.rs b/tests/compat/stainless.rs new file mode 100644 index 000000000..496a2124a --- /dev/null +++ b/tests/compat/stainless.rs @@ -0,0 +1,90 @@ +//! Stainless's `x-stainless-skip`: a property that makes no field, written as +//! a boolean or as a list of targets. + +use super::common::{assert_same, describe, operation_with, parse, parse_err, responding_with}; + +fn with_skip(value: &str) -> String { + responding_with( + &format!( + r#"{{"type": "object", "properties": {{"id": {{"type": "string"}}, "old_id": {{"type": "string", "x-stainless-skip": {value}}}}}}}"# + ), + "{}", + ) +} + +fn with_ignore(value: &str) -> String { + responding_with( + &format!( + r#"{{"type": "object", "properties": {{"id": {{"type": "string"}}, "old_id": {{"type": "string", "x-forge-ignore": {value}}}}}}}"# + ), + "{}", + ) +} + +#[test] +fn a_skipped_property_is_read_as_an_ignored_one() { + assert_same(&with_skip("true"), &with_ignore("true")); + assert!(!describe(&parse(&with_skip("true"))).contains("old_id")); +} + +#[test] +fn a_property_skipped_as_false_keeps_its_field() { + assert_same(&with_skip("false"), &with_ignore("false")); + assert!(describe(&parse(&with_skip("false"))).contains("old_id")); +} + +#[test] +fn a_list_of_targets_is_not_a_skip() { + assert_same(&with_skip(r#"["typescript", "go"]"#), &with_ignore("false")); + assert!(describe(&parse(&with_skip(r#"["typescript"]"#))).contains("old_id")); + assert!(describe(&parse(&with_skip("[]"))).contains("old_id")); +} + +#[test] +fn a_skip_that_is_not_a_boolean_or_a_list_is_an_error() { + let err = parse_err(&with_skip(r#""yes""#)); + + assert!(err.contains("a boolean or an array of targets"), "{err}"); +} + +#[test] +fn a_list_of_targets_is_an_error_for_the_native_key() { + let err = parse_err(&with_ignore(r#"["typescript"]"#)); + + assert!(err.contains("invalid type"), "{err}"); +} + +#[test] +fn a_skip_written_twice_is_an_error_naming_it() { + let schema = responding_with( + r#"{"type": "object", "properties": {"a": {"type": "string", "x-stainless-skip": false, "x-stainless-skip": false}}}"#, + "{}", + ); + + assert!(parse_err(&schema).contains("duplicate field `x-stainless-skip`")); +} + +#[test] +fn a_skip_on_an_operation_says_nothing_and_stays_an_extension() { + let forge = parse(&operation_with( + r#""x-forge-group": ["a"], "x-forge-method": "get", "x-stainless-skip": true,"#, + )); + let method = forge.groups().next().unwrap().methods().next().unwrap(); + + assert_eq!( + method.extensions().map(|(name, _)| name).collect::>(), + ["x-stainless-skip"] + ); +} + +#[test] +fn the_other_stainless_keys_are_not_read() { + let with = responding_with( + r#"{"type": "object", "x-stainless-terraform-configurability": "computed", + "properties": {"a": {"type": "string", "x-stainless-deprecation-message": "gone", "x-stainless-collection-type": "set"}}}"#, + "{}", + ); + let without = responding_with(r#"{"type": "object", "properties": {"a": {"type": "string"}}}"#, "{}"); + + assert_same(&with, &without); +} diff --git a/tests/wasm/ts/wit/forge.wit b/tests/wasm/ts/wit/forge.wit index af93aa10e..7a489f74e 100644 --- a/tests/wasm/ts/wit/forge.wit +++ b/tests/wasm/ts/wit/forge.wit @@ -82,7 +82,7 @@ interface forge { json: option, } - /// The stage of the API a method is in, from `x-fern-availability`. + /// The stage of the API a method is in, from `x-forge-availability`. enum availability { alpha, beta, preview, generally-available, deprecated, } From 96c2266928c075a203c06a61be54095099b4c971 Mon Sep 17 00:00:00 2001 From: Niels Swimberghe Date: Sun, 25 Oct 2026 22:57:32 -0400 Subject: [PATCH 04/25] test: write the fixtures with the native keys x-fern-* and x-stainless-skip become their x-forge-* equivalents; a dotted group string becomes an array. What only the compat layer does is covered by tests/compat. --- src/plugin/ts/tests.rs | 6 +- tests/cli.rs | 2 +- tests/parse/aliases.rs | 81 +++++++++--------- tests/parse/bodies.rs | 28 +++---- tests/parse/common.rs | 4 +- tests/parse/document.rs | 4 +- tests/parse/extensions.rs | 34 ++++---- tests/parse/groups.rs | 71 ++++++++-------- tests/parse/headers.rs | 52 ++++++------ tests/parse/methods.rs | 68 +++++++-------- tests/parse/models.rs | 40 ++++----- tests/parse/requests.rs | 130 ++++++++++++++--------------- tests/parse/responses.rs | 12 +-- tests/parse/types/arrays.rs | 4 +- tests/parse/types/enums.rs | 2 +- tests/parse/types/intersections.rs | 4 +- tests/parse/types/nulls.rs | 8 +- tests/parse/types/objects.rs | 10 +-- tests/parse/types/refs.rs | 18 ++-- tests/parse/types/scalars.rs | 6 +- tests/parse/types/unions.rs | 10 +-- tests/wasm.rs | 20 ++--- 22 files changed, 308 insertions(+), 306 deletions(-) diff --git a/src/plugin/ts/tests.rs b/src/plugin/ts/tests.rs index f6c095bde..bf7b8c80c 100644 --- a/src/plugin/ts/tests.rs +++ b/src/plugin/ts/tests.rs @@ -370,11 +370,11 @@ fn models() { r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": { - "/a": {"get": {"x-fern-sdk-group-name": ["pet-store", "list_all"], "x-fern-sdk-method-name": "get", + "/a": {"get": {"x-forge-group": ["pet-store", "list_all"], "x-forge-method": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/pet_owner"}}}}}}}, - "/b": {"get": {"x-fern-sdk-group-name": "pet-store", "x-fern-sdk-method-name": "get", + "/b": {"get": {"x-forge-group": ["pet-store"], "x-forge-method": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/2fa"}}}}}}}, - "/c": {"get": {"x-fern-sdk-group-name": "other", "x-fern-sdk-method-name": "get", + "/c": {"get": {"x-forge-group": ["other"], "x-forge-method": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/2fa"}}}}}}} }, "components": {"schemas": {"pet_owner": {"type": "string"}, "2fa": {"type": "string"}}} diff --git a/tests/cli.rs b/tests/cli.rs index 6b85c5fc4..f59b30727 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -6,7 +6,7 @@ use std::process::{Command, Output, Stdio}; /// A document with one method, `a.get`. const SPEC: &str = r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", "responses": {"200": {"description": "ok"}}}}} }"#; diff --git a/tests/parse/aliases.rs b/tests/parse/aliases.rs index eb299ef5f..c36579065 100644 --- a/tests/parse/aliases.rs +++ b/tests/parse/aliases.rs @@ -10,7 +10,7 @@ fn aliased(aliases: &str, fields: &str) -> String { r#"{{ "openapi": "3.0.3", "info": {{"title": "t", "version": "1"}}, "paths": {{"/a/{{id}}/status": {{"patch": {{{fields} - "x-fern-sdk-group-name": ["a", "status"], "x-fern-sdk-method-name": "edit", + "x-forge-group": ["a", "status"], "x-forge-method": "edit", "x-forge-aliases": {aliases}, "summary": "Edit status", "description": "Changes the status.", "parameters": [{{"name": "id", "in": "path", "required": true, "schema": {{"type": "string"}}}}], @@ -39,8 +39,8 @@ fn methods(forge: &Forge) -> Vec { } const PAUSE_AND_TERMINATE: &str = r#"[ - {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "pause"}, - {"x-fern-sdk-group-name": ["a"], "x-fern-sdk-method-name": "terminate"} + {"x-forge-group": ["a"], "x-forge-method": "pause"}, + {"x-forge-group": ["a"], "x-forge-method": "terminate"} ]"#; #[test] @@ -77,11 +77,11 @@ fn aliases_share_the_rest_of_the_operation() { fn each_alias_has_its_own_availability_hidden_confirmation_and_extensions() { let forge = parse(&aliased( r#"[ - {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "pause", - "x-fern-availability": "generally-available", "x-forge-hidden": true, + {"x-forge-group": ["a"], "x-forge-method": "pause", + "x-forge-availability": "generally-available", "x-forge-hidden": true, "x-forge-params": {"status": {"default": "pause", "hidden": true}}}, - {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "terminate", - "x-fern-availability": "beta", "x-forge-require-confirmation": "Sure?", "x-a": 1, "x-b": 2} + {"x-forge-group": ["a"], "x-forge-method": "terminate", + "x-forge-availability": "beta", "x-forge-require-confirmation": "Sure?", "x-a": 1, "x-b": 2} ]"#, "", )); @@ -111,8 +111,8 @@ fn each_alias_has_its_own_availability_hidden_confirmation_and_extensions() { #[test] fn alias_does_not_fall_back_to_the_operation() { let forge = parse(&aliased( - r#"[{"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "pause"}]"#, - r#""x-fern-availability": "beta", "x-forge-hidden": true, "x-forge-require-confirmation": "Sure?", + r#"[{"x-forge-group": ["a"], "x-forge-method": "pause"}]"#, + r#""x-forge-availability": "beta", "x-forge-hidden": true, "x-forge-require-confirmation": "Sure?", "x-forge-params": {"status": {}},"#, )); @@ -128,10 +128,10 @@ fn alias_does_not_fall_back_to_the_operation() { fn alias_without_a_group_or_name_is_not_a_method() { let forge = parse(&aliased( r#"[ - {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "pause"}, - {"x-fern-sdk-group-name": "a"}, - {"x-fern-sdk-method-name": "resume"}, - {"x-fern-sdk-group-name": "", "x-fern-sdk-method-name": "restart"} + {"x-forge-group": ["a"], "x-forge-method": "pause"}, + {"x-forge-group": ["a"]}, + {"x-forge-method": "resume"}, + {"x-forge-group": [], "x-forge-method": "restart"} ]"#, "", )); @@ -142,7 +142,7 @@ fn alias_without_a_group_or_name_is_not_a_method() { #[test] fn element_that_is_not_an_object_is_ignored() { let forge = parse(&aliased( - r#"["pause", null, 1, true, ["a"], {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "pause"}]"#, + r#"["pause", null, 1, true, ["a"], {"x-forge-group": ["a"], "x-forge-method": "pause"}]"#, "", )); @@ -158,10 +158,7 @@ fn empty_aliases_make_no_method() { #[test] fn aliases_that_are_not_an_array_are_an_error() { - let err = parse_err(&aliased( - r#"{"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "pause"}"#, - "", - )); + let err = parse_err(&aliased(r#"{"x-forge-group": ["a"], "x-forge-method": "pause"}"#, "")); assert!(err.contains("an array of aliases"), "{err}"); } @@ -172,11 +169,11 @@ fn alias_and_another_method_with_equal_names_are_an_error() { r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": { - "/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "pause", + "/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "pause", "responses": {"204": {"description": "ok"}}}}, "/b": {"post": {"x-forge-aliases": [ - {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "resume"}, - {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "Pause"} + {"x-forge-group": ["a"], "x-forge-method": "resume"}, + {"x-forge-group": ["a"], "x-forge-method": "Pause"} ], "responses": {"204": {"description": "ok"}}}} } }"#, @@ -192,8 +189,8 @@ fn alias_and_another_method_with_equal_names_are_an_error() { fn two_aliases_with_equal_names_are_an_error() { let err = parse_err(&aliased( r#"[ - {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "pause"}, - {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "pause"} + {"x-forge-group": ["a"], "x-forge-method": "pause"}, + {"x-forge-group": ["a"], "x-forge-method": "pause"} ]"#, "", )); @@ -207,12 +204,12 @@ fn two_aliases_with_equal_names_are_an_error() { #[test] fn alias_group_name_without_letters_is_an_error_naming_the_alias() { let err = parse_err(&aliased( - r#"[{"x-fern-sdk-group-name": "a..b", "x-fern-sdk-method-name": "pause"}]"#, + r#"[{"x-forge-group": ["a", "", "b"], "x-forge-method": "pause"}]"#, "", )); assert!( - err.contains("PATCH /a/{id}/status: x-forge-aliases[0]: x-fern-sdk-group-name"), + err.contains("PATCH /a/{id}/status: x-forge-aliases[0]: x-forge-group"), "{err}" ); } @@ -220,12 +217,12 @@ fn alias_group_name_without_letters_is_an_error_naming_the_alias() { #[test] fn alias_availability_is_checked() { let err = parse_err(&aliased( - r#"[1, {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "pause", "x-fern-availability": "ga"}]"#, + r#"[1, {"x-forge-group": ["a"], "x-forge-method": "pause", "x-forge-availability": "ga"}]"#, "", )); assert!( - err.contains(r#"x-forge-aliases[1]: x-fern-availability "ga" is not"#), + err.contains(r#"x-forge-aliases[1]: x-forge-availability "ga" is not"#), "{err}" ); } @@ -233,16 +230,16 @@ fn alias_availability_is_checked() { #[test] fn repeated_key_forge_reads_in_an_alias_is_an_error() { let err = parse_err(&aliased( - r#"[{"x-fern-sdk-method-name": "pause", "x-fern-sdk-method-name": "resume"}]"#, + r#"[{"x-forge-method": "pause", "x-forge-method": "resume"}]"#, "", )); - assert!(err.contains("duplicate field `x-fern-sdk-method-name`"), "{err}"); + assert!(err.contains("duplicate field `x-forge-method`"), "{err}"); } #[test] fn ignored_operation_still_has_its_aliases() { - let forge = parse(&aliased(PAUSE_AND_TERMINATE, r#""x-fern-ignore": true,"#)); + let forge = parse(&aliased(PAUSE_AND_TERMINATE, r#""x-forge-ignore": true,"#)); assert_eq!(methods(&forge), ["a.pause", "a.terminate"]); } @@ -251,8 +248,8 @@ fn ignored_operation_still_has_its_aliases() { fn ignored_alias_is_not_a_method() { let forge = parse(&aliased( r#"[ - {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "pause", "x-fern-ignore": true}, - {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "terminate", "x-fern-ignore": false} + {"x-forge-group": ["a"], "x-forge-method": "pause", "x-forge-ignore": true}, + {"x-forge-group": ["a"], "x-forge-method": "terminate", "x-forge-ignore": false} ]"#, "", )); @@ -263,8 +260,8 @@ fn ignored_alias_is_not_a_method() { #[test] fn aliases_are_not_extensions() { let forge = parse(&aliased( - r#"[{"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "pause", "x-a": 1, - "x-forge-aliases": [{"x-fern-sdk-group-name": "b", "x-fern-sdk-method-name": "nested"}]}]"#, + r#"[{"x-forge-group": ["a"], "x-forge-method": "pause", "x-a": 1, + "x-forge-aliases": [{"x-forge-group": ["b"], "x-forge-method": "nested"}]}]"#, "", )); @@ -277,7 +274,7 @@ fn aliases_are_not_extensions() { #[test] fn keys_of_an_alias_that_are_not_extensions_are_ignored() { let forge = parse(&aliased( - r#"[{"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "pause", + r#"[{"x-forge-group": ["a"], "x-forge-method": "pause", "summary": "Pause", "deprecated": false, "path": "/b"}]"#, "", )); @@ -293,7 +290,7 @@ fn operation_without_aliases_keeps_its_own_method() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", "responses": {"204": {"description": "ok"}}}}} }"#, ); @@ -307,10 +304,10 @@ fn model_used_by_aliases_in_two_top_level_groups_is_at_the_top_level() { r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": {"/m": {"get": { - "x-fern-sdk-group-name": "a.b", "x-fern-sdk-method-name": "get", + "x-forge-group": ["a", "b"], "x-forge-method": "get", "x-forge-aliases": [ - {"x-fern-sdk-group-name": "a.b", "x-fern-sdk-method-name": "get"}, - {"x-fern-sdk-group-name": "x", "x-fern-sdk-method-name": "get"} + {"x-forge-group": ["a", "b"], "x-forge-method": "get"}, + {"x-forge-group": ["x"], "x-forge-method": "get"} ], "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/M"}}}}}}}}, "components": {"schemas": {"M": {"type": "object", "properties": {"id": {"type": "string"}}}}} @@ -328,10 +325,10 @@ fn model_used_by_aliases_in_one_group_is_in_that_group() { r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": {"/m": {"get": { - "x-fern-sdk-group-name": "x", "x-fern-sdk-method-name": "get", + "x-forge-group": ["x"], "x-forge-method": "get", "x-forge-aliases": [ - {"x-fern-sdk-group-name": "a.b", "x-fern-sdk-method-name": "get"}, - {"x-fern-sdk-group-name": "a.c", "x-fern-sdk-method-name": "get"} + {"x-forge-group": ["a", "b"], "x-forge-method": "get"}, + {"x-forge-group": ["a", "c"], "x-forge-method": "get"} ], "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/M"}}}}}}}}, "components": {"schemas": {"M": {"type": "object", "properties": {"id": {"type": "string"}}}}} diff --git a/tests/parse/bodies.rs b/tests/parse/bodies.rs index 4b09e2967..cb96a46e5 100644 --- a/tests/parse/bodies.rs +++ b/tests/parse/bodies.rs @@ -21,7 +21,7 @@ fn body_has_its_required_and_description() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"post": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "create", + "paths": {"/a": {"post": {"x-forge-group": ["a"], "x-forge-method": "create", "requestBody": {"required": true, "description": "The pet.", "content": {"application/json": {"schema": {"type": "string"}}}}, "responses": {"204": {"description": "ok"}}}}} @@ -38,7 +38,7 @@ fn body_is_optional_by_default() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"post": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "create", + "paths": {"/a": {"post": {"x-forge-group": ["a"], "x-forge-method": "create", "requestBody": {"content": {"application/json": {"schema": {"type": "string"}}}}, "responses": {"204": {"description": "ok"}}}}} }"#, @@ -52,7 +52,7 @@ fn contents_are_sorted_by_media_type() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"post": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "create", + "paths": {"/a": {"post": {"x-forge-group": ["a"], "x-forge-method": "create", "requestBody": {"content": { "text/plain": {"schema": {"type": "string"}}, "multipart/form-data": {"schema": {"type": "object"}}, @@ -73,7 +73,7 @@ fn json_is_application_json_before_other_json_types() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"post": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "create", + "paths": {"/a": {"post": {"x-forge-group": ["a"], "x-forge-method": "create", "requestBody": {"content": { "application/geo+json": {"schema": {"type": "object"}}, "application/json": {"schema": {"type": "string"}} @@ -90,7 +90,7 @@ fn json_is_otherwise_the_first_json_type() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"patch": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "create", + "paths": {"/a": {"patch": {"x-forge-group": ["a"], "x-forge-method": "create", "requestBody": {"content": { "multipart/form-data": {"schema": {"type": "object"}}, "application/scim+json": {"schema": {"type": "object"}}, @@ -111,7 +111,7 @@ fn json_ignores_case_and_parameters() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"post": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "create", + "paths": {"/a": {"post": {"x-forge-group": ["a"], "x-forge-method": "create", "requestBody": {"content": {"Application/JSON; charset=utf-8": {"schema": {"type": "string"}}}}, "responses": {"204": {"description": "ok"}}}}} }"#, @@ -127,7 +127,7 @@ fn multipart_content_is_typed() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"post": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "create", + "paths": {"/a": {"post": {"x-forge-group": ["a"], "x-forge-method": "create", "requestBody": {"content": {"multipart/form-data": {"schema": { "type": "object", "required": ["file"], "properties": { "file": {"type": "string", "format": "binary"}, @@ -151,7 +151,7 @@ fn octet_stream_content_of_a_binary_string_is_binary() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"post": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "create", + "paths": {"/a": {"post": {"x-forge-group": ["a"], "x-forge-method": "create", "requestBody": {"content": {"application/octet-stream": {"schema": { "type": "string", "format": "binary" }}}}, @@ -169,7 +169,7 @@ fn json_ignores_content_that_is_not_json() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"post": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "create", + "paths": {"/a": {"post": {"x-forge-group": ["a"], "x-forge-method": "create", "requestBody": {"content": { "multipart/form-data": {"schema": {"type": "object", "properties": {"name": {"type": "string"}}}}, "text/plain": {"schema": {"type": "string"}} @@ -189,7 +189,7 @@ fn content_that_is_not_json_without_a_schema_is_unknown() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"post": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "create", + "paths": {"/a": {"post": {"x-forge-group": ["a"], "x-forge-method": "create", "requestBody": {"content": {"application/octet-stream": {}}}, "responses": {"204": {"description": "ok"}}}}} }"#, @@ -204,7 +204,7 @@ fn content_without_a_schema_is_unknown() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"post": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "create", + "paths": {"/a": {"post": {"x-forge-group": ["a"], "x-forge-method": "create", "requestBody": {"content": {"application/json": {}}}, "responses": {"204": {"description": "ok"}}}}} }"#, @@ -219,7 +219,7 @@ fn body_ref_is_followed() { let forge = parse( r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"post": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "create", + "paths": {"/a": {"post": {"x-forge-group": ["a"], "x-forge-method": "create", "requestBody": {"$ref": "#/components/requestBodies/Pet"}, "responses": {"204": {"description": "ok"}}}}}, "components": {"requestBodies": { @@ -239,7 +239,7 @@ fn with_body_schema(version: &str, schema: &str, schemas: &str) -> String { format!( r#"{{ "openapi": "{version}", "info": {{"title": "t", "version": "1"}}, - "paths": {{"/a": {{"post": {{"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "create", + "paths": {{"/a": {{"post": {{"x-forge-group": ["a"], "x-forge-method": "create", "requestBody": {{"content": {{"application/json": {{"schema": {schema}}}}}}}, "responses": {{"204": {{"description": "ok"}}}}}}}}}}, "components": {{"schemas": {schemas}}} @@ -301,7 +301,7 @@ fn response_content_has_its_schema_description() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", "responses": {"200": {"description": "ok", "content": {"application/json": { "schema": {"type": "string", "description": "The name."}}}}}}}} }"#, diff --git a/tests/parse/common.rs b/tests/parse/common.rs index ed8854cd2..39d178462 100644 --- a/tests/parse/common.rs +++ b/tests/parse/common.rs @@ -19,7 +19,7 @@ pub fn responding_with(schema: &str) -> String { format!( r#"{{ "openapi": "3.0.3", "info": {{"title": "t", "version": "1"}}, - "paths": {{"/a": {{"get": {{"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "paths": {{"/a": {{"get": {{"x-forge-group": ["a"], "x-forge-method": "get", "responses": {{"200": {{"description": "ok", "content": {{"application/json": {{"schema": {schema}}}}}}}}}}}}}}} }}"# ) @@ -31,7 +31,7 @@ pub fn responding_with_schemas(schema: &str, schemas: &str) -> String { format!( r#"{{ "openapi": "3.0.3", "info": {{"title": "t", "version": "1"}}, - "paths": {{"/a": {{"get": {{"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "paths": {{"/a": {{"get": {{"x-forge-group": ["a"], "x-forge-method": "get", "responses": {{"200": {{"description": "ok", "content": {{"application/json": {{"schema": {schema}}}}}}}}}}}}}}}, "components": {{"schemas": {schemas}}} }}"# diff --git a/tests/parse/document.rs b/tests/parse/document.rs index 282c6744b..6bfec98fa 100644 --- a/tests/parse/document.rs +++ b/tests/parse/document.rs @@ -103,7 +103,7 @@ fn every_version_of_3_0_3_1_and_3_2_is_read() { let forge = parse(&format!( r#"{{ "openapi": "{version}", "info": {{"title": "t", "version": "1"}}, - "paths": {{"/a": {{"get": {{"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "paths": {{"/a": {{"get": {{"x-forge-group": ["a"], "x-forge-method": "get", "responses": {{"204": {{"description": "ok"}}}}}}}}}} }}"# )); @@ -118,7 +118,7 @@ fn errors_are_reported_together() { r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "servers": [{"url": "https://{region}.example.com"}], - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a..b", "x-fern-sdk-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a", "", "b"], "x-forge-method": "get", "responses": {"204": {"description": "ok"}}}}} }"#, ); diff --git a/tests/parse/extensions.rs b/tests/parse/extensions.rs index f626036f4..9726355af 100644 --- a/tests/parse/extensions.rs +++ b/tests/parse/extensions.rs @@ -9,7 +9,7 @@ fn operation_with(fields: &str) -> String { r#"{{ "openapi": "3.0.3", "info": {{"title": "t", "version": "1"}}, "paths": {{"/a": {{"get": {{{fields} - "x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "x-forge-group": ["a"], "x-forge-method": "get", "responses": {{"204": {{"description": "ok"}}}}}}}}}} }}"# ) @@ -71,7 +71,7 @@ fn method_without_extensions_has_none() { let get = method(&forge, "a.get"); assert_eq!(get.extensions().len(), 0); - assert_eq!(get.extension("x-fern-sdk-method-name"), None); + assert_eq!(get.extension("x-forge-method"), None); } #[test] @@ -85,24 +85,24 @@ fn keys_that_are_not_extensions_are_not_kept() { #[test] fn extensions_forge_reads_are_not_kept() { - let forge = parse(&operation_with(r#""x-fern-ignore": false, "x-a": 1,"#)); + let forge = parse(&operation_with(r#""x-forge-ignore": false, "x-a": 1,"#)); let get = method(&forge, "a.get"); assert_eq!(names(&forge, "a.get"), ["x-a"]); - assert_eq!(get.extension("x-fern-sdk-group-name"), None); - assert_eq!(get.extension("x-fern-ignore"), None); + assert_eq!(get.extension("x-forge-group"), None); + assert_eq!(get.extension("x-forge-ignore"), None); } #[test] fn availability_hidden_and_confirmation_are_not_kept() { let forge = parse(&operation_with( - r#""x-a": 1, "x-fern-availability": "beta", "x-forge-hidden": true, + r#""x-a": 1, "x-forge-availability": "beta", "x-forge-hidden": true, "x-forge-require-confirmation": "Sure?", "x-b": 2,"#, )); let get = method(&forge, "a.get"); assert_eq!(names(&forge, "a.get"), ["x-a", "x-b"]); - assert_eq!(get.extension("x-fern-availability"), None); + assert_eq!(get.extension("x-forge-availability"), None); assert_eq!(get.extension("x-forge-hidden"), None); assert_eq!(get.extension("x-forge-require-confirmation"), None); } @@ -113,9 +113,9 @@ fn extensions_forge_reads_are_still_read() { r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": {"/a": { - "get": {"x-fern-sdk-group-name": ["a", "b"], "x-fern-sdk-method-name": "list", "x-a": 1, + "get": {"x-forge-group": ["a", "b"], "x-forge-method": "list", "x-a": 1, "responses": {"204": {"description": "ok"}}}, - "post": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "create", "x-fern-ignore": true, + "post": {"x-forge-group": ["a"], "x-forge-method": "create", "x-forge-ignore": true, "x-a": 1, "responses": {"204": {"description": "ok"}}} }} }"#, @@ -136,9 +136,9 @@ fn repeated_extension_has_the_last_value_in_the_place_of_the_first() { #[test] fn repeated_extension_forge_reads_is_still_an_error() { - let err = parse_err(&operation_with(r#""x-fern-ignore": false, "x-fern-ignore": false,"#)); + let err = parse_err(&operation_with(r#""x-forge-ignore": false, "x-forge-ignore": false,"#)); - assert!(err.contains("duplicate field `x-fern-ignore`"), "{err}"); + assert!(err.contains("duplicate field `x-forge-ignore`"), "{err}"); } #[test] @@ -147,11 +147,11 @@ fn each_method_keeps_its_own_extensions() { r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": { - "/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", "x-a": "get", + "/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", "x-a": "get", "responses": {"204": {"description": "ok"}}}}, - "/b": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "list", + "/b": {"get": {"x-forge-group": ["a"], "x-forge-method": "list", "responses": {"204": {"description": "ok"}}}}, - "/c": {"post": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "create", "x-a": "create", + "/c": {"post": {"x-forge-group": ["a"], "x-forge-method": "create", "x-a": "create", "x-b": [], "responses": {"204": {"description": "ok"}}}} } }"#, @@ -170,7 +170,7 @@ fn extensions_outside_the_operation_are_not_kept() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "x-root": 1, - "paths": {"/a": {"x-path-item": 1, "get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "paths": {"/a": {"x-path-item": 1, "get": {"x-forge-group": ["a"], "x-forge-method": "get", "parameters": [{"name": "q", "in": "query", "x-parameter": 1, "schema": {"type": "string", "x-schema": 1}}], "responses": {"204": {"description": "ok", "x-response": 1}}}}} }"#, @@ -186,11 +186,11 @@ fn operation_of_a_referenced_path_item_keeps_its_extensions() { "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, "paths": { "/a": {"$ref": "#/components/pathItems/A"}, - "/b": {"get": {"x-fern-sdk-group-name": "b", "x-fern-sdk-method-name": "get", "x-b": 2, + "/b": {"get": {"x-forge-group": ["b"], "x-forge-method": "get", "x-b": 2, "responses": {"204": {"description": "ok"}}}} }, "components": {"pathItems": { - "A": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", "x-a": 1, + "A": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", "x-a": 1, "responses": {"204": {"description": "ok"}}}} }} }"##, diff --git a/tests/parse/groups.rs b/tests/parse/groups.rs index 18618c70e..b5d6f918a 100644 --- a/tests/parse/groups.rs +++ b/tests/parse/groups.rs @@ -7,7 +7,7 @@ fn string_group_name_splits_on_dots() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "accounts.members", "x-fern-sdk-method-name": "list", + "paths": {"/a": {"get": {"x-forge-group": ["accounts", "members"], "x-forge-method": "list", "responses": {"204": {"description": "ok"}}}}} }"#, ); @@ -21,7 +21,7 @@ fn array_group_name_has_a_group_per_element() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": ["accounts", "members"], "x-fern-sdk-method-name": "list", + "paths": {"/a": {"get": {"x-forge-group": ["accounts", "members"], "x-forge-method": "list", "responses": {"204": {"description": "ok"}}}}} }"#, ); @@ -35,7 +35,7 @@ fn array_element_with_a_dot_is_one_group() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": ["zones.settings"], "x-fern-sdk-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group": ["zones.settings"], "x-forge-method": "get", "responses": {"204": {"description": "ok"}}}}} }"#, ); @@ -54,9 +54,9 @@ fn groups_with_equal_names_are_one_group() { r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": { - "/a": {"get": {"x-fern-sdk-group-name": "abuse-reports", "x-fern-sdk-method-name": "list", + "/a": {"get": {"x-forge-group": ["abuse-reports"], "x-forge-method": "list", "responses": {"204": {"description": "ok"}}}}, - "/b": {"get": {"x-fern-sdk-group-name": ["abuse_reports"], "x-fern-sdk-method-name": "get", + "/b": {"get": {"x-forge-group": ["abuse_reports"], "x-forge-method": "get", "responses": {"204": {"description": "ok"}}}} } }"#, @@ -72,9 +72,9 @@ fn subgroup_and_method_can_share_a_name() { r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": { - "/a": {"delete": {"x-fern-sdk-group-name": "members", "x-fern-sdk-method-name": "delete", + "/a": {"delete": {"x-forge-group": ["members"], "x-forge-method": "delete", "responses": {"204": {"description": "ok"}}}}, - "/b": {"post": {"x-fern-sdk-group-name": "members.delete", "x-fern-sdk-method-name": "bulk", + "/b": {"post": {"x-forge-group": ["members", "delete"], "x-forge-method": "bulk", "responses": {"204": {"description": "ok"}}}} } }"#, @@ -89,7 +89,7 @@ fn operation_without_a_group_is_not_a_method() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-method-name": "get", + "paths": {"/a": {"get": {"x-forge-method": "get", "responses": {"204": {"description": "ok"}}}}} }"#, ); @@ -102,7 +102,7 @@ fn empty_group_name_is_no_group() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "", "x-fern-sdk-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group": [], "x-forge-method": "get", "responses": {"204": {"description": "ok"}}}}} }"#, ); @@ -115,7 +115,7 @@ fn operation_without_a_method_name_is_not_a_method() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "responses": {"204": {"description": "ok"}}}}} }"#, ); @@ -128,7 +128,7 @@ fn empty_method_name_is_not_a_method() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "", "responses": {"204": {"description": "ok"}}}}} }"#, ); @@ -142,9 +142,9 @@ fn ignored_operation_is_not_a_method() { r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": {"/a": { - "get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "list", + "get": {"x-forge-group": ["a"], "x-forge-method": "list", "responses": {"204": {"description": "ok"}}}, - "post": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "create", "x-fern-ignore": true, + "post": {"x-forge-group": ["a"], "x-forge-method": "create", "x-forge-ignore": true, "responses": {"204": {"description": "ok"}}} }} }"#, @@ -159,7 +159,7 @@ fn group_name_without_letters_is_an_error() { let err = parse_err( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "accounts..members", "x-fern-sdk-method-name": "list", + "paths": {"/a": {"get": {"x-forge-group": ["accounts", "", "members"], "x-forge-method": "list", "responses": {"204": {"description": "ok"}}}}} }"#, ); @@ -173,9 +173,9 @@ fn duplicate_method_in_a_group_is_an_error() { r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": { - "/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "list", + "/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "list", "responses": {"204": {"description": "ok"}}}}, - "/b": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "list", + "/b": {"get": {"x-forge-group": ["a"], "x-forge-method": "list", "responses": {"204": {"description": "ok"}}}} } }"#, @@ -190,9 +190,9 @@ fn method_names_are_equal_as_names() { r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": { - "/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "list_all", + "/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "list_all", "responses": {"204": {"description": "ok"}}}}, - "/b": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "listAll", + "/b": {"get": {"x-forge-group": ["a"], "x-forge-method": "listAll", "responses": {"204": {"description": "ok"}}}} } }"#, @@ -207,11 +207,11 @@ fn groups_are_depth_first() { r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": { - "/a": {"get": {"x-fern-sdk-group-name": "a.b.c", "x-fern-sdk-method-name": "get", + "/a": {"get": {"x-forge-group": ["a", "b", "c"], "x-forge-method": "get", "responses": {"204": {"description": "ok"}}}}, - "/b": {"get": {"x-fern-sdk-group-name": "x", "x-fern-sdk-method-name": "get", + "/b": {"get": {"x-forge-group": ["x"], "x-forge-method": "get", "responses": {"204": {"description": "ok"}}}}, - "/c": {"get": {"x-fern-sdk-group-name": "a.d", "x-fern-sdk-method-name": "get", + "/c": {"get": {"x-forge-group": ["a", "d"], "x-forge-method": "get", "responses": {"204": {"description": "ok"}}}} } }"#, @@ -241,7 +241,7 @@ fn group_description_comes_from_x_forge_commands() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "accounts", "x-fern-sdk-method-name": "list", + "paths": {"/a": {"get": {"x-forge-group": ["accounts"], "x-forge-method": "list", "responses": {"204": {"description": "ok"}}}}}, "x-forge-commands": {"accounts": {"description": "Accounts."}} }"#, @@ -255,7 +255,7 @@ fn subgroup_description_is_in_its_parents_command() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "accounts.members", "x-fern-sdk-method-name": "list", + "paths": {"/a": {"get": {"x-forge-group": ["accounts", "members"], "x-forge-method": "list", "responses": {"204": {"description": "ok"}}}}}, "x-forge-commands": {"accounts": {"groups": {"members": {"description": "Members."}}}} }"#, @@ -271,8 +271,13 @@ fn with_commands(groups: &[&str], commands: &str) -> String { .iter() .enumerate() .map(|(i, group)| { + let group = group + .split('.') + .map(|name| format!("{name:?}")) + .collect::>() + .join(", "); format!( - r#""/{i}": {{"get": {{"x-fern-sdk-group-name": "{group}", "x-fern-sdk-method-name": "list", + r#""/{i}": {{"get": {{"x-forge-group": [{group}], "x-forge-method": "list", "responses": {{"204": {{"description": "ok"}}}}}}}}"# ) }) @@ -341,7 +346,7 @@ fn command_matches_a_group_with_an_equal_name() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "abuse_reports", "x-fern-sdk-method-name": "list", + "paths": {"/a": {"get": {"x-forge-group": ["abuse_reports"], "x-forge-method": "list", "responses": {"204": {"description": "ok"}}}}}, "x-forge-commands": {"abuse-reports": {"description": "Abuse reports."}} }"#, @@ -355,7 +360,7 @@ fn group_without_a_command_has_no_description() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "accounts", "x-fern-sdk-method-name": "list", + "paths": {"/a": {"get": {"x-forge-group": ["accounts"], "x-forge-method": "list", "responses": {"204": {"description": "ok"}}}}}, "x-forge-commands": {"zones": {"description": "Zones."}} }"#, @@ -384,13 +389,13 @@ fn groups_are_sorted_by_name() { r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": { - "/a": {"get": {"x-fern-sdk-group-name": "zones", "x-fern-sdk-method-name": "get", + "/a": {"get": {"x-forge-group": ["zones"], "x-forge-method": "get", "responses": {"204": {"description": "ok"}}}}, - "/b": {"get": {"x-fern-sdk-group-name": "accounts.members", "x-fern-sdk-method-name": "get", + "/b": {"get": {"x-forge-group": ["accounts", "members"], "x-forge-method": "get", "responses": {"204": {"description": "ok"}}}}, - "/c": {"get": {"x-fern-sdk-group-name": "dns", "x-fern-sdk-method-name": "get", + "/c": {"get": {"x-forge-group": ["dns"], "x-forge-method": "get", "responses": {"204": {"description": "ok"}}}}, - "/d": {"get": {"x-fern-sdk-group-name": "accounts.billing", "x-fern-sdk-method-name": "get", + "/d": {"get": {"x-forge-group": ["accounts", "billing"], "x-forge-method": "get", "responses": {"204": {"description": "ok"}}}} } }"#, @@ -412,11 +417,11 @@ fn methods_are_sorted_by_name() { r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": {"/a": { - "get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "list", + "get": {"x-forge-group": ["a"], "x-forge-method": "list", "responses": {"204": {"description": "ok"}}}, - "post": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "create", + "post": {"x-forge-group": ["a"], "x-forge-method": "create", "responses": {"204": {"description": "ok"}}}, - "delete": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "delete", + "delete": {"x-forge-group": ["a"], "x-forge-method": "delete", "responses": {"204": {"description": "ok"}}} }} }"#, diff --git a/tests/parse/headers.rs b/tests/parse/headers.rs index 49f34a22a..1ab012b02 100644 --- a/tests/parse/headers.rs +++ b/tests/parse/headers.rs @@ -17,7 +17,7 @@ fn header_param_is_a_field() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", "parameters": [ {"name": "X-Request-ID", "in": "header", "required": true, "description": "Traces the request.", "schema": {"type": "integer", "format": "int64"}}, @@ -43,8 +43,8 @@ fn header_field_is_named_by_x_fern_parameter_name() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", - "parameters": [{"name": "cf-r2-jurisdiction", "in": "header", "x-fern-parameter-name": "jurisdiction", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", + "parameters": [{"name": "cf-r2-jurisdiction", "in": "header", "x-forge-parameter-name": "jurisdiction", "schema": {"type": "string"}}], "responses": {"204": {"description": "ok"}}}}} }"#, @@ -59,7 +59,7 @@ fn header_description_is_the_params_or_else_its_schemas() { let forge = parse( r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", "parameters": [ {"name": "X-Own", "in": "header", "description": "Param.", "schema": {"$ref": "#/components/schemas/Id"}}, {"name": "X-Inherited", "in": "header", "schema": {"$ref": "#/components/schemas/Id"}}, @@ -81,7 +81,7 @@ fn deprecated_header_is_a_deprecated_field() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", "parameters": [{"name": "X-Old", "in": "header", "deprecated": true, "schema": {"type": "string"}}], "responses": {"204": {"description": "ok"}}}}} }"#, @@ -95,7 +95,7 @@ fn header_is_sensitive_when_its_schema_is_and_never_read_only() { let forge = parse( r##"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", "parameters": [ {"name": "X-Api-Key", "in": "header", "schema": {"type": "string", "x-sensitive": true, "readOnly": true}}, {"name": "X-Auth-Token", "in": "header", "schema": {"$ref": "#/components/schemas/Token"}}, @@ -119,7 +119,7 @@ fn header_has_the_default_of_its_schema() { let forge = parse( r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", "parameters": [ {"name": "X-Version", "in": "header", "schema": {"type": "string", "default": "2024-01-01"}}, {"name": "X-Retries", "in": "header", "schema": {"$ref": "#/components/schemas/Retries"}}, @@ -144,7 +144,7 @@ fn header_param_ref_is_followed() { let forge = parse( r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"post": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "create", + "paths": {"/a": {"post": {"x-forge-group": ["a"], "x-forge-method": "create", "parameters": [{"$ref": "#/components/parameters/IdempotencyKey"}], "responses": {"204": {"description": "ok"}}}}}, "components": {"parameters": {"IdempotencyKey": {"name": "Idempotency-Key", "in": "header", @@ -163,8 +163,8 @@ fn path_item_headers_belong_to_each_operation() { "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": {"/a": { "parameters": [{"name": "X-Account", "in": "header", "schema": {"type": "string"}}], - "get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", "responses": {"204": {"description": "ok"}}}, - "delete": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "delete", "responses": {"204": {"description": "ok"}}} + "get": {"x-forge-group": ["a"], "x-forge-method": "get", "responses": {"204": {"description": "ok"}}}, + "delete": {"x-forge-group": ["a"], "x-forge-method": "delete", "responses": {"204": {"description": "ok"}}} }} }"#, ); @@ -181,7 +181,7 @@ fn operation_header_replaces_the_path_item_header() { "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": {"/a": { "parameters": [{"name": "X-Limit", "in": "header", "schema": {"type": "string"}}], - "get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "get": {"x-forge-group": ["a"], "x-forge-method": "get", "parameters": [{"name": "X-Limit", "in": "header", "required": true, "schema": {"type": "integer"}}], "responses": {"204": {"description": "ok"}}} }} @@ -202,7 +202,7 @@ fn operation_header_replaces_the_path_item_header_ignoring_case() { "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": {"/a": { "parameters": [{"name": "X-Request-ID", "in": "header", "schema": {"type": "string"}}], - "get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "get": {"x-forge-group": ["a"], "x-forge-method": "get", "parameters": [{"name": "x-request-id", "in": "header", "schema": {"type": "integer"}}], "responses": {"204": {"description": "ok"}}} }} @@ -221,7 +221,7 @@ fn query_param_does_not_replace_a_header_with_its_name() { "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": {"/a": { "parameters": [{"name": "version", "in": "header", "schema": {"type": "string"}}], - "get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "get": {"x-forge-group": ["a"], "x-forge-method": "get", "parameters": [{"name": "version", "in": "query", "schema": {"type": "string"}}], "responses": {"204": {"description": "ok"}}} }} @@ -243,7 +243,7 @@ fn headers_are_in_declared_order_the_path_items_first() { {"name": "X-First", "in": "header", "schema": {"type": "string"}}, {"name": "X-Second", "in": "header", "schema": {"type": "string"}} ], - "get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "get": {"x-forge-group": ["a"], "x-forge-method": "get", "parameters": [ {"name": "X-Third", "in": "header", "schema": {"type": "string"}}, {"name": "x-first", "in": "header", "schema": {"type": "string"}} @@ -264,7 +264,7 @@ fn headers_with_equal_names_are_an_error() { let err = parse_err( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", "parameters": [ {"name": "X-Rate-Limit", "in": "header", "schema": {"type": "string"}}, {"name": "X_Rate_Limit", "in": "header", "schema": {"type": "string"}} @@ -289,7 +289,7 @@ fn path_item_headers_differing_in_case_are_an_error() { {"name": "X-A", "in": "header", "schema": {"type": "string"}}, {"name": "x-a", "in": "header", "schema": {"type": "string"}} ], - "get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "get": {"x-forge-group": ["a"], "x-forge-method": "get", "responses": {"204": {"description": "ok"}}} }} }"#, @@ -303,7 +303,7 @@ fn header_and_param_with_equal_names_are_not_an_error() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a/{account_id}": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "paths": {"/a/{account_id}": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", "parameters": [ {"name": "account_id", "in": "path", "required": true, "schema": {"type": "string"}}, {"name": "Account-Id", "in": "header", "schema": {"type": "string"}} @@ -320,7 +320,7 @@ fn cookie_params_are_not_headers() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", "parameters": [ {"name": "session", "in": "cookie", "schema": {"type": "string"}}, {"name": "X-Trace", "in": "header", "schema": {"type": "string"}} @@ -337,7 +337,7 @@ fn method_with_only_cookie_params_has_no_headers() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", "parameters": [{"name": "session", "in": "cookie", "schema": {"type": "string"}}], "responses": {"204": {"description": "ok"}}}}} }"#, @@ -351,7 +351,7 @@ fn accept_content_type_and_authorization_headers_are_kept() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"put": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "upload", + "paths": {"/a": {"put": {"x-forge-group": ["a"], "x-forge-method": "upload", "parameters": [ {"name": "Content-Type", "in": "header", "required": true, "schema": {"type": "string"}}, {"name": "Content-Length", "in": "header", "schema": {"type": "integer"}}, @@ -376,7 +376,7 @@ fn method_without_header_params_has_no_headers() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "list", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "list", "parameters": [{"name": "page", "in": "query", "schema": {"type": "integer"}}], "responses": {"204": {"description": "ok"}}}}} }"#, @@ -391,7 +391,7 @@ fn headers_are_not_in_the_request() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"post": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "create", + "paths": {"/a": {"post": {"x-forge-group": ["a"], "x-forge-method": "create", "parameters": [ {"name": "page", "in": "query", "schema": {"type": "integer"}}, {"name": "X-Trace", "in": "header", "schema": {"type": "string"}} @@ -411,7 +411,7 @@ fn method_with_only_headers_has_no_request() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", "parameters": [{"name": "X-Trace", "in": "header", "schema": {"type": "string"}}], "responses": {"204": {"description": "ok"}}}}} }"#, @@ -427,7 +427,7 @@ fn model_used_only_by_a_header_is_a_model() { let forge = parse( r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a.b", "x-fern-sdk-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a", "b"], "x-forge-method": "get", "parameters": [{"name": "cf-r2-storage-class", "in": "header", "schema": {"$ref": "#/components/schemas/StorageClass"}}], "responses": {"204": {"description": "ok"}}}}}, "components": {"schemas": {"StorageClass": {"type": "string", "enum": ["Standard", "InfrequentAccess"]}}} @@ -446,7 +446,7 @@ fn header_with_content_is_an_error() { let err = parse_err( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", "parameters": [{"name": "X-Filter", "in": "header", "content": {"application/json": {"schema": { "type": "object", "properties": {"status": {"type": "string"}} }}}}], @@ -465,7 +465,7 @@ fn header_style_other_than_simple_is_an_error() { let err = parse_err( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", "parameters": [{"name": "X-Ids", "in": "header", "style": "form", "schema": {"type": "array", "items": {"type": "string"}}}], "responses": {"204": {"description": "ok"}}}}} diff --git a/tests/parse/methods.rs b/tests/parse/methods.rs index 5daea4ea9..664905303 100644 --- a/tests/parse/methods.rs +++ b/tests/parse/methods.rs @@ -9,7 +9,7 @@ fn operation_with(fields: &str) -> String { r#"{{ "openapi": "3.0.3", "info": {{"title": "t", "version": "1"}}, "paths": {{"/a": {{"get": {{{fields} - "x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "x-forge-group": ["a"], "x-forge-method": "get", "responses": {{"204": {{"description": "ok"}}}}}}}}}} }}"# ) @@ -21,14 +21,14 @@ fn each_operation_field_is_its_http_method() { r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": {"/a": { - "get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", "responses": {"204": {"description": "ok"}}}, - "put": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "put", "responses": {"204": {"description": "ok"}}}, - "post": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "post", "responses": {"204": {"description": "ok"}}}, - "delete": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "delete", "responses": {"204": {"description": "ok"}}}, - "options": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "options", "responses": {"204": {"description": "ok"}}}, - "head": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "head", "responses": {"204": {"description": "ok"}}}, - "patch": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "patch", "responses": {"204": {"description": "ok"}}}, - "trace": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "trace", "responses": {"204": {"description": "ok"}}} + "get": {"x-forge-group": ["a"], "x-forge-method": "get", "responses": {"204": {"description": "ok"}}}, + "put": {"x-forge-group": ["a"], "x-forge-method": "put", "responses": {"204": {"description": "ok"}}}, + "post": {"x-forge-group": ["a"], "x-forge-method": "post", "responses": {"204": {"description": "ok"}}}, + "delete": {"x-forge-group": ["a"], "x-forge-method": "delete", "responses": {"204": {"description": "ok"}}}, + "options": {"x-forge-group": ["a"], "x-forge-method": "options", "responses": {"204": {"description": "ok"}}}, + "head": {"x-forge-group": ["a"], "x-forge-method": "head", "responses": {"204": {"description": "ok"}}}, + "patch": {"x-forge-group": ["a"], "x-forge-method": "patch", "responses": {"204": {"description": "ok"}}}, + "trace": {"x-forge-group": ["a"], "x-forge-method": "trace", "responses": {"204": {"description": "ok"}}} }} }"#, ); @@ -54,7 +54,7 @@ fn query_operation_is_query() { let forge = parse( r#"{ "openapi": "3.2.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"query": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "search", + "paths": {"/a": {"query": {"x-forge-group": ["a"], "x-forge-method": "search", "responses": {"204": {"description": "ok"}}}}} }"#, ); @@ -68,7 +68,7 @@ fn additional_operation_is_its_method_as_written() { r#"{ "openapi": "3.2.0", "info": {"title": "t", "version": "1"}, "paths": {"/a": {"additionalOperations": { - "COPY": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "copy", + "COPY": {"x-forge-group": ["a"], "x-forge-method": "copy", "responses": {"204": {"description": "ok"}}} }}} }"#, @@ -86,7 +86,7 @@ fn additional_operation_with_the_method_of_a_field_is_that_variant() { r#"{ "openapi": "3.2.0", "info": {"title": "t", "version": "1"}, "paths": {"/a": {"additionalOperations": { - "GET": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "GET": {"x-forge-group": ["a"], "x-forge-method": "get", "responses": {"204": {"description": "ok"}}} }}} }"#, @@ -101,7 +101,7 @@ fn additional_operation_methods_are_case_sensitive() { r#"{ "openapi": "3.2.0", "info": {"title": "t", "version": "1"}, "paths": {"/a": {"additionalOperations": { - "get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "get": {"x-forge-group": ["a"], "x-forge-method": "get", "responses": {"204": {"description": "ok"}}} }}} }"#, @@ -118,7 +118,7 @@ fn method_has_the_summary_description_and_deprecation_of_its_operation() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", "summary": "Get an a.", "description": "Gets an a.", "deprecated": true, "responses": {"204": {"description": "ok"}}}}} }"#, @@ -135,7 +135,7 @@ fn method_without_docs_is_empty_and_not_deprecated() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", "responses": {"204": {"description": "ok"}}}}} }"#, ); @@ -153,7 +153,7 @@ fn path_item_ref_is_followed() { "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, "paths": {"/a": {"$ref": "#/components/pathItems/A"}}, "components": {"pathItems": { - "A": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "A": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", "responses": {"204": {"description": "ok"}}}} }} }"##, @@ -168,11 +168,11 @@ fn extensions_in_paths_and_path_items_are_not_operations() { r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": { - "x-paths-extension": {"get": {"x-fern-sdk-group-name": "x", "x-fern-sdk-method-name": "get", + "x-paths-extension": {"get": {"x-forge-group": ["x"], "x-forge-method": "get", "responses": {"204": {"description": "ok"}}}}, "/a": { "x-forge-commands": {"a": {"description": "A."}}, - "get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "get": {"x-forge-group": ["a"], "x-forge-method": "get", "responses": {"204": {"description": "ok"}}} } } @@ -189,9 +189,9 @@ fn additional_operation_with_the_method_of_a_present_field_is_skipped() { r#"{ "openapi": "3.2.0", "info": {"title": "t", "version": "1"}, "paths": {"/a": { - "get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "get": {"x-forge-group": ["a"], "x-forge-method": "get", "responses": {"204": {"description": "ok"}}}, - "additionalOperations": {"GET": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "shadowed", + "additionalOperations": {"GET": {"x-forge-group": ["a"], "x-forge-method": "shadowed", "responses": {"204": {"description": "ok"}}}} }} }"#, @@ -206,7 +206,7 @@ fn webhooks_are_not_methods() { let forge = parse( r#"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "webhooks": {"newPet": {"post": {"x-fern-sdk-group-name": "pets", "x-fern-sdk-method-name": "created", + "webhooks": {"newPet": {"post": {"x-forge-group": ["pets"], "x-forge-method": "created", "responses": {"204": {"description": "ok"}}}}} }"#, ); @@ -219,9 +219,9 @@ fn callbacks_are_not_methods() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/subscribe": {"post": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "subscribe", + "paths": {"/subscribe": {"post": {"x-forge-group": ["a"], "x-forge-method": "subscribe", "callbacks": {"event": {"{$request.query.url}": {"post": { - "x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "event", + "x-forge-group": ["a"], "x-forge-method": "event", "responses": {"204": {"description": "ok"}} }}}}, "responses": {"204": {"description": "ok"}}}}} @@ -297,7 +297,7 @@ fn each_availability_is_its_variant() { ("generally-available", Availability::GenerallyAvailable), ("deprecated", Availability::Deprecated), ] { - let forge = parse(&operation_with(&format!(r#""x-fern-availability": "{written}","#))); + let forge = parse(&operation_with(&format!(r#""x-forge-availability": "{written}","#))); assert_eq!(method(&forge, "a.get").availability(), Some(want), "{written}"); assert_eq!(want.as_str(), written); @@ -306,21 +306,21 @@ fn each_availability_is_its_variant() { #[test] fn unknown_availability_is_an_error_naming_the_operation() { - let err = parse_err(&operation_with(r#""x-fern-availability": "stable","#)); + let err = parse_err(&operation_with(r#""x-forge-availability": "stable","#)); - assert!(err.contains(r#"GET /a: x-fern-availability "stable" is not"#), "{err}"); + assert!(err.contains(r#"GET /a: x-forge-availability "stable" is not"#), "{err}"); } #[test] fn availability_is_case_sensitive() { - let err = parse_err(&operation_with(r#""x-fern-availability": "Beta","#)); + let err = parse_err(&operation_with(r#""x-forge-availability": "Beta","#)); - assert!(err.contains(r#"GET /a: x-fern-availability "Beta" is not"#), "{err}"); + assert!(err.contains(r#"GET /a: x-forge-availability "Beta" is not"#), "{err}"); } #[test] fn availability_that_is_not_a_string_is_an_error() { - let err = parse_err(&operation_with(r#""x-fern-availability": 1,"#)); + let err = parse_err(&operation_with(r#""x-forge-availability": 1,"#)); assert!(err.contains("expected a string"), "{err}"); } @@ -331,13 +331,13 @@ fn availability_and_deprecation_are_apart() { r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": {"/a": { - "get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", - "deprecated": true, "x-fern-availability": "generally-available", + "get": {"x-forge-group": ["a"], "x-forge-method": "get", + "deprecated": true, "x-forge-availability": "generally-available", "responses": {"204": {"description": "ok"}}}, - "put": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "put", - "x-fern-availability": "deprecated", + "put": {"x-forge-group": ["a"], "x-forge-method": "put", + "x-forge-availability": "deprecated", "responses": {"204": {"description": "ok"}}}, - "post": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "post", + "post": {"x-forge-group": ["a"], "x-forge-method": "post", "deprecated": true, "responses": {"204": {"description": "ok"}}} }} }"#, diff --git a/tests/parse/models.rs b/tests/parse/models.rs index d6373aafe..40f7ca110 100644 --- a/tests/parse/models.rs +++ b/tests/parse/models.rs @@ -13,8 +13,8 @@ fn response_model_is_placed_in_its_group() { "paths": { "/zones": { "get": { - "x-fern-sdk-group-name": "zones", - "x-fern-sdk-method-name": "list", + "x-forge-group": ["zones"], + "x-forge-method": "list", "responses": { "200": { "description": "OK", @@ -43,9 +43,9 @@ fn model_used_in_sibling_groups_is_in_their_parent() { r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": { - "/c": {"get": {"x-fern-sdk-group-name": "a.b.c", "x-fern-sdk-method-name": "get", + "/c": {"get": {"x-forge-group": ["a", "b", "c"], "x-forge-method": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/M"}}}}}}}, - "/d": {"get": {"x-fern-sdk-group-name": "a.b.d", "x-fern-sdk-method-name": "get", + "/d": {"get": {"x-forge-group": ["a", "b", "d"], "x-forge-method": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/M"}}}}}}} }, "components": {"schemas": {"M": {"type": "object", "properties": {"id": {"type": "string"}}}}} @@ -61,9 +61,9 @@ fn model_used_in_a_group_and_its_subgroup_is_in_the_group() { r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": { - "/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/M"}}}}}}}, - "/c": {"get": {"x-fern-sdk-group-name": "a.b.c", "x-fern-sdk-method-name": "get", + "/c": {"get": {"x-forge-group": ["a", "b", "c"], "x-forge-method": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/M"}}}}}}} }, "components": {"schemas": {"M": {"type": "object", "properties": {"id": {"type": "string"}}}}} @@ -79,9 +79,9 @@ fn model_used_in_top_level_groups_is_at_the_top_level() { r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": { - "/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/M"}}}}}}}, - "/x": {"get": {"x-fern-sdk-group-name": "x", "x-fern-sdk-method-name": "get", + "/x": {"get": {"x-forge-group": ["x"], "x-forge-method": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/M"}}}}}}} }, "components": {"schemas": {"M": {"type": "object", "properties": {"id": {"type": "string"}}}}} @@ -99,9 +99,9 @@ fn model_used_through_another_model_is_placed_by_every_use() { r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": { - "/b": {"get": {"x-fern-sdk-group-name": "a.b", "x-fern-sdk-method-name": "get", + "/b": {"get": {"x-forge-group": ["a", "b"], "x-forge-method": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Outer"}}}}}}}, - "/c": {"get": {"x-fern-sdk-group-name": "a.c", "x-fern-sdk-method-name": "get", + "/c": {"get": {"x-forge-group": ["a", "c"], "x-forge-method": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Inner"}}}}}}} }, "components": {"schemas": { @@ -120,7 +120,7 @@ fn params_bodies_and_errors_use_models() { let forge = parse( r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"post": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "create", + "paths": {"/a": {"post": {"x-forge-group": ["a"], "x-forge-method": "create", "parameters": [{"name": "filter", "in": "query", "schema": {"$ref": "#/components/schemas/Filter"}}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Input"}}}}, "responses": {"400": {"description": "no", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Failure"}}}}}}}}, @@ -144,11 +144,11 @@ fn schema_no_method_uses_is_not_a_model() { r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": { - "/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Used"}}}}}}}, - "/ignored": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "ignored", "x-fern-ignore": true, + "/ignored": {"get": {"x-forge-group": ["a"], "x-forge-method": "ignored", "x-forge-ignore": true, "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Ignored"}}}}}}}, - "/ungrouped": {"get": {"x-fern-sdk-method-name": "ungrouped", + "/ungrouped": {"get": {"x-forge-method": "ungrouped", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Ungrouped"}}}}}}} }, "components": {"schemas": { @@ -189,7 +189,7 @@ fn model_is_named_after_its_schema() { fn model_is_named_by_x_fern_type_name() { let forge = parse(&responding_with_schemas( r##"{"$ref": "#/components/schemas/not_found"}"##, - r#"{"not_found": {"type": "object", "x-fern-type-name": "Missing"}}"#, + r#"{"not_found": {"type": "object", "x-forge-model-name": "Missing"}}"#, )); assert_eq!(model(&forge, "not_found").name(), &Name::new("Missing")); @@ -224,7 +224,7 @@ fn schema_in_defs_is_a_model() { let forge = parse( r##"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Tree/$defs/Node"}}}}}}}}, "components": {"schemas": { "Tree": {"type": "object", "$defs": {"Node": {"type": "string"}}} @@ -256,7 +256,7 @@ fn schema_used_only_by_content_that_is_not_json_is_a_model() { let forge = parse( r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"post": {"x-fern-sdk-group-name": "a.files", "x-fern-sdk-method-name": "upload", + "paths": {"/a": {"post": {"x-forge-group": ["a", "files"], "x-forge-method": "upload", "requestBody": {"content": {"multipart/form-data": {"schema": {"$ref": "#/components/schemas/Upload"}}}}, "responses": {"200": {"description": "ok", "content": {"text/csv": {"schema": {"$ref": "#/components/schemas/Report"}}}}}}}}, "components": {"schemas": { @@ -287,9 +287,9 @@ fn use_by_content_that_is_not_json_places_a_model() { r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": { - "/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/M"}}}}}}}, - "/b": {"post": {"x-fern-sdk-group-name": "b", "x-fern-sdk-method-name": "upload", + "/b": {"post": {"x-forge-group": ["b"], "x-forge-method": "upload", "requestBody": {"content": {"multipart/form-data": {"schema": { "type": "object", "properties": {"meta": {"$ref": "#/components/schemas/M"}} }}}}, @@ -307,7 +307,7 @@ fn schema_used_only_in_a_response_header_is_not_a_model() { let forge = parse( r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", "parameters": [{"name": "X-Trace", "in": "header", "schema": {"$ref": "#/components/schemas/Trace"}}], "responses": {"204": {"description": "ok", "headers": {"X-Rate-Limit": {"schema": {"$ref": "#/components/schemas/RateLimit"}}}}}}}}, diff --git a/tests/parse/requests.rs b/tests/parse/requests.rs index 31ad98776..9fe63590c 100644 --- a/tests/parse/requests.rs +++ b/tests/parse/requests.rs @@ -13,8 +13,8 @@ fn path_param_is_a_required_request_field() { "paths": { "/zones/{zone_id}": { "get": { - "x-fern-sdk-group-name": "zones", - "x-fern-sdk-method-name": "get", + "x-forge-group": ["zones"], + "x-forge-method": "get", "parameters": [ { "name": "zone_id", "in": "path", "required": true, "schema": { "type": "string" } } ], @@ -44,7 +44,7 @@ fn query_param_is_optional_and_exploded_by_default() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "list", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "list", "parameters": [{"name": "page", "in": "query", "schema": {"type": "integer"}}], "responses": {"204": {"description": "ok"}}}}} }"#, @@ -61,7 +61,7 @@ fn query_param_can_be_required() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "list", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "list", "parameters": [{"name": "page", "in": "query", "required": true, "schema": {"type": "integer"}}], "responses": {"204": {"description": "ok"}}}}} }"#, @@ -76,7 +76,7 @@ fn query_param_with_explode_false_is_not_exploded() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "list", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "list", "parameters": [{"name": "ids", "in": "query", "style": "form", "explode": false, "schema": {"type": "array", "items": {"type": "string"}}}], "responses": {"204": {"description": "ok"}}}}} @@ -93,7 +93,7 @@ fn header_and_cookie_params_are_not_fields() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", "parameters": [ {"name": "X-Trace", "in": "header", "schema": {"type": "string"}}, {"name": "session", "in": "cookie", "schema": {"type": "string"}} @@ -111,8 +111,8 @@ fn param_field_is_named_by_x_fern_parameter_name() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "list", - "parameters": [{"name": "q", "in": "query", "x-fern-parameter-name": "query", "schema": {"type": "string"}}], + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "list", + "parameters": [{"name": "q", "in": "query", "x-forge-parameter-name": "query", "schema": {"type": "string"}}], "responses": {"204": {"description": "ok"}}}}} }"#, ); @@ -126,7 +126,7 @@ fn param_description_falls_back_to_an_inline_schema() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "list", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "list", "parameters": [ {"name": "own", "in": "query", "description": "Param.", "schema": {"type": "string", "description": "Schema."}}, {"name": "inherited", "in": "query", "schema": {"type": "string", "description": "Schema."}} @@ -145,7 +145,7 @@ fn param_description_comes_from_a_ref_schema() { let forge = parse( r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "list", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "list", "parameters": [ {"name": "own", "in": "query", "description": "Param.", "schema": {"$ref": "#/components/schemas/Id"}}, {"name": "id", "in": "query", "schema": {"$ref": "#/components/schemas/Id"}} @@ -165,7 +165,7 @@ fn param_description_follows_a_chain_of_refs() { let forge = parse( r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a/{id}": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "paths": {"/a/{id}": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"$ref": "#/components/schemas/Id"}}], "responses": {"204": {"description": "ok"}}}}}, "components": {"schemas": { @@ -184,7 +184,7 @@ fn param_description_is_never_a_schema_title() { let forge = parse( r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "list", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "list", "parameters": [ {"name": "id", "in": "query", "schema": {"$ref": "#/components/schemas/Id"}}, {"name": "inline", "in": "query", "schema": {"type": "string", "title": "Inline"}} @@ -204,7 +204,7 @@ fn description_next_to_a_param_schema_ref_is_its_own_in_3_1() { let forge = parse( r##"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "list", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "list", "parameters": [{"name": "id", "in": "query", "schema": {"$ref": "#/components/schemas/Id", "description": "The ID to find."}}], "responses": {"204": {"description": "ok"}}}}}, @@ -221,7 +221,7 @@ fn param_description_comes_from_an_all_of_member_in_3_1() { let forge = parse( r##"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "list", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "list", "parameters": [{"name": "region", "in": "query", "schema": {"allOf": [{"$ref": "#/components/schemas/Region"}, {"default": "us"}]}}], "responses": {"204": {"description": "ok"}}}}}, @@ -238,7 +238,7 @@ fn param_description_from_an_all_of_member_is_ignored_in_3_0() { let forge = parse( r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "list", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "list", "parameters": [{"name": "region", "in": "query", "schema": {"allOf": [{"$ref": "#/components/schemas/Region"}, {"default": "us"}]}}], "responses": {"204": {"description": "ok"}}}}}, @@ -255,7 +255,7 @@ fn param_ref_description_overrides_the_params_in_3_1() { let forge = parse( r##"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "list", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "list", "parameters": [{"$ref": "#/components/parameters/Page", "description": "The page of pets."}], "responses": {"204": {"description": "ok"}}}}}, "components": {"parameters": {"Page": {"name": "page", "in": "query", "description": "A page.", @@ -272,7 +272,7 @@ fn param_ref_description_is_ignored_in_3_0() { let forge = parse( r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "list", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "list", "parameters": [{"$ref": "#/components/parameters/Page", "description": "The page of pets."}], "responses": {"204": {"description": "ok"}}}}}, "components": {"parameters": {"Page": {"name": "page", "in": "query", "description": "A page.", @@ -289,7 +289,7 @@ fn param_ref_without_a_description_has_the_params() { let forge = parse( r##"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "list", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "list", "parameters": [{"$ref": "#/components/parameters/Page"}], "responses": {"204": {"description": "ok"}}}}}, "components": {"parameters": {"Page": {"name": "page", "in": "query", "description": "A page.", @@ -306,7 +306,7 @@ fn param_field_is_never_read_only() { let forge = parse( r##"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a/{id}": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "paths": {"/a/{id}": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", "parameters": [ {"name": "id", "in": "path", "required": true, "schema": {"type": "string", "readOnly": true}}, {"name": "since", "in": "query", "schema": {"$ref": "#/components/schemas/Since", "readOnly": true}}, @@ -331,7 +331,7 @@ fn query_string_property_is_never_read_only() { let forge = parse( r#"{ "openapi": "3.2.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "list", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "list", "parameters": [{"name": "filter", "in": "querystring", "content": {"application/x-www-form-urlencoded": {"schema": { "type": "object", "properties": {"status": {"type": "string", "readOnly": true, "x-sensitive": true}} }}}}], @@ -349,7 +349,7 @@ fn param_field_is_sensitive_when_its_schema_is() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "list", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "list", "parameters": [ {"name": "token", "in": "query", "schema": {"type": "string", "x-sensitive": true}}, {"name": "page", "in": "query", "schema": {"type": "integer"}} @@ -368,7 +368,7 @@ fn param_field_is_sensitive_when_its_ref_schema_is() { let forge = parse( r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "list", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "list", "parameters": [{"name": "token", "in": "query", "schema": {"$ref": "#/components/schemas/Token"}}], "responses": {"204": {"description": "ok"}}}}}, "components": {"schemas": { @@ -386,7 +386,7 @@ fn sensitive_next_to_a_param_schema_ref_counts_in_3_0() { let forge = parse( r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "list", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "list", "parameters": [{"name": "token", "in": "query", "schema": {"$ref": "#/components/schemas/Token", "x-sensitive": true}}], "responses": {"204": {"description": "ok"}}}}}, @@ -402,7 +402,7 @@ fn sensitive_on_the_param_itself_is_ignored() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "list", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "list", "parameters": [{"name": "token", "in": "query", "x-sensitive": true, "schema": {"type": "string"}}], "responses": {"204": {"description": "ok"}}}}} }"#, @@ -416,7 +416,7 @@ fn param_ref_is_followed() { let forge = parse( r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "list", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "list", "parameters": [{"$ref": "#/components/parameters/Page"}], "responses": {"204": {"description": "ok"}}}}}, "components": {"parameters": {"Page": {"name": "page", "in": "query", "schema": {"type": "integer"}}}} @@ -435,8 +435,8 @@ fn path_item_params_belong_to_each_operation() { "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": {"/a/{id}": { "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}], - "get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", "responses": {"204": {"description": "ok"}}}, - "delete": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "delete", "responses": {"204": {"description": "ok"}}} + "get": {"x-forge-group": ["a"], "x-forge-method": "get", "responses": {"204": {"description": "ok"}}}, + "delete": {"x-forge-group": ["a"], "x-forge-method": "delete", "responses": {"204": {"description": "ok"}}} }} }"#, ); @@ -455,7 +455,7 @@ fn operation_param_replaces_the_path_item_param() { "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": {"/a": { "parameters": [{"name": "page", "in": "query", "schema": {"type": "string"}}], - "get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "list", + "get": {"x-forge-group": ["a"], "x-forge-method": "list", "parameters": [{"name": "page", "in": "query", "schema": {"type": "integer"}}], "responses": {"204": {"description": "ok"}}} }} @@ -472,7 +472,7 @@ fn undeclared_path_placeholder_is_a_required_string() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a/{id}": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "paths": {"/a/{id}": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", "responses": {"204": {"description": "ok"}}}}} }"#, ); @@ -491,7 +491,7 @@ fn params_are_in_path_template_then_declared_order() { "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": {"/a/{second}/{first}": { "parameters": [{"name": "item_query", "in": "query", "schema": {"type": "string"}}], - "get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "get": {"x-forge-group": ["a"], "x-forge-method": "get", "parameters": [ {"name": "op_query", "in": "query", "schema": {"type": "string"}}, {"name": "first", "in": "path", "required": true, "schema": {"type": "string"}}, @@ -511,7 +511,7 @@ fn query_string_object_is_query_fields() { let forge = parse( r#"{ "openapi": "3.2.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "list", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "list", "parameters": [{"name": "filter", "in": "querystring", "content": {"application/x-www-form-urlencoded": {"schema": { "type": "object", "properties": {"status": {"type": "string"}, "limit": {"type": "integer"}} }}}}], @@ -532,7 +532,7 @@ fn no_params_and_no_body_is_no_request() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", "responses": {"204": {"description": "ok"}}}}} }"#, ); @@ -545,7 +545,7 @@ fn body_without_params_is_the_request() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"post": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "create", + "paths": {"/a": {"post": {"x-forge-group": ["a"], "x-forge-method": "create", "requestBody": {"content": {"application/json": {"schema": {"type": "array", "items": {"type": "string"}}}}}, "responses": {"204": {"description": "ok"}}}}} }"#, @@ -559,7 +559,7 @@ fn object_body_is_intersected_with_the_params() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a/{id}": {"put": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "update", + "paths": {"/a/{id}": {"put": {"x-forge-group": ["a"], "x-forge-method": "update", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}], "requestBody": {"content": {"application/json": {"schema": { "type": "object", "properties": {"name": {"type": "string"}} @@ -582,7 +582,7 @@ fn model_body_is_intersected_with_the_params() { let forge = parse( r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a/{id}": {"put": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "update", + "paths": {"/a/{id}": {"put": {"x-forge-group": ["a"], "x-forge-method": "update", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Pet"}}}}, "responses": {"204": {"description": "ok"}}}}}, @@ -604,7 +604,7 @@ fn union_body_is_intersected_with_the_params() { let forge = parse( r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a/{id}": {"put": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "update", + "paths": {"/a/{id}": {"put": {"x-forge-group": ["a"], "x-forge-method": "update", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}], "requestBody": {"content": {"application/json": {"schema": { "oneOf": [{"$ref": "#/components/schemas/Cat"}, {"$ref": "#/components/schemas/Dog"}] @@ -631,7 +631,7 @@ fn array_body_is_the_last_field_of_the_params() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a/{id}": {"put": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "update", + "paths": {"/a/{id}": {"put": {"x-forge-group": ["a"], "x-forge-method": "update", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}], "requestBody": {"content": {"application/json": {"schema": {"type": "array", "items": {"type": "string"}}}}}, "responses": {"204": {"description": "ok"}}}}} @@ -651,7 +651,7 @@ fn body_field_is_neither_read_only_nor_sensitive() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a/{id}": {"put": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "update", + "paths": {"/a/{id}": {"put": {"x-forge-group": ["a"], "x-forge-method": "update", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}], "requestBody": {"content": {"application/json": {"schema": { "type": "array", "items": {"type": "string"}, "readOnly": true, "x-sensitive": true @@ -670,7 +670,7 @@ fn read_only_field_of_a_body_model_is_kept() { let forge = parse( r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"post": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "create", + "paths": {"/a": {"post": {"x-forge-group": ["a"], "x-forge-method": "create", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Pet"}}}}, "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Pet"}}}}}}}}, "components": {"schemas": {"Pet": {"type": "object", "properties": { @@ -690,7 +690,7 @@ fn map_body_is_a_body_field() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a/{id}": {"put": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "update", + "paths": {"/a/{id}": {"put": {"x-forge-group": ["a"], "x-forge-method": "update", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}], "requestBody": {"content": {"application/json": {"schema": { "type": "object", "additionalProperties": {"type": "string"} @@ -708,7 +708,7 @@ fn unknown_body_is_a_body_field() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a/{id}": {"put": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "update", + "paths": {"/a/{id}": {"put": {"x-forge-group": ["a"], "x-forge-method": "update", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}], "requestBody": {"content": {"application/json": {"schema": {}}}}, "responses": {"204": {"description": "ok"}}}}} @@ -724,7 +724,7 @@ fn nullable_body_is_a_body_field() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a/{id}": {"put": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "update", + "paths": {"/a/{id}": {"put": {"x-forge-group": ["a"], "x-forge-method": "update", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}], "requestBody": {"content": {"application/json": {"schema": { "type": "object", "nullable": true, "properties": {"name": {"type": "string"}} @@ -742,7 +742,7 @@ fn body_with_the_name_of_a_param_is_a_body_field() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a/{zone_id}": {"put": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "update", + "paths": {"/a/{zone_id}": {"put": {"x-forge-group": ["a"], "x-forge-method": "update", "parameters": [{"name": "zone_id", "in": "path", "required": true, "schema": {"type": "string"}}], "requestBody": {"content": {"application/json": {"schema": { "type": "object", "properties": {"zoneId": {"type": "string"}, "name": {"type": "string"}} @@ -762,8 +762,8 @@ fn field_of_another_method_has_no_location() { "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": {"/a/{id}": { "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}], - "get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", "responses": {"204": {"description": "ok"}}}, - "delete": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "delete", "responses": {"204": {"description": "ok"}}} + "get": {"x-forge-group": ["a"], "x-forge-method": "get", "responses": {"204": {"description": "ok"}}}, + "delete": {"x-forge-group": ["a"], "x-forge-method": "delete", "responses": {"204": {"description": "ok"}}} }} }"#, ); @@ -779,7 +779,7 @@ fn body_that_is_not_json_is_an_unknown_body_field() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a/{id}": {"post": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "upload", + "paths": {"/a/{id}": {"post": {"x-forge-group": ["a"], "x-forge-method": "upload", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}], "requestBody": {"content": {"multipart/form-data": {"schema": { "type": "object", "properties": {"file": {"type": "string", "format": "binary"}} @@ -798,7 +798,7 @@ fn body_that_is_only_multipart_is_an_unknown_request() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"post": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "upload", + "paths": {"/a": {"post": {"x-forge-group": ["a"], "x-forge-method": "upload", "requestBody": {"content": {"multipart/form-data": {"schema": { "type": "object", "properties": {"file": {"type": "string", "format": "binary"}} }}}}, @@ -816,7 +816,7 @@ fn path_param_missing_from_the_template_is_dropped() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a/{id}": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "paths": {"/a/{id}": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", "parameters": [ {"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}, {"name": "account_id", "in": "path", "required": true, "schema": {"type": "string"}} @@ -833,7 +833,7 @@ fn deprecated_query_param_is_a_deprecated_field() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "list", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "list", "parameters": [{"name": "page", "in": "query", "deprecated": true, "schema": {"type": "integer"}}], "responses": {"204": {"description": "ok"}}}}} }"#, @@ -847,7 +847,7 @@ fn deprecated_path_param_is_not_a_deprecated_field() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a/{id}": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "paths": {"/a/{id}": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", "parameters": [{"name": "id", "in": "path", "required": true, "deprecated": true, "schema": {"type": "string"}}], "responses": {"204": {"description": "ok"}}}}} }"#, @@ -861,7 +861,7 @@ fn all_of_body_is_one_member_of_the_intersection() { let forge = parse( r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a/{id}": {"put": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "update", + "paths": {"/a/{id}": {"put": {"x-forge-group": ["a"], "x-forge-method": "update", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}], "requestBody": {"content": {"application/json": {"schema": { "allOf": [{"$ref": "#/components/schemas/A"}, {"$ref": "#/components/schemas/B"}] @@ -888,7 +888,7 @@ fn param_with_content_is_an_error() { let err = parse_err( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "list", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "list", "parameters": [{"name": "filter", "in": "query", "content": {"application/json": {"schema": { "type": "object", "properties": {"status": {"type": "string"}} }}}}], @@ -912,7 +912,7 @@ fn query_style_other_than_form_is_an_error() { let err = parse_err(&format!( r#"{{ "openapi": "3.0.3", "info": {{"title": "t", "version": "1"}}, - "paths": {{"/a": {{"get": {{"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "list", + "paths": {{"/a": {{"get": {{"x-forge-group": ["a"], "x-forge-method": "list", "parameters": [{{"name": "filter", "in": "query", "style": "{style}", "schema": {schema}}}], "responses": {{"204": {{"description": "ok"}}}}}}}}}} }}"# @@ -927,7 +927,7 @@ fn two_query_string_params_are_an_error() { let err = parse_err( r#"{ "openapi": "3.2.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "list", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "list", "parameters": [ {"name": "one", "in": "querystring", "content": {"application/x-www-form-urlencoded": {"schema": {"type": "object"}}}}, {"name": "two", "in": "querystring", "content": {"application/x-www-form-urlencoded": {"schema": {"type": "object"}}}} @@ -946,7 +946,7 @@ fn query_and_query_string_params_are_an_error() { "openapi": "3.2.0", "info": {"title": "t", "version": "1"}, "paths": {"/a": { "parameters": [{"name": "page", "in": "query", "schema": {"type": "integer"}}], - "get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "list", + "get": {"x-forge-group": ["a"], "x-forge-method": "list", "parameters": [{"name": "filter", "in": "querystring", "content": {"application/x-www-form-urlencoded": {"schema": {"type": "object"}}}}], "responses": {"204": {"description": "ok"}}} }} @@ -963,10 +963,10 @@ fn body_field_is_required_when_the_body_is() { "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": {"/a/{id}": { "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}], - "put": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "replace", + "put": {"x-forge-group": ["a"], "x-forge-method": "replace", "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "array", "items": {"type": "string"}}}}}, "responses": {"204": {"description": "ok"}}}, - "patch": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "append", + "patch": {"x-forge-group": ["a"], "x-forge-method": "append", "requestBody": {"content": {"application/json": {"schema": {"type": "array", "items": {"type": "string"}}}}}, "responses": {"204": {"description": "ok"}}} }} @@ -983,7 +983,7 @@ fn params_with_equal_names_are_an_error() { let err = parse_err( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a/{zone_id}": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "paths": {"/a/{zone_id}": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", "parameters": [ {"name": "zone_id", "in": "path", "required": true, "schema": {"type": "string"}}, {"name": "zoneId", "in": "query", "schema": {"type": "string"}} @@ -1001,7 +1001,7 @@ fn path_style_other_than_simple_is_an_error() { let err = parse_err(&format!( r#"{{ "openapi": "3.0.3", "info": {{"title": "t", "version": "1"}}, - "paths": {{"/a/{{id}}": {{"get": {{"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "paths": {{"/a/{{id}}": {{"get": {{"x-forge-group": ["a"], "x-forge-method": "get", "parameters": [{{"name": "id", "in": "path", "required": true, "style": "{style}", "schema": {{"type": "string"}}}}], "responses": {{"204": {{"description": "ok"}}}}}}}}}} }}"# @@ -1016,7 +1016,7 @@ fn param_field_has_the_default_of_its_schema() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "list", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "list", "parameters": [ {"name": "per_page", "in": "query", "schema": {"type": "integer", "default": 20}}, {"name": "page", "in": "query", "schema": {"type": "integer"}} @@ -1035,7 +1035,7 @@ fn param_field_has_the_default_of_its_ref_schema() { let forge = parse( r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a/{order}": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "list", + "paths": {"/a/{order}": {"get": {"x-forge-group": ["a"], "x-forge-method": "list", "parameters": [ {"name": "order", "in": "path", "required": true, "schema": {"$ref": "#/components/schemas/Order"}}, {"$ref": "#/components/parameters/Direction"} @@ -1062,7 +1062,7 @@ fn default_next_to_a_param_schema_ref_is_its_own_in_3_1() { let forge = parse( r##"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "list", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "list", "parameters": [{"name": "per_page", "in": "query", "schema": {"$ref": "#/components/schemas/PerPage", "default": 50}}], "responses": {"204": {"description": "ok"}}}}}, @@ -1079,7 +1079,7 @@ fn default_on_the_param_itself_is_ignored() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "list", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "list", "parameters": [{"name": "per_page", "in": "query", "default": 20, "schema": {"type": "integer"}}], "responses": {"204": {"description": "ok"}}}}} }"#, @@ -1096,7 +1096,7 @@ fn body_field_and_undeclared_placeholder_have_no_default() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a/{id}": {"put": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "update", + "paths": {"/a/{id}": {"put": {"x-forge-group": ["a"], "x-forge-method": "update", "requestBody": {"content": {"application/json": {"schema": { "type": "array", "items": {"type": "string"}, "default": ["x"] }}}}, @@ -1114,7 +1114,7 @@ fn array_body_keeps_its_max_items() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"post": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "create", + "paths": {"/a": {"post": {"x-forge-group": ["a"], "x-forge-method": "create", "requestBody": {"content": {"application/json": {"schema": { "type": "array", "items": {"type": "string"}, "maxItems": 100 }}}}, diff --git a/tests/parse/responses.rs b/tests/parse/responses.rs index a34167acc..eab8ea51d 100644 --- a/tests/parse/responses.rs +++ b/tests/parse/responses.rs @@ -7,7 +7,7 @@ fn responses_are_the_2xx_sorted_by_status() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"post": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "create", + "paths": {"/a": {"post": {"x-forge-group": ["a"], "x-forge-method": "create", "responses": { "2XX": {"description": "ok"}, "201": {"description": "ok"}, @@ -29,7 +29,7 @@ fn errors_are_the_rest_sorted_with_default_last() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", "responses": { "default": {"description": "no"}, "500": {"description": "no"}, @@ -54,7 +54,7 @@ fn response_has_its_description() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", "responses": {"200": {"description": "The pet."}}}}} }"#, ); @@ -68,7 +68,7 @@ fn response_without_content_has_none() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"delete": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "delete", + "paths": {"/a": {"delete": {"x-forge-group": ["a"], "x-forge-method": "delete", "responses": {"204": {"description": "ok"}}}}} }"#, ); @@ -83,7 +83,7 @@ fn response_ref_is_followed() { let forge = parse( r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", "responses": {"4XX": {"$ref": "#/components/responses/Failure"}}}}}, "components": {"responses": { "Failure": {"description": "Failed.", "content": {"application/json": {"schema": {"type": "string"}}}} @@ -102,7 +102,7 @@ fn response_content_that_is_not_json_is_typed() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "download", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "download", "responses": {"200": {"description": "ok", "content": { "application/octet-stream": {"schema": {"type": "string", "format": "binary"}}, "text/csv": {"schema": {"type": "string"}} diff --git a/tests/parse/types/arrays.rs b/tests/parse/types/arrays.rs index 262c601e1..bf5d85931 100644 --- a/tests/parse/types/arrays.rs +++ b/tests/parse/types/arrays.rs @@ -92,7 +92,7 @@ fn array_without_items_has_unknown_elements() { let forge = parse( r#"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { "type": "array" }}}}}}}} @@ -107,7 +107,7 @@ fn tuple_is_unknown() { let forge = parse( r#"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { "type": "array", "prefixItems": [{"type": "string"}, {"type": "integer"}], "items": false }}}}}}}} diff --git a/tests/parse/types/enums.rs b/tests/parse/types/enums.rs index d2b3b497e..101ea434d 100644 --- a/tests/parse/types/enums.rs +++ b/tests/parse/types/enums.rs @@ -59,7 +59,7 @@ fn const_is_an_enum_of_one_value() { let forge = parse( r#"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { "const": "a" }}}}}}}} diff --git a/tests/parse/types/intersections.rs b/tests/parse/types/intersections.rs index 7c5d6564b..ca373866b 100644 --- a/tests/parse/types/intersections.rs +++ b/tests/parse/types/intersections.rs @@ -49,7 +49,7 @@ fn true_member_is_dropped() { let forge = parse( r##"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { "allOf": [{"$ref": "#/components/schemas/A"}, true, {"$ref": "#/components/schemas/B"}] }}}}}}}}, @@ -141,7 +141,7 @@ fn unevaluated_properties_is_an_object_member() { let forge = parse( r##"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { "allOf": [{"$ref": "#/components/schemas/A"}], "unevaluatedProperties": {"type": "string"} diff --git a/tests/parse/types/nulls.rs b/tests/parse/types/nulls.rs index a3794823e..b191b43cc 100644 --- a/tests/parse/types/nulls.rs +++ b/tests/parse/types/nulls.rs @@ -95,7 +95,7 @@ fn nullable_is_read_in_3_1() { let forge = parse( r#"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { "type": "string", "nullable": true }}}}}}}} @@ -110,7 +110,7 @@ fn null_in_a_type_array_is_a_union_with_null() { let forge = parse( r#"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { "type": ["string", "null"] }}}}}}}} @@ -125,7 +125,7 @@ fn null_type_is_null() { let forge = parse( r#"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { "type": "null" }}}}}}}} @@ -140,7 +140,7 @@ fn const_null_is_null() { let forge = parse( r#"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { "const": null }}}}}}}} diff --git a/tests/parse/types/objects.rs b/tests/parse/types/objects.rs index 1b5139d46..f3e9bf7dc 100644 --- a/tests/parse/types/objects.rs +++ b/tests/parse/types/objects.rs @@ -38,7 +38,7 @@ fn required_names_without_a_property_are_ignored() { #[test] fn field_is_named_by_x_fern_property_name() { let forge = parse(&responding_with( - r#"{"type": "object", "properties": {"owner": {"type": "string", "x-fern-property-name": "person"}}}"#, + r#"{"type": "object", "properties": {"owner": {"type": "string", "x-forge-property-name": "person"}}}"#, )); let owner = field(object(response_type(&forge, "a.get")), "owner"); @@ -195,7 +195,7 @@ fn pattern_properties_are_ignored() { let forge = parse( r#"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { "type": "object", "properties": {"id": {"type": "string"}}, "patternProperties": {"^x-": {"type": "string"}} }}}}}}}} @@ -212,7 +212,7 @@ fn false_property_is_no_field() { let forge = parse( r#"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { "type": "object", "properties": {"id": {"type": "string"}, "gone": false} }}}}}}}} @@ -223,9 +223,9 @@ fn false_property_is_no_field() { } #[test] -fn property_skipped_by_x_stainless_skip_is_no_field() { +fn property_ignored_by_x_forge_ignore_is_no_field() { let forge = parse(&responding_with( - r#"{"type": "object", "properties": {"id": {"type": "string"}, "old_id": {"type": "string", "x-stainless-skip": true}}}"#, + r#"{"type": "object", "properties": {"id": {"type": "string"}, "old_id": {"type": "string", "x-forge-ignore": true}}}"#, )); assert_eq!(field_names(object(response_type(&forge, "a.get"))), ["id"]); diff --git a/tests/parse/types/refs.rs b/tests/parse/types/refs.rs index 0e39e86e3..a00c96c9c 100644 --- a/tests/parse/types/refs.rs +++ b/tests/parse/types/refs.rs @@ -65,7 +65,7 @@ fn annotations_next_to_a_ref_keep_the_model_in_3_1() { let forge = parse( r##"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { "$ref": "#/components/schemas/Zone", "description": "The zone.", "deprecated": true }}}}}}}}, @@ -81,7 +81,7 @@ fn shape_next_to_a_ref_is_intersected_in_3_1() { let forge = parse( r##"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { "$ref": "#/components/schemas/Zone", "properties": {"extra": {"type": "string"}} }}}}}}}}, @@ -97,7 +97,7 @@ fn nullable_next_to_a_ref_is_a_union_with_null_in_3_1() { let forge = parse( r##"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { "$ref": "#/components/schemas/Zone", "nullable": true }}}}}}}}, @@ -153,7 +153,7 @@ fn description_next_to_a_property_ref_is_its_own_in_3_1() { let forge = parse( r##"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { "type": "object", "properties": {"home": {"$ref": "#/components/schemas/Zone", "description": "The home zone."}} }}}}}}}}, @@ -324,7 +324,7 @@ fn read_only_next_to_a_property_ref_counts_in_3_1() { let forge = parse( r##"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { "type": "object", "properties": {"id": {"$ref": "#/components/schemas/Id", "readOnly": true}} }}}}}}}}, @@ -350,7 +350,7 @@ fn read_only_false_next_to_a_ref_does_not_undo_its_schemas_in_3_1() { let forge = parse( r##"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { "type": "object", "properties": {"id": {"$ref": "#/components/schemas/Id", "readOnly": false}} }}}}}}}}, @@ -386,7 +386,7 @@ fn sensitive_next_to_a_property_ref_counts_in_3_1() { let forge = parse( r##"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { "type": "object", "properties": {"token": {"$ref": "#/components/schemas/Token", "x-sensitive": true}} }}}}}}}}, @@ -424,7 +424,7 @@ fn property_default_is_the_first_along_a_chain_of_refs_in_3_1() { let forge = parse( r##"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { "type": "object", "properties": {"size": {"$ref": "#/components/schemas/Size"}} }}}}}}}}, @@ -454,7 +454,7 @@ fn default_next_to_a_property_ref_is_its_own_in_3_1() { let forge = parse( r##"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { "type": "object", "properties": { "size": {"$ref": "#/components/schemas/Size", "default": 5}, diff --git a/tests/parse/types/scalars.rs b/tests/parse/types/scalars.rs index 810e9954a..21e442f53 100644 --- a/tests/parse/types/scalars.rs +++ b/tests/parse/types/scalars.rs @@ -140,7 +140,7 @@ fn base64_content_encoding_is_bytes() { let forge = parse( r#"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { "type": "string", "contentEncoding": "base64" }}}}}}}} @@ -155,7 +155,7 @@ fn binary_format_is_binary_even_with_base64_content_encoding() { let forge = parse( r#"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { "type": "string", "format": "binary", "contentEncoding": "base64" }}}}}}}} @@ -198,7 +198,7 @@ fn true_schema_is_unknown() { let forge = parse( r#"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": true}}}}}}} }"#, ); diff --git a/tests/parse/types/unions.rs b/tests/parse/types/unions.rs index 45f11f81d..40df4abf4 100644 --- a/tests/parse/types/unions.rs +++ b/tests/parse/types/unions.rs @@ -76,7 +76,7 @@ fn type_array_is_exclusive() { let forge = parse( r#"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { "type": ["string", "integer"] }}}}}}}} @@ -138,7 +138,7 @@ fn types_in_a_type_array_are_a_union() { let forge = parse( r#"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { "type": ["string", "integer"] }}}}}}}} @@ -298,8 +298,8 @@ fn discriminator_is_named_by_x_fern_property_name() { "discriminator": {"propertyName": "kind"} }"##, r#"{ - "A": {"type": "object", "properties": {"kind": {"type": "string", "x-fern-property-name": "pet_kind"}}}, - "B": {"type": "object", "properties": {"kind": {"type": "string", "x-fern-property-name": "pet_kind"}}} + "A": {"type": "object", "properties": {"kind": {"type": "string", "x-forge-property-name": "pet_kind"}}}, + "B": {"type": "object", "properties": {"kind": {"type": "string", "x-forge-property-name": "pet_kind"}}} }"#, )); @@ -329,7 +329,7 @@ fn discriminator_default_mapping_is_the_default() { let forge = parse( r##"{ "openapi": "3.2.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { "oneOf": [{"$ref": "#/components/schemas/A"}, {"$ref": "#/components/schemas/B"}], "discriminator": {"propertyName": "kind", "mapping": {"a": "#/components/schemas/A"}, "defaultMapping": "#/components/schemas/B"} diff --git a/tests/wasm.rs b/tests/wasm.rs index 7278ead1d..2e733443b 100644 --- a/tests/wasm.rs +++ b/tests/wasm.rs @@ -41,7 +41,7 @@ const SPEC: &str = r##"{ "paths": { "/pets": { "get": { - "x-fern-sdk-group-name": "pets", "x-fern-sdk-method-name": "list", "summary": "Lists pets.", + "x-forge-group": ["pets"], "x-forge-method": "list", "summary": "Lists pets.", "parameters": [ {"name": "limit", "in": "query", "schema": {"type": "integer", "format": "int32"}}, {"name": "tags", "in": "query", "explode": false, "schema": {"type": "array", "items": {"type": "string"}}} @@ -52,14 +52,14 @@ const SPEC: &str = r##"{ } }, "post": { - "x-fern-sdk-group-name": "pets", "x-fern-sdk-method-name": "create", "deprecated": true, + "x-forge-group": ["pets"], "x-forge-method": "create", "deprecated": true, "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/NewPet"}}}}, "responses": {"201": {"description": "created", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Pet"}}}}} } }, "/pets/{pet_id}": { "get": { - "x-fern-sdk-group-name": "pets", "x-fern-sdk-method-name": "get", + "x-forge-group": ["pets"], "x-forge-method": "get", "parameters": [{"name": "pet_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}}], "responses": { "200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Pet"}}}}, @@ -69,7 +69,7 @@ const SPEC: &str = r##"{ }, "/pets/{pet_id}/photos": { "put": { - "x-fern-sdk-group-name": "pets.photos", "x-fern-sdk-method-name": "upload", + "x-forge-group": ["pets", "photos"], "x-forge-method": "upload", "parameters": [{"name": "pet_id", "in": "path", "required": true, "schema": {"type": "string"}}], "requestBody": {"content": {"application/octet-stream": {"schema": {"type": "string", "format": "binary"}}}}, "responses": {"204": {"description": "done"}} @@ -77,7 +77,7 @@ const SPEC: &str = r##"{ }, "/owners": { "get": { - "x-fern-sdk-group-name": "owners", "x-fern-sdk-method-name": "list", + "x-forge-group": ["owners"], "x-forge-method": "list", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { "type": "object", "properties": { @@ -138,8 +138,8 @@ const FEATURES: &str = r##"{ "/pets/{id}": { "parameters": [{"name": "X-Trace", "in": "header", "schema": {"type": "string"}}], "get": { - "x-fern-sdk-group-name": "pets", "x-fern-sdk-method-name": "get", - "x-fern-availability": "beta", "x-forge-hidden": true, + "x-forge-group": ["pets"], "x-forge-method": "get", + "x-forge-availability": "beta", "x-forge-hidden": true, "x-custom": {"b": 1, "a": [1.5, "two \"quoted\"\n", null, true, {}], "b": 2}, "x-flag": false, "x-count": 9007199254740993, "x-text": "é 😀", "parameters": [ @@ -152,9 +152,9 @@ const FEATURES: &str = r##"{ }, "delete": { "x-forge-aliases": [ - {"x-fern-sdk-group-name": "pets", "x-fern-sdk-method-name": "remove", "x-fern-availability": "deprecated", + {"x-forge-group": ["pets"], "x-forge-method": "remove", "x-forge-availability": "deprecated", "x-forge-require-confirmation": "This deletes the pet.", "x-alias": [1]}, - {"x-fern-sdk-group-name": "admin.pets", "x-fern-sdk-method-name": "delete", "x-forge-require-confirmation": ""} + {"x-forge-group": ["admin", "pets"], "x-forge-method": "delete", "x-forge-require-confirmation": ""} ], "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}], "responses": {"204": {"description": "gone"}} @@ -162,7 +162,7 @@ const FEATURES: &str = r##"{ }, "/pets/{id}/photo": { "put": { - "x-fern-sdk-group-name": "pets.photos", "x-fern-sdk-method-name": "upload", + "x-forge-group": ["pets", "photos"], "x-forge-method": "upload", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}], "requestBody": {"required": true, "content": { "application/octet-stream": {"schema": {"type": "string", "format": "binary", "description": "The photo."}}, From f3189e3fa8cc26b77d4841a1e8bbeac3e0f3b2d4 Mon Sep 17 00:00:00 2001 From: Niels Swimberghe Date: Sun, 25 Oct 2026 22:57:32 -0400 Subject: [PATCH 05/25] test: guard the boundary of the compatibility layer tests/boundary.rs fails, naming the file and line, when an x-fern or x-stainless name appears in src/, tests/ or packages/forge/ outside src/compat/ and tests/compat/. `just boundary` runs it. --- justfile | 4 ++ tests/boundary.rs | 118 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 tests/boundary.rs diff --git a/justfile b/justfile index 77cc872cf..c3b7d5819 100644 --- a/justfile +++ b/justfile @@ -27,6 +27,10 @@ lint: cargo clippy --locked --all-targets --all-features -- -D warnings cargo clippy --locked --all-targets --no-default-features -- -D warnings +# Check that no other tool's extension is named outside the compatibility layer +boundary: + cargo test --locked --test boundary + # Release builds # # Every way of getting release binaries puts them in target/binaries// diff --git a/tests/boundary.rs b/tests/boundary.rs new file mode 100644 index 000000000..c91c59d13 --- /dev/null +++ b/tests/boundary.rs @@ -0,0 +1,118 @@ +//! The boundary of the compatibility layer: no other tool's extension is named +//! anywhere but in it. +//! +//! The compiler keeps what the layer reads out of reach of the rest of the +//! crate, but it cannot see a name in a doc comment, a match arm or a test +//! document, so this searches for them. It reads the engine, its tests, and +//! the npm package that ships its docs and its WIT, and fails with the file +//! and line of each name it finds outside the layer and its tests. + +use std::fs; +use std::path::{Path, PathBuf}; + +/// What the extensions of each tool forge reads begin with. +const VENDORS: [&str; 2] = ["x-fern", "x-stainless"]; + +/// Where those names may be: the layer, its tests, and this file, which +/// writes them to look for them. +const ALLOWED: [&str; 3] = ["src/compat/", "tests/compat/", "tests/boundary.rs"]; + +/// What is searched, from the root of the repository. +const SEARCHED: [&str; 3] = ["src", "tests", "packages/forge"]; + +/// The folders that hold what is built or installed, not what is written. +const SKIPPED: [&str; 4] = ["target", "node_modules", "dist", ".git"]; + +/// Adds every file under `dir` to `out`, leaving out the `SKIPPED` folders. +fn files(dir: &Path, out: &mut Vec) { + let Ok(entries) = fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + let name = path.file_name().and_then(|name| name.to_str()); + if name.is_some_and(|name| SKIPPED.contains(&name)) { + continue; + } + files(&path, out); + } else { + out.push(path); + } + } +} + +/// Returns each file under `SEARCHED` as its path from the root, with `/` +/// between folders, and what it says. A file that is not text is left out. +fn searched() -> Vec<(String, String)> { + let root = Path::new(env!("CARGO_MANIFEST_DIR")); + let mut paths = Vec::new(); + for dir in SEARCHED { + files(&root.join(dir), &mut paths); + } + let mut searched = Vec::new(); + for path in paths { + let Ok(text) = fs::read_to_string(&path) else { + continue; + }; + let relative = path + .strip_prefix(root) + .unwrap_or(&path) + .to_string_lossy() + .replace('\\', "/"); + searched.push((relative, text)); + } + searched +} + +#[test] +fn no_extension_of_another_tool_is_named_outside_the_compatibility_layer() { + let mut found = Vec::new(); + for (path, text) in searched() { + if ALLOWED.iter().any(|allowed| path.starts_with(allowed)) { + continue; + } + for (i, line) in text.lines().enumerate() { + if VENDORS.iter().any(|vendor| line.contains(vendor)) { + found.push(format!("{path}:{}: {}", i + 1, line.trim())); + } + } + } + + assert!( + found.is_empty(), + "the extensions of other tools are named only in src/compat/ and tests/compat/, but:\n{}", + found.join("\n") + ); +} + +#[test] +fn the_search_covers_the_engine_its_tests_and_the_npm_package() { + let searched = searched(); + let has = |path: &str| searched.iter().any(|(searched, _)| searched == path); + + for path in [ + "src/lib.rs", + "src/link/openapi.rs", + "src/plugin/wasm.rs", + "tests/parse/main.rs", + "packages/forge/src/index.ts", + "packages/forge/plugin/forge.wit", + ] { + assert!(has(path), "{path} is not searched"); + } +} + +#[test] +fn the_search_finds_the_names_it_looks_for_where_they_are_allowed() { + // So that finding none elsewhere means something. + let searched = searched(); + for vendor in VENDORS { + assert!( + searched + .iter() + .any(|(path, text)| path.starts_with("src/compat/") && text.contains(vendor)), + "src/compat/ does not name {vendor}" + ); + } +} From f6264734d4cbf23a8f95828a9d3ae4b0fcaa7ed5 Mon Sep 17 00:00:00 2001 From: Niels Swimberghe Date: Sun, 25 Oct 2026 22:57:32 -0400 Subject: [PATCH 06/25] docs: record how RFC 0001 was verified --- rfcs/0001-metadata-resolution-layer.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/rfcs/0001-metadata-resolution-layer.md b/rfcs/0001-metadata-resolution-layer.md index 0b95958cd..2b6bab71c 100644 --- a/rfcs/0001-metadata-resolution-layer.md +++ b/rfcs/0001-metadata-resolution-layer.md @@ -123,7 +123,7 @@ Properties, all deliberate: - **Dependencies**: `link → metadata` and `link → compat` (behind the feature), and `compat → metadata` (an adapter produces metadata's primitives). `metadata` imports neither of the others, so compat sits *beside* the native table rather than under it. `link/resolve.rs` asks native first, compat second — that fallback arm *is* the D4 rule. - **Purity**: the library's contract is `&str` keys in and small values out: primitives, key names, and *format descriptors as data* (for example "a string splits on `.`" or "a list of targets counts as `false`"). The serde reading and the arena writes stay in `link` and are generic, driven by those descriptors. This is what makes the library pluggable, standalone-consumable (a future `forge-ci validate` needs the key table, not the engine), and mechanically liftable into a real separate crate when the first standalone consumer appears (FORGE-5's work). - **The availability vocabulary is native.** The five values live in the native table (they are shipped public API); `fern.rs` only maps `x-fern-availability` onto that primitive. -- **Deletion is the designed end state.** `cargo build --no-default-features --features cli` proves the native vocabulary stands alone; when vendor keys reach end-of-life, deleting `compat/` and the one cfg arm in `link/resolve.rs` touches nothing else — no `x-forge-*` extension changes, in behavior, docs, or WIT. +- **Deletion is the designed end state.** `cargo build --no-default-features --features cli` proves the native vocabulary stands alone; when vendor keys reach end-of-life, deleting `compat/` and the cfg arms in `link/resolve.rs` (one for each kind of object) touches nothing else — no `x-forge-*` extension changes, in behavior, docs, or WIT. - **Dispatch, not semantics**: the layer maps keys to primitives and normalizes simple values. Structural behavior — `x-forge-aliases` becoming methods, `x-forge-commands` describing groups — stays in the builder. ## Enforcement @@ -140,6 +140,18 @@ Properties, all deliberate: - Existing tripwires keep working: `wit_is_current` (WIT regeneration is mechanical via `wit.mjs`), the wasm dump equality, native-vs-wasm generated-output byte equality. - Performance: divan parse benches before/after on the real aggregate (`FORGE_SPEC`); `tests/wasm.rs::bench` re-run; forge-bench re-pinned upstream after merge. +## Verification + +The implementation was checked against the unmodified engine on the released aggregate (`openapi.forge.json`, 27.6 MB, sha `99544617…`): + +- **Fern-keyed behavior.** The original, untouched suite — 379 parse tests written with Fern keys — passed against the new engine before any fixture was migrated. +- **Generated output.** `forge --target ts` on the aggregate writes the same 3,381 files, byte for byte (`diff -r`). +- **The model.** The full model dump (the repo's own `tests/wasm/dump.rs`: every group, method, availability, hidden flag, confirmation, extension and field name) is identical between the old and new engines: 197,702 lines, covering 3,021 availability values, 1,259 hidden flags, 217 confirmations and 4,695 extension entries. The generated TypeScript consumes none of those four, which is why the dump, not the output diff, is what proves them. +- **Across the plugin boundary.** `tests/wasm.rs`, including the ignored `bench` on the aggregate, reports identical output and dump, native versus WASM, for 1,317 groups and 3,381 files; `wit_is_current` holds with the regenerated WIT. +- **Independence.** `cargo test --no-default-features --features cli` (compat off) passes all 438 tests of the native suite; with compat on, 486 pass. Clippy is clean in the library-alone, compat-off and all-features configurations. +- **The checks can fail.** Breaking the native-wins rule fails 8 compat tests; planting a vendor name in a doc comment fails the boundary test, naming the file and line. +- **Performance** (parse and link of the aggregate, `cargo bench --bench parse`, six alternating runs against the unmodified code): fastest 40.8 ms against 40.3 ms (+1.2%), median 42.2 ms against 41.5 ms (+1.6%), peak memory 69.13 MB against 68.75 MB (+0.55%), the same peak allocation count. The cost is the dispatch of the ~15k extension keys through the native table and the adapters, and the provenance kept on each operation's definition. Parsing is under a tenth of a whole `forge` run on the aggregate (about 0.5 s), so the end-to-end change is about 0.1%. + ## Release shape Non-breaking. The WIT changes are doc comments only — `forge:plugin@0.3.0` stays, bindings regenerate identically, no plugin rebuilds anywhere; npm gets an additive release (new native keys accepted, compat module added); the spec surface grows append-only; generated output is byte-identical. Vocabulary renames that *are* breaking (group→namespace, the availability word) are deliberately deferred to FORGE-3 so one breaking release absorbs all of them; the versioning policy that governs this is FORGE-4's RFC. From abc48965f83292c395b2f6977c2318e83ff3b439 Mon Sep 17 00:00:00 2001 From: Niels Swimberghe Date: Mon, 26 Oct 2026 11:12:20 -0400 Subject: [PATCH 07/25] perf: turn away other keys early, and keep a resolved key small - Each table checks its prefix (x-forge-, x-fern-, x-stainless-) before matching, and only x- keys are resolved, so a skipped key costs one compare instead of a match per table. - A Hit holds only its field and slot; a key's name comes from the slot, in the per-kind name tables, when an error needs it. The reader's per-key result is back to 16 bytes, which fits in registers. Output and the model dump of the aggregate are unchanged. --- src/compat/fern.rs | 45 +++++++---- src/compat/mod.rs | 67 +++++++++++++++- src/compat/stainless.rs | 15 +++- src/link.rs | 9 ++- src/link/openapi.rs | 17 +++-- src/link/resolve.rs | 81 ++++++++++++++++---- src/metadata/mod.rs | 165 +++++++++++++++++++++++++--------------- 7 files changed, 294 insertions(+), 105 deletions(-) diff --git a/src/compat/fern.rs b/src/compat/fern.rs index 5c57b4690..ccb5707ca 100644 --- a/src/compat/fern.rs +++ b/src/compat/fern.rs @@ -15,6 +15,9 @@ use crate::metadata::{BoolFormat, DefinitionField, GroupFormat, Hit, ParameterField, SchemaField}; +/// What every key of the adapter begins with. A key without it is turned +/// away before it is matched against the table. +const PREFIX: &str = "x-fern-"; const METHOD_NAME: &str = "x-fern-sdk-method-name"; const GROUP_NAME: &str = "x-fern-sdk-group-name"; const IGNORE: &str = "x-fern-ignore"; @@ -23,33 +26,43 @@ const PARAMETER_NAME: &str = "x-fern-parameter-name"; const PROPERTY_NAME: &str = "x-fern-property-name"; const TYPE_NAME: &str = "x-fern-type-name"; +/// The names of the adapter's keys of an operation or an alias, by slot. +pub(super) const DEFINITION_NAMES: [&str; 4] = [METHOD_NAME, GROUP_NAME, IGNORE, AVAILABILITY]; +/// The names of the adapter's keys of a Parameter Object, by slot. +pub(super) const PARAMETER_NAMES: [&str; 1] = [PARAMETER_NAME]; +/// The names of the adapter's keys of a Schema Object, by slot. +pub(super) const SCHEMA_NAMES: [&str; 2] = [TYPE_NAME, PROPERTY_NAME]; + pub(super) fn definition(key: &str) -> Option> { + if !key.starts_with(PREFIX) { + return None; + } Some(match key { - METHOD_NAME => Hit::compat(DefinitionField::Method, METHOD_NAME, 0), - GROUP_NAME => Hit::compat( - DefinitionField::Group(GroupFormat::StringOrArray { separator: '.' }), - GROUP_NAME, - 1, - ), - IGNORE => Hit::compat(DefinitionField::Ignore(BoolFormat::Bool), IGNORE, 2), - AVAILABILITY => Hit::compat(DefinitionField::Availability, AVAILABILITY, 3), + METHOD_NAME => Hit::compat(DefinitionField::Method, 0), + GROUP_NAME => Hit::compat(DefinitionField::Group(GroupFormat::StringOrArray { separator: '.' }), 1), + IGNORE => Hit::compat(DefinitionField::Ignore(BoolFormat::Bool), 2), + AVAILABILITY => Hit::compat(DefinitionField::Availability, 3), _ => return None, }) } pub(super) fn parameter(key: &str) -> Option> { + if !key.starts_with(PREFIX) { + return None; + } Some(match key { - PARAMETER_NAME => Hit::compat(ParameterField::Name, PARAMETER_NAME, 0), + PARAMETER_NAME => Hit::compat(ParameterField::Name, 0), _ => return None, }) } -/// Slots 0 and 1 of the compatibility keys of a Schema Object are Fern's; -/// the next adapter's start at 2. pub(super) fn schema(key: &str) -> Option> { + if !key.starts_with(PREFIX) { + return None; + } Some(match key { - TYPE_NAME => Hit::compat(SchemaField::ModelName, TYPE_NAME, 0), - PROPERTY_NAME => Hit::compat(SchemaField::PropertyName, PROPERTY_NAME, 1), + TYPE_NAME => Hit::compat(SchemaField::ModelName, 0), + PROPERTY_NAME => Hit::compat(SchemaField::PropertyName, 1), _ => return None, }) } @@ -62,13 +75,13 @@ mod tests { fn each_key_is_read_as_itself_and_as_not_native() { for key in [METHOD_NAME, GROUP_NAME, IGNORE, AVAILABILITY] { let hit = definition(key).unwrap_or_else(|| panic!("{key} is not a definition key")); - assert_eq!((hit.key, hit.native), (key, false)); + assert!(!hit.is_native(), "{key}"); } let hit = parameter(PARAMETER_NAME).unwrap(); - assert_eq!((hit.key, hit.native), (PARAMETER_NAME, false)); + assert!(!hit.is_native(), "{PARAMETER_NAME}"); for key in [TYPE_NAME, PROPERTY_NAME] { let hit = schema(key).unwrap_or_else(|| panic!("{key} is not a schema key")); - assert_eq!((hit.key, hit.native), (key, false)); + assert!(!hit.is_native(), "{key}"); } } diff --git a/src/compat/mod.rs b/src/compat/mod.rs index 2545a0124..555f2d7cf 100644 --- a/src/compat/mod.rs +++ b/src/compat/mod.rs @@ -31,6 +31,71 @@ pub(crate) fn parameter(key: &str) -> Option> { } /// Returns what `key` says on a Schema Object, if an adapter reads it there. +/// Each adapter counts its keys from zero, so Stainless's come after Fern's. pub(crate) fn schema(key: &str) -> Option> { - fern::schema(key).or_else(|| stainless::schema(key)) + fern::schema(key).or_else(|| stainless::schema(key).map(|hit| hit.after(FERN_SCHEMA_KEYS))) +} + +/// How many keys of a Schema Object Fern has, which is where Stainless's +/// start. +const FERN_SCHEMA_KEYS: u8 = fern::SCHEMA_NAMES.len() as u8; + +/// Returns the name of the key of an operation or an alias that is `n`th +/// among the keys of the adapters. +pub(crate) fn definition_name(n: u8) -> &'static str { + fern::DEFINITION_NAMES[usize::from(n)] +} + +/// Returns the name of the key of a Parameter Object that is `n`th among the +/// keys of the adapters. +pub(crate) fn parameter_name(n: u8) -> &'static str { + fern::PARAMETER_NAMES[usize::from(n)] +} + +/// Returns the name of the key of a Schema Object that is `n`th among the +/// keys of the adapters. +pub(crate) fn schema_name(n: u8) -> &'static str { + match n.checked_sub(FERN_SCHEMA_KEYS) { + None => fern::SCHEMA_NAMES[usize::from(n)], + Some(n) => stainless::SCHEMA_NAMES[usize::from(n)], + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::metadata::COMPAT_SLOTS; + + /// Asserts that each key in `names` is read by `read` as a key that is + /// not native, whose slot `name` names it by. + #[track_caller] + fn read_as_itself(names: &[&'static str], read: fn(&str) -> Option>, name: fn(u8) -> &'static str) { + for &key in names { + let hit = read(key).unwrap_or_else(|| panic!("{key} is not read")); + assert!(!hit.is_native(), "{key}"); + assert_eq!(name(hit.slot - COMPAT_SLOTS), key); + } + } + + #[test] + fn each_key_of_each_adapter_is_named_by_its_slot() { + read_as_itself(&fern::DEFINITION_NAMES, definition, definition_name); + read_as_itself(&fern::PARAMETER_NAMES, parameter, parameter_name); + read_as_itself(&fern::SCHEMA_NAMES, schema, schema_name); + read_as_itself(&stainless::SCHEMA_NAMES, schema, schema_name); + } + + #[test] + fn the_slots_of_the_adapters_do_not_collide_and_fit_in_a_set() { + let mut slots: Vec = fern::SCHEMA_NAMES + .iter() + .chain(&stainless::SCHEMA_NAMES) + .map(|key| schema(key).unwrap().slot) + .collect(); + let len = slots.len(); + slots.sort_unstable(); + slots.dedup(); + assert_eq!(slots.len(), len); + assert!(slots.iter().all(|&slot| slot < 64)); + } } diff --git a/src/compat/stainless.rs b/src/compat/stainless.rs index 9338545a0..90f9dc265 100644 --- a/src/compat/stainless.rs +++ b/src/compat/stainless.rs @@ -10,13 +10,20 @@ use crate::metadata::{BoolFormat, Hit, SchemaField}; +/// What every key of the adapter begins with. A key without it is turned +/// away before it is matched against the table. +const PREFIX: &str = "x-stainless-"; const SKIP: &str = "x-stainless-skip"; -/// Slot 2 among the compatibility keys of a Schema Object: Fern's are the -/// first two. +/// The names of the adapter's keys of a Schema Object, by slot. +pub(super) const SCHEMA_NAMES: [&str; 1] = [SKIP]; + pub(super) fn schema(key: &str) -> Option> { + if !key.starts_with(PREFIX) { + return None; + } Some(match key { - SKIP => Hit::compat(SchemaField::Ignore(BoolFormat::BoolOrTargets), SKIP, 2), + SKIP => Hit::compat(SchemaField::Ignore(BoolFormat::BoolOrTargets), 0), _ => return None, }) } @@ -28,7 +35,7 @@ mod tests { #[test] fn the_skip_is_read_on_a_schema_as_a_boolean_or_targets_and_not_native() { let hit = schema(SKIP).unwrap(); - assert_eq!((hit.key, hit.native), (SKIP, false)); + assert!(!hit.is_native(), "{SKIP}"); assert!(matches!(hit.field, SchemaField::Ignore(BoolFormat::BoolOrTargets))); } diff --git a/src/link.rs b/src/link.rs index fcca41f55..84072a4cd 100644 --- a/src/link.rs +++ b/src/link.rs @@ -580,14 +580,17 @@ impl<'d> Builder<'d> { if name.is_empty() { self.error(format_args!( "{} has a group name with no letters or digits", - definition.group_names.key + resolve::definition_name(definition.group_names.slot) )); return; } } let name = Name::new(doc.str(definition.method_name.value)); if name.is_empty() { - self.error(format_args!("{} has no letters or digits", definition.method_name.key)); + self.error(format_args!( + "{} has no letters or digits", + resolve::definition_name(definition.method_name.slot) + )); return; } let mut availability = None; @@ -597,7 +600,7 @@ impl<'d> Builder<'d> { if availability.is_none() { self.error(format_args!( "{} {value:?} is not alpha, beta, preview, generally-available or deprecated", - definition.availability.key + resolve::definition_name(definition.availability.slot) )); } } diff --git a/src/link/openapi.rs b/src/link/openapi.rs index 877b6df47..0a99e81a5 100644 --- a/src/link/openapi.rs +++ b/src/link/openapi.rs @@ -246,8 +246,9 @@ pub(super) struct Operation { #[derive(Default)] pub(super) struct Val { pub(super) value: T, - /// The key as written, or empty if no key gave the value. - pub(super) key: &'static str, + /// The slot of the key that gave the value, which names it. It means + /// nothing if no key gave one. + pub(super) slot: u8, native: bool, } @@ -257,7 +258,7 @@ impl Val { fn set(&mut self, hit: &Hit, value: T) { if takes(hit, &mut self.native) { self.value = value; - self.key = hit.key; + self.slot = hit.slot; } } } @@ -266,8 +267,8 @@ impl Val { /// far, which came from a native key if `native` is set, and records where /// the value kept came from. fn takes(hit: &Hit, native: &mut bool) -> bool { - if hit.native || !*native { - *native = hit.native; + if hit.is_native() || !*native { + *native = hit.is_native(); return true; } false @@ -1117,9 +1118,11 @@ macro_rules! entry { fn visit_str(self, v: &str) -> Result<$name, E> { // The OpenAPI keys first: most keys of a document are - // one of them, and none is an extension. + // one of them, and none is an extension. Of the rest, + // only an `x-` key is asked about, so that a key that + // is skipped costs one compare. Ok(match $keys::from_key(v) { - $keys::Other => match $resolve(v) { + $keys::Other if v.starts_with("x-") => match $resolve(v) { Some(hit) => $name::Extension(hit), None => $name::Key($keys::Other), }, diff --git a/src/link/resolve.rs b/src/link/resolve.rs index 87b2db0bc..956351c13 100644 --- a/src/link/resolve.rs +++ b/src/link/resolve.rs @@ -16,45 +16,63 @@ use crate::metadata::{self, DefinitionField, DocumentField, Hit, ParameterField, /// Returns what `key` says on an operation or an alias, if it is a key of /// one. pub(super) fn definition(key: &str) -> Option> { - if !key.starts_with("x-") { - return None; - } metadata::definition(key).or_else(|| compat_definition(key)) } /// Returns what `key` says on a Parameter Object, if it is a key of one. pub(super) fn parameter(key: &str) -> Option> { - if !key.starts_with("x-") { - return None; - } metadata::parameter(key).or_else(|| compat_parameter(key)) } /// Returns what `key` says on a Schema Object, if it is a key of one. pub(super) fn schema(key: &str) -> Option> { - if !key.starts_with("x-") { - return None; - } metadata::schema(key).or_else(|| compat_schema(key)) } /// Returns what `key` says at the root of the document, if it is a key of /// it. No compatibility key says anything there. pub(super) fn document(key: &str) -> Option> { - if !key.starts_with("x-") { - return None; - } metadata::document(key) } +/// A kind of object with extensions, which can name its keys by slot. +pub(super) trait Kind { + /// Returns the name of the key in `slot`, as the document writes it. + fn name(slot: u8) -> &'static str; +} + +impl Kind for DefinitionField { + fn name(slot: u8) -> &'static str { + metadata::native_name(&metadata::DEFINITION_NAMES, slot).unwrap_or_else(|| compat_definition_name(slot)) + } +} + +impl Kind for ParameterField { + fn name(slot: u8) -> &'static str { + metadata::native_name(&metadata::PARAMETER_NAMES, slot).unwrap_or_else(|| compat_parameter_name(slot)) + } +} + +impl Kind for SchemaField { + fn name(slot: u8) -> &'static str { + metadata::native_name(&metadata::SCHEMA_NAMES, slot).unwrap_or_else(|| compat_schema_name(slot)) + } +} + +impl Kind for DocumentField { + fn name(slot: u8) -> &'static str { + metadata::native_name(&metadata::DOCUMENT_NAMES, slot).unwrap_or_default() + } +} + /// Returns an error if the key of `hit` is in `seen`, which has a bit for /// each key read so far from the object, and else adds it. A native key and /// a compatibility key of the same meaning are different keys, so both can /// be written: the native one's value is kept. -pub(super) fn once(hit: &Hit, seen: &mut u64) -> Result<(), E> { +pub(super) fn once(hit: &Hit, seen: &mut u64) -> Result<(), E> { let bit = 1u64 << hit.slot; if *seen & bit != 0 { - return Err(E::duplicate_field(hit.key)); + return Err(E::duplicate_field(F::name(hit.slot))); } *seen |= bit; Ok(()) @@ -89,3 +107,38 @@ fn compat_schema(key: &str) -> Option> { fn compat_schema(_: &str) -> Option> { None } + +#[cfg(feature = "compat")] +fn compat_definition_name(slot: u8) -> &'static str { + crate::compat::definition_name(slot - metadata::COMPAT_SLOTS) +} + +#[cfg(not(feature = "compat"))] +fn compat_definition_name(_: u8) -> &'static str { + "" +} + +#[cfg(feature = "compat")] +fn compat_parameter_name(slot: u8) -> &'static str { + crate::compat::parameter_name(slot - metadata::COMPAT_SLOTS) +} + +#[cfg(not(feature = "compat"))] +fn compat_parameter_name(_: u8) -> &'static str { + "" +} + +#[cfg(feature = "compat")] +fn compat_schema_name(slot: u8) -> &'static str { + crate::compat::schema_name(slot - metadata::COMPAT_SLOTS) +} + +#[cfg(not(feature = "compat"))] +fn compat_schema_name(_: u8) -> &'static str { + "" +} + +/// Returns the name of the key of an operation or an alias in `slot`. +pub(super) fn definition_name(slot: u8) -> &'static str { + DefinitionField::name(slot) +} diff --git a/src/metadata/mod.rs b/src/metadata/mod.rs index 559869b8f..97c986388 100644 --- a/src/metadata/mod.rs +++ b/src/metadata/mod.rs @@ -21,50 +21,68 @@ use crate::Availability; /// [`definition`], since an alias cannot have one. pub(crate) const ALIASES: &str = "x-forge-aliases"; +/// What every native key begins with, but `x-sensitive`. A key without it is +/// turned away before it is matched against the table: most extensions in a +/// document are no key of any table, and this is the cheap way to say so. +const PREFIX: &str = "x-forge-"; + /// A key that says something about the object it is on. +/// +/// It is kept to a few bytes, since one is read for every key of every +/// object: the key's name, which only an error needs, is found from its +/// `slot`, in the table of names of its kind of object. #[derive(Clone, Copy)] pub(crate) struct Hit { /// What the key says. pub(crate) field: F, - /// The key as the document writes it, for errors. - pub(crate) key: &'static str, - /// Whether the key is native. A value from a native key is kept over one - /// from any other, whatever order they are written in. - pub(crate) native: bool, /// The bit of the key in a set of the keys read from an object so far, - /// which is how a key written twice is found. Unique among the keys of - /// one kind of object, native or not. + /// which is how a key written twice is found, and the index of its name. + /// Unique among the keys of one kind of object, native or not: the + /// native keys count up from zero, and the others from [`COMPAT_SLOTS`]. pub(crate) slot: u8, } /// The first slot of the keys that are not native, so that slots never /// collide with the native ones, which count up from zero. -#[cfg(feature = "compat")] -const COMPAT_SLOTS: u8 = 32; +pub(crate) const COMPAT_SLOTS: u8 = 32; impl Hit { /// Returns a native key, in slot `slot`, which is under 32. - const fn native(field: F, key: &'static str, slot: u8) -> Hit { - Hit { - field, - key, - native: true, - slot, - } + const fn native(field: F, slot: u8) -> Hit { + Hit { field, slot } } /// Returns a key that is not native, which a compatibility layer /// reads in place of a native one. It is the `n`th of its kind of /// object, counting from zero, `n` under 32. #[cfg(feature = "compat")] - pub(crate) const fn compat(field: F, key: &'static str, n: u8) -> Hit { + pub(crate) const fn compat(field: F, n: u8) -> Hit { Hit { field, - key, - native: false, slot: COMPAT_SLOTS + n, } } + + /// Returns this key moved `n` slots on, for a compatibility layer that + /// puts the keys of several adapters one after another. + #[cfg(feature = "compat")] + pub(crate) fn after(self, n: u8) -> Hit { + Hit { + field: self.field, + slot: self.slot + n, + } + } + + /// Returns whether the key is native. A value from a native key is kept + /// over one from any other, whatever order they are written in. + pub(crate) const fn is_native(&self) -> bool { + self.slot < COMPAT_SLOTS + } +} + +/// Returns the name in `names` of the key in `slot`, if it is a native one. +pub(crate) fn native_name(names: &[&'static str], slot: u8) -> Option<&'static str> { + names.get(usize::from(slot)).copied() } /// What a key says on an operation, or on an alias of one. @@ -135,23 +153,41 @@ pub(crate) enum BoolFormat { /// Returns what `key` says on an operation or an alias, if it is a native /// key of one. pub(crate) fn definition(key: &str) -> Option> { - const METHOD: &str = "x-forge-method"; - const GROUP: &str = "x-forge-group"; - const IGNORE: &str = "x-forge-ignore"; - const AVAILABILITY: &str = "x-forge-availability"; - const HIDDEN: &str = "x-forge-hidden"; - const CONFIRMATION: &str = "x-forge-require-confirmation"; + if !key.starts_with(PREFIX) { + return None; + } Some(match key { - METHOD => Hit::native(DefinitionField::Method, METHOD, 0), - GROUP => Hit::native(DefinitionField::Group(GroupFormat::Array), GROUP, 1), - IGNORE => Hit::native(DefinitionField::Ignore(BoolFormat::Bool), IGNORE, 2), - AVAILABILITY => Hit::native(DefinitionField::Availability, AVAILABILITY, 3), - HIDDEN => Hit::native(DefinitionField::Hidden, HIDDEN, 4), - CONFIRMATION => Hit::native(DefinitionField::Confirmation, CONFIRMATION, 5), + METHOD => Hit::native(DefinitionField::Method, 0), + GROUP => Hit::native(DefinitionField::Group(GroupFormat::Array), 1), + IGNORE => Hit::native(DefinitionField::Ignore(BoolFormat::Bool), 2), + AVAILABILITY => Hit::native(DefinitionField::Availability, 3), + HIDDEN => Hit::native(DefinitionField::Hidden, 4), + CONFIRMATION => Hit::native(DefinitionField::Confirmation, 5), _ => return None, }) } +const METHOD: &str = "x-forge-method"; +const GROUP: &str = "x-forge-group"; +const IGNORE: &str = "x-forge-ignore"; +const AVAILABILITY: &str = "x-forge-availability"; +const HIDDEN: &str = "x-forge-hidden"; +const CONFIRMATION: &str = "x-forge-require-confirmation"; +const PARAMETER_NAME: &str = "x-forge-parameter-name"; +const MODEL_NAME: &str = "x-forge-model-name"; +const PROPERTY_NAME: &str = "x-forge-property-name"; +const SENSITIVE: &str = "x-sensitive"; +const COMMANDS: &str = "x-forge-commands"; + +/// The names of the native keys of an operation or an alias, by slot. +pub(crate) const DEFINITION_NAMES: [&str; 6] = [METHOD, GROUP, IGNORE, AVAILABILITY, HIDDEN, CONFIRMATION]; +/// The names of the native keys of a Parameter Object, by slot. +pub(crate) const PARAMETER_NAMES: [&str; 1] = [PARAMETER_NAME]; +/// The names of the native keys of a Schema Object, by slot. +pub(crate) const SCHEMA_NAMES: [&str; 4] = [MODEL_NAME, PROPERTY_NAME, IGNORE, SENSITIVE]; +/// The names of the native keys of the document, by slot. +pub(crate) const DOCUMENT_NAMES: [&str; 1] = [COMMANDS]; + /// Returns whether `key` is the `x-forge-aliases` of an operation. pub(crate) fn is_aliases(key: &str) -> bool { key == ALIASES @@ -160,24 +196,26 @@ pub(crate) fn is_aliases(key: &str) -> bool { /// Returns what `key` says on a Parameter Object, if it is a native key of /// one. pub(crate) fn parameter(key: &str) -> Option> { - const NAME: &str = "x-forge-parameter-name"; + if !key.starts_with(PREFIX) { + return None; + } Some(match key { - NAME => Hit::native(ParameterField::Name, NAME, 0), + PARAMETER_NAME => Hit::native(ParameterField::Name, 0), _ => return None, }) } /// Returns what `key` says on a Schema Object, if it is a native key of one. pub(crate) fn schema(key: &str) -> Option> { - const MODEL_NAME: &str = "x-forge-model-name"; - const PROPERTY_NAME: &str = "x-forge-property-name"; - const IGNORE: &str = "x-forge-ignore"; - const SENSITIVE: &str = "x-sensitive"; + // `x-sensitive` is the one native key outside the prefix. + if !key.starts_with(PREFIX) && key != SENSITIVE { + return None; + } Some(match key { - MODEL_NAME => Hit::native(SchemaField::ModelName, MODEL_NAME, 0), - PROPERTY_NAME => Hit::native(SchemaField::PropertyName, PROPERTY_NAME, 1), - IGNORE => Hit::native(SchemaField::Ignore(BoolFormat::Bool), IGNORE, 2), - SENSITIVE => Hit::native(SchemaField::Sensitive, SENSITIVE, 3), + MODEL_NAME => Hit::native(SchemaField::ModelName, 0), + PROPERTY_NAME => Hit::native(SchemaField::PropertyName, 1), + IGNORE => Hit::native(SchemaField::Ignore(BoolFormat::Bool), 2), + SENSITIVE => Hit::native(SchemaField::Sensitive, 3), _ => return None, }) } @@ -185,9 +223,11 @@ pub(crate) fn schema(key: &str) -> Option> { /// Returns what `key` says at the root of the document, if it is a native /// key of it. pub(crate) fn document(key: &str) -> Option> { - const COMMANDS: &str = "x-forge-commands"; + if !key.starts_with(PREFIX) { + return None; + } Some(match key { - COMMANDS => Hit::native(DocumentField::Commands, COMMANDS, 0), + COMMANDS => Hit::native(DocumentField::Commands, 0), _ => return None, }) } @@ -209,7 +249,7 @@ pub(crate) fn availability(value: &str) -> Option { mod tests { use super::*; - /// Every native key of each kind of object, as `(key, slot)`. + /// Every native key of each kind of object, in slot order. fn native_keys() -> [&'static [&'static str]; 4] { [ &[ @@ -231,25 +271,30 @@ mod tests { ] } + /// Asserts that each of `keys` is read by `read` as a native key whose + /// slot names it in `names`. + #[track_caller] + fn read_as_itself(keys: &[&str], names: &[&'static str], read: fn(&str) -> Option>) { + for &key in keys { + let hit = read(key).unwrap_or_else(|| panic!("{key} is not read")); + assert!(hit.is_native(), "{key}"); + assert_eq!(native_name(names, hit.slot), Some(key)); + } + assert_eq!(keys.len(), names.len(), "every name is a key that is read"); + } + #[test] fn each_native_key_is_read_as_itself_and_as_native() { let [definitions, parameters, schemas, documents] = native_keys(); - for &key in definitions { - let hit = definition(key).unwrap_or_else(|| panic!("{key} is not a definition key")); - assert_eq!((hit.key, hit.native), (key, true)); - } - for &key in parameters { - let hit = parameter(key).unwrap_or_else(|| panic!("{key} is not a parameter key")); - assert_eq!((hit.key, hit.native), (key, true)); - } - for &key in schemas { - let hit = schema(key).unwrap_or_else(|| panic!("{key} is not a schema key")); - assert_eq!((hit.key, hit.native), (key, true)); - } - for &key in documents { - let hit = document(key).unwrap_or_else(|| panic!("{key} is not a document key")); - assert_eq!((hit.key, hit.native), (key, true)); - } + read_as_itself(definitions, &DEFINITION_NAMES, definition); + read_as_itself(parameters, &PARAMETER_NAMES, parameter); + read_as_itself(schemas, &SCHEMA_NAMES, schema); + read_as_itself(documents, &DOCUMENT_NAMES, document); + } + + #[test] + fn a_slot_past_the_native_ones_has_no_native_name() { + assert_eq!(native_name(&DEFINITION_NAMES, COMPAT_SLOTS), None); } #[test] From 7a9dee1ddaa287af0193d10309767258f478d1c8 Mon Sep 17 00:00:00 2001 From: Niels Swimberghe Date: Mon, 26 Oct 2026 11:12:20 -0400 Subject: [PATCH 08/25] chore: add benches/compare.sh, a paired benchmark against a revision Builds REV in a throwaway worktree and runs the parse bench of both in ABBA order, so drift in the machine's speed cancels, leaving out pairs in which it changed speed. `just bench-compare [rev] [pairs]` runs it. --- benches/compare.sh | 81 ++++++++++++++++++++++++++++++++++++++++++++++ justfile | 4 +++ 2 files changed, 85 insertions(+) create mode 100755 benches/compare.sh diff --git a/benches/compare.sh b/benches/compare.sh new file mode 100755 index 000000000..d79406d66 --- /dev/null +++ b/benches/compare.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# Compares the parse benchmark of the working tree against a git revision: +# +# FORGE_SPEC=/path/to/openapi.json benches/compare.sh [REV] [PAIRS] +# +# REV defaults to main and PAIRS to 12. The revision is built in a throwaway +# worktree, so the working tree is left as it is. +# +# Each pair runs the two in ABBA order (base, new, new, base) and takes the +# mean of each side's two runs, so a machine that warms up or cools down +# through the session does not bias one side. Three warm-up pairs are +# discarded first. The statistics are paired, on divan's fastest sample of +# each run. A pair whose four runs differ by more than 10% is reported and +# left out: the machine changed speed in the middle of it. +# +# A difference under about twice its standard error is not one this can see. +set -eu + +REV=${1:-main} +PAIRS=${2:-12} +: "${FORGE_SPEC:?set FORGE_SPEC to the document to benchmark}" +export FORGE_SPEC + +ROOT=$(git rev-parse --show-toplevel) +WORK=$(mktemp -d) +trap 'git -C "$ROOT" worktree remove --force "$WORK/base" > /dev/null 2>&1 || true; rm -rf "$WORK"' EXIT + +# Builds the parse benchmark of the tree at $1 into the target folder $2, and +# copies it to $3. +build() { + (cd "$1" && CARGO_TARGET_DIR=$2 cargo bench --locked --bench parse --no-run 2>&1 | tail -1) >&2 + local bin + bin=$(ls -t "$2"/release/deps/parse-* | grep -vE '\.(d|o)$' | head -1) + cp "$bin" "$3" +} + +git -C "$ROOT" worktree add --detach "$WORK/base" "$REV" > /dev/null 2>&1 +build "$WORK/base" "$WORK/target-base" "$WORK/base.bin" +build "$ROOT" "$ROOT/target" "$WORK/new.bin" + +# Writes the fastest sample of a divan run of $1, in ms. +measure() { + "$1" --bench forge > "$WORK/run.out" 2> /dev/null + awk ' + /├─ forge/ {f = 1} + f && /╰─|├─/ && /[0-9] (ms|µs|s) / { + for (i = 1; i <= NF; i++) + if ($i ~ /^[0-9]+(\.[0-9]+)?$/) { + v = $i; u = $(i+1) + print (u == "µs" ? v / 1000 : u == "s" ? v * 1000 : v) + exit + } + }' "$WORK/run.out" +} + +for i in 1 2 3; do + measure "$WORK/base.bin" > /dev/null; measure "$WORK/new.bin" > /dev/null +done +: > "$WORK/pairs" +for i in $(seq "$PAIRS"); do + b1=$(measure "$WORK/base.bin"); n1=$(measure "$WORK/new.bin") + n2=$(measure "$WORK/new.bin"); b2=$(measure "$WORK/base.bin") + echo "$b1 $n1 $n2 $b2" >> "$WORK/pairs" + printf 'pair %2d base %s %s ms new %s %s ms\n' "$i" "$b1" "$b2" "$n1" "$n2" >&2 +done + +awk -v rev="$REV" ' + { + lo = $1; hi = $1 + for (i = 2; i <= 4; i++) { if ($i < lo) lo = $i; if ($i > hi) hi = $i } + if (hi > lo * 1.1) { printf "pair %d left out: the machine changed speed\n", NR; next } + n++; b = ($1 + $4) / 2; d = ($2 + $3) / 2 - b + sb += b; sd += d; sd2 += d * d; if (d > 0) up++ + } + END { + if (n < 2) { print "too few pairs to compare"; exit 1 } + mean = sd / n; se = sqrt((sd2 - n * mean * mean) / (n - 1) / n) + printf "%s: %.2f ms\n", rev, sb / n + printf "working tree - %s: %+.3f ms (%+.2f%%), standard error %.3f ms, t = %.1f, slower in %d of %d pairs\n", + rev, mean, 100 * mean / (sb / n), se, (se > 0 ? mean / se : 0), up, n + }' "$WORK/pairs" diff --git a/justfile b/justfile index c3b7d5819..ffbadbc88 100644 --- a/justfile +++ b/justfile @@ -27,6 +27,10 @@ lint: cargo clippy --locked --all-targets --all-features -- -D warnings cargo clippy --locked --all-targets --no-default-features -- -D warnings +# Compare the parse benchmark against a revision, with FORGE_SPEC set to a document +bench-compare rev="main" pairs="12": + benches/compare.sh {{rev}} {{pairs}} + # Check that no other tool's extension is named outside the compatibility layer boundary: cargo test --locked --test boundary From 9f47d32858eed048efdcbd37dbc25ecd260c19d7 Mon Sep 17 00:00:00 2001 From: Niels Swimberghe Date: Mon, 26 Oct 2026 11:12:20 -0400 Subject: [PATCH 09/25] docs: record the measured performance of RFC 0001 and where it went --- rfcs/0001-metadata-resolution-layer.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/rfcs/0001-metadata-resolution-layer.md b/rfcs/0001-metadata-resolution-layer.md index 2b6bab71c..c94a91d5a 100644 --- a/rfcs/0001-metadata-resolution-layer.md +++ b/rfcs/0001-metadata-resolution-layer.md @@ -148,9 +148,11 @@ The implementation was checked against the unmodified engine on the released agg - **Generated output.** `forge --target ts` on the aggregate writes the same 3,381 files, byte for byte (`diff -r`). - **The model.** The full model dump (the repo's own `tests/wasm/dump.rs`: every group, method, availability, hidden flag, confirmation, extension and field name) is identical between the old and new engines: 197,702 lines, covering 3,021 availability values, 1,259 hidden flags, 217 confirmations and 4,695 extension entries. The generated TypeScript consumes none of those four, which is why the dump, not the output diff, is what proves them. - **Across the plugin boundary.** `tests/wasm.rs`, including the ignored `bench` on the aggregate, reports identical output and dump, native versus WASM, for 1,317 groups and 3,381 files; `wit_is_current` holds with the regenerated WIT. -- **Independence.** `cargo test --no-default-features --features cli` (compat off) passes all 438 tests of the native suite; with compat on, 486 pass. Clippy is clean in the library-alone, compat-off and all-features configurations. +- **Independence.** `cargo test --no-default-features --features cli` (compat off) passes all 439 tests of the native suite; with compat on, 489 pass. Clippy is clean in the library-alone, compat-off and all-features configurations. - **The checks can fail.** Breaking the native-wins rule fails 8 compat tests; planting a vendor name in a doc comment fails the boundary test, naming the file and line. -- **Performance** (parse and link of the aggregate, `cargo bench --bench parse`, six alternating runs against the unmodified code): fastest 40.8 ms against 40.3 ms (+1.2%), median 42.2 ms against 41.5 ms (+1.6%), peak memory 69.13 MB against 68.75 MB (+0.55%), the same peak allocation count. The cost is the dispatch of the ~15k extension keys through the native table and the adapters, and the provenance kept on each operation's definition. Parsing is under a tenth of a whole `forge` run on the aggregate (about 0.5 s), so the end-to-end change is about 0.1%. +- **Performance.** Measured with `benches/compare.sh` (`just bench-compare`): parse and link of the aggregate, paired against the unmodified engine, each pair run base–new–new–base so that drift in the machine's speed cancels, and pairs in which the machine changed speed left out. The working tree is 0–1% slower (about +0.45 ms of 41.8 ms, standard error 0.17 ms, on a machine at full speed; −0.04 ms, standard error 0.28, on the same machine throttled). Peak memory is +0.55%, with the same peak allocation count. Parsing is under a tenth of a whole `forge` run on the aggregate (about 0.5 s), so the end-to-end change is under 0.1%. + - *Where it went.* Profiling (samply, with line tables, attributing inlined frames) found no hot spot of the layer's own: most of the difference is LLVM inlining parts of serde_json's key loop into `SchemaObject::read_object` and laying the code out differently, which moves with any change. Three things were measured and kept: each table turns away keys outside its prefix (`x-forge-`, `x-fern-`, `x-stainless-`) before matching, and only `x-` keys are asked about, so a skipped key costs one compare; and a `Hit` carries only its field and slot, the key's name looked up by slot when an error needs it, so the reader's per-key result fits in registers (16 bytes, as before, not 24). + - *Ruling things out.* Unpaired, sequential runs had suggested +1.5–1.9%; that was the machine warming up through the session, which the ABBA order removes. A single run of either binary differs from the next by about as much as the change measured here, so a smaller difference needs more pairs, not a better single run. ## Release shape From 74a1453d5a3c1f0641cadad5b6a2c057acfa43ba Mon Sep 17 00:00:00 2001 From: Niels Swimberghe Date: Mon, 26 Oct 2026 12:29:11 -0400 Subject: [PATCH 10/25] feat: rename x-forge-method to x-forge-method-name, add x-forge-sensitive - x-forge-method-name, in line with the other -name keys. x-forge-method never shipped, so it gets no alias. - x-forge-sensitive replaces x-sensitive, so every native key begins with x-forge-. x-sensitive stays readable as a deprecated native alias: a new Tier (native, deprecated, compat) decides which key's value is kept, and the alias works with compat off. - tests/boundary.rs also catches vendor names spelled as identifiers (x_fern_...), and the five test names that still had them are renamed. Output and the model dump of the aggregate are unchanged. --- packages/forge/src/index.ts | 2 +- src/compat/fern.rs | 7 +- src/compat/mod.rs | 3 +- src/compat/stainless.rs | 3 +- src/lib.rs | 4 +- src/link.rs | 4 +- src/link/openapi.rs | 60 +++++++------ src/metadata/mod.rs | 89 ++++++++++++++---- src/plugin/ts/tests.rs | 6 +- src/types.rs | 12 +-- tests/boundary.rs | 8 +- tests/cli.rs | 2 +- tests/compat/common.rs | 4 +- tests/compat/fern.rs | 22 ++--- tests/compat/native_first.rs | 40 +++++---- tests/compat/stainless.rs | 2 +- tests/parse/aliases.rs | 67 +++++++------- tests/parse/bodies.rs | 28 +++--- tests/parse/common.rs | 4 +- tests/parse/document.rs | 4 +- tests/parse/extensions.rs | 20 ++--- tests/parse/groups.rs | 64 ++++++------- tests/parse/headers.rs | 56 ++++++------ tests/parse/methods.rs | 65 ++++++++------ tests/parse/models.rs | 38 ++++---- tests/parse/requests.rs | 140 ++++++++++++++--------------- tests/parse/responses.rs | 12 +-- tests/parse/types/arrays.rs | 4 +- tests/parse/types/enums.rs | 2 +- tests/parse/types/intersections.rs | 4 +- tests/parse/types/nulls.rs | 8 +- tests/parse/types/objects.rs | 61 +++++++++++-- tests/parse/types/refs.rs | 24 ++--- tests/parse/types/scalars.rs | 6 +- tests/parse/types/unions.rs | 8 +- tests/wasm.rs | 22 ++--- 36 files changed, 524 insertions(+), 381 deletions(-) diff --git a/packages/forge/src/index.ts b/packages/forge/src/index.ts index 983a4bb14..d40d60c63 100644 --- a/packages/forge/src/index.ts +++ b/packages/forge/src/index.ts @@ -267,7 +267,7 @@ export interface Field { * though the same model's field appears in responses: `readOnly`. */ readonly readOnly: boolean; - /** Whether the value is a secret, such as a password or a token, to keep out of logs: `x-sensitive`. */ + /** Whether the value is a secret, such as a password or a token, to keep out of logs: `x-forge-sensitive`. */ readonly sensitive: boolean; /** The `default` of the property's schema, or undefined if it has none. */ readonly default: Json | undefined; diff --git a/src/compat/fern.rs b/src/compat/fern.rs index ccb5707ca..fe7358fdf 100644 --- a/src/compat/fern.rs +++ b/src/compat/fern.rs @@ -70,18 +70,19 @@ pub(super) fn schema(key: &str) -> Option> { #[cfg(test)] mod tests { use super::*; + use crate::metadata::Tier; #[test] fn each_key_is_read_as_itself_and_as_not_native() { for key in [METHOD_NAME, GROUP_NAME, IGNORE, AVAILABILITY] { let hit = definition(key).unwrap_or_else(|| panic!("{key} is not a definition key")); - assert!(!hit.is_native(), "{key}"); + assert_eq!(hit.tier, Tier::Compat, "{key}"); } let hit = parameter(PARAMETER_NAME).unwrap(); - assert!(!hit.is_native(), "{PARAMETER_NAME}"); + assert_eq!(hit.tier, Tier::Compat, "{PARAMETER_NAME}"); for key in [TYPE_NAME, PROPERTY_NAME] { let hit = schema(key).unwrap_or_else(|| panic!("{key} is not a schema key")); - assert!(!hit.is_native(), "{key}"); + assert_eq!(hit.tier, Tier::Compat, "{key}"); } } diff --git a/src/compat/mod.rs b/src/compat/mod.rs index 555f2d7cf..48a6a6e42 100644 --- a/src/compat/mod.rs +++ b/src/compat/mod.rs @@ -65,6 +65,7 @@ pub(crate) fn schema_name(n: u8) -> &'static str { mod tests { use super::*; use crate::metadata::COMPAT_SLOTS; + use crate::metadata::Tier; /// Asserts that each key in `names` is read by `read` as a key that is /// not native, whose slot `name` names it by. @@ -72,7 +73,7 @@ mod tests { fn read_as_itself(names: &[&'static str], read: fn(&str) -> Option>, name: fn(u8) -> &'static str) { for &key in names { let hit = read(key).unwrap_or_else(|| panic!("{key} is not read")); - assert!(!hit.is_native(), "{key}"); + assert_eq!(hit.tier, Tier::Compat, "{key}"); assert_eq!(name(hit.slot - COMPAT_SLOTS), key); } } diff --git a/src/compat/stainless.rs b/src/compat/stainless.rs index 90f9dc265..320957172 100644 --- a/src/compat/stainless.rs +++ b/src/compat/stainless.rs @@ -31,11 +31,12 @@ pub(super) fn schema(key: &str) -> Option> { #[cfg(test)] mod tests { use super::*; + use crate::metadata::Tier; #[test] fn the_skip_is_read_on_a_schema_as_a_boolean_or_targets_and_not_native() { let hit = schema(SKIP).unwrap(); - assert!(!hit.is_native(), "{SKIP}"); + assert_eq!(hit.tier, Tier::Compat, "{SKIP}"); assert!(matches!(hit.field, SchemaField::Ignore(BoolFormat::BoolOrTargets))); } diff --git a/src/lib.rs b/src/lib.rs index 3895b7e5b..bc07c7d84 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -253,7 +253,7 @@ pub struct Method<'f> { impl<'f> Method<'f> { /// Returns the name of the method, which is unique within its group. /// - /// Corresponds to the `x-forge-method` extension of the + /// Corresponds to the `x-forge-method-name` extension of the /// operation, or of the alias the method comes from. An operation or /// alias without one, or with an empty one, is not a method, and one /// with no letters or digits is an error. Two methods in a group with @@ -497,7 +497,7 @@ impl<'f> Method<'f> { /// /// An extension is a key of the /// [Operation Object](https://spec.openapis.org/oas/latest.html#operation-object) - /// that starts with `x-`. Those forge reads, `x-forge-method`, + /// that starts with `x-`. Those forge reads, `x-forge-method-name`, /// `x-forge-group`, `x-forge-ignore`, `x-forge-availability`, /// `x-forge-hidden`, `x-forge-require-confirmation` and /// `x-forge-aliases`, are left out, and so are the keys the diff --git a/src/link.rs b/src/link.rs index 84072a4cd..197ab42da 100644 --- a/src/link.rs +++ b/src/link.rs @@ -2002,11 +2002,11 @@ impl<'d> Builder<'d> { /// Returns whether `schema` is read-only and sensitive, as a field /// gives them: whether it or any schema along its chain of `$ref`s has - /// `readOnly: true`, and `x-sensitive: true`. + /// `readOnly: true`, and `x-forge-sensitive: true`. /// /// A `readOnly` next to a `$ref` counts from OpenAPI 3.1, and is /// ignored in 3.0, as every keyword next to a `$ref` is. An - /// `x-sensitive` next to a `$ref` counts in every version, since tools + /// `x-forge-sensitive` next to a `$ref` counts in every version, since tools /// commonly honour extensions there. The chain stops at a `$ref` that /// does not point to a schema, which reading the schema's type /// reports, and after 32 `$ref`s, which lead back to themselves. diff --git a/src/link/openapi.rs b/src/link/openapi.rs index 0a99e81a5..05b4867ce 100644 --- a/src/link/openapi.rs +++ b/src/link/openapi.rs @@ -29,7 +29,7 @@ use crate::Name; use crate::arena::{Id, List, Span, Str, Strings}; use crate::json::{self, Json}; use crate::metadata::{ - self, BoolFormat, DefinitionField, DocumentField, GroupFormat, Hit, ParameterField, SchemaField, + self, BoolFormat, DefinitionField, DocumentField, GroupFormat, Hit, ParameterField, SchemaField, Tier, }; /// An OpenAPI document: the fields of the OpenAPI Object at its root, and @@ -241,22 +241,22 @@ pub(super) struct Operation { } /// A value an extension gives, and the key it came from, so that an error can -/// name the key the document wrote. A native key's value is kept over any -/// other's, whatever order they are written in. +/// name the key the document wrote. The value of the strongest [`Tier`] of +/// key is kept, whatever order the keys are written in. #[derive(Default)] pub(super) struct Val { pub(super) value: T, /// The slot of the key that gave the value, which names it. It means /// nothing if no key gave one. pub(super) slot: u8, - native: bool, + tier: Tier, } impl Val { - /// Keeps `value`, which `hit` gave, unless a native key gave the value - /// held and `hit` is not native. + /// Keeps `value`, which `hit` gave, unless a key of a stronger tier + /// gave the value held. fn set(&mut self, hit: &Hit, value: T) { - if takes(hit, &mut self.native) { + if takes(hit, &mut self.tier) { self.value = value; self.slot = hit.slot; } @@ -264,11 +264,13 @@ impl Val { } /// Reports whether a value from the key of `hit` replaces the one read so -/// far, which came from a native key if `native` is set, and records where -/// the value kept came from. -fn takes(hit: &Hit, native: &mut bool) -> bool { - if hit.is_native() || !*native { - *native = hit.is_native(); +/// far, which came from a key of tier `tier`, and records the tier of the +/// value kept. Keys of the same tier that say the same thing are different +/// keys of the same object, which no table has, so equal tiers do not +/// happen; were they to, the last written would be kept. +fn takes(hit: &Hit, tier: &mut Tier) -> bool { + if hit.tier >= *tier { + *tier = hit.tier; return true; } false @@ -318,8 +320,8 @@ pub(super) struct Parameter { pub(super) content: Span<(Str, Schema)>, /// The name of the parameter's field, or empty for none. pub(super) field_name: Str, - /// Whether a native key gave `field_name`. - field_name_native: bool, + /// The tier of the key that gave `field_name`. + field_name_tier: Tier, } /// Where a parameter is sent: the `in` of a Parameter Object. @@ -434,8 +436,8 @@ pub(super) struct SchemaObject { /// `readOnly`: the value appears in responses, and a client should not /// send it. pub(super) read_only: bool, - /// Whether the schema is marked `x-sensitive: true`: the value is a - /// secret, such as a password or a token. + /// Whether the schema is marked `x-forge-sensitive: true`: the value is + /// a secret, such as a password or a token. pub(super) sensitive: bool, /// The name of the model, for a schema in `components.schemas`, or /// empty for none. @@ -445,11 +447,12 @@ pub(super) struct SchemaObject { /// Whether a property's schema is marked to be left out: it makes no /// field. pub(super) ignore: bool, - /// Whether a native key gave `model_name`, `field_name` and `ignore`, - /// each, so that no other key replaces it. - model_name_native: bool, - field_name_native: bool, - ignore_native: bool, + /// The tier of the key that gave each of `model_name`, `field_name`, + /// `ignore` and `sensitive`, so that no weaker key replaces it. + model_name_tier: Tier, + field_name_tier: Tier, + ignore_tier: Tier, + sensitive_tier: Tier, } /// A value of `type` in a Schema Object. @@ -1625,7 +1628,7 @@ impl ReadObject for Parameter { match hit.field { ParameterField::Name => { let name = map.next_value_seed(seed::(doc))?; - if takes(&hit, &mut param.field_name_native) { + if takes(&hit, &mut param.field_name_tier) { param.field_name = name; } } @@ -1771,23 +1774,28 @@ impl ReadObject for SchemaObject { match hit.field { SchemaField::ModelName => { let name = map.next_value_seed(seed::(doc))?; - if takes(&hit, &mut schema.model_name_native) { + if takes(&hit, &mut schema.model_name_tier) { schema.model_name = name; } } SchemaField::PropertyName => { let name = map.next_value_seed(seed::(doc))?; - if takes(&hit, &mut schema.field_name_native) { + if takes(&hit, &mut schema.field_name_tier) { schema.field_name = name; } } SchemaField::Ignore(format) => { let ignore = read_bool(&mut map, format)?; - if takes(&hit, &mut schema.ignore_native) { + if takes(&hit, &mut schema.ignore_tier) { schema.ignore = ignore; } } - SchemaField::Sensitive => schema.sensitive = map.next_value()?, + SchemaField::Sensitive => { + let sensitive = map.next_value()?; + if takes(&hit, &mut schema.sensitive_tier) { + schema.sensitive = sensitive; + } + } } continue; } diff --git a/src/metadata/mod.rs b/src/metadata/mod.rs index 97c986388..ae40ba42c 100644 --- a/src/metadata/mod.rs +++ b/src/metadata/mod.rs @@ -21,9 +21,10 @@ use crate::Availability; /// [`definition`], since an alias cannot have one. pub(crate) const ALIASES: &str = "x-forge-aliases"; -/// What every native key begins with, but `x-sensitive`. A key without it is -/// turned away before it is matched against the table: most extensions in a -/// document are no key of any table, and this is the cheap way to say so. +/// What every native key begins with, but the deprecated `x-sensitive`. A key +/// without it is turned away before it is matched against the table: most +/// extensions in a document are no key of any table, and this is the cheap +/// way to say so. const PREFIX: &str = "x-forge-"; /// A key that says something about the object it is on. @@ -35,6 +36,9 @@ const PREFIX: &str = "x-forge-"; pub(crate) struct Hit { /// What the key says. pub(crate) field: F, + /// Which kind of key it is, which decides whose value is kept when + /// several keys say the same thing. + pub(crate) tier: Tier, /// The bit of the key in a set of the keys read from an object so far, /// which is how a key written twice is found, and the index of its name. /// Unique among the keys of one kind of object, native or not: the @@ -42,14 +46,48 @@ pub(crate) struct Hit { pub(crate) slot: u8, } +/// Which kind of key gave a value, from the weakest to the strongest. When +/// keys of different tiers say the same thing about an object, the value of +/// the strongest is kept, whatever order they are written in. +#[derive(Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Debug)] +pub(crate) enum Tier { + /// No key has given a value yet. + #[default] + Unset, + /// A key a compatibility layer reads in place of a native one. + #[cfg_attr(not(any(feature = "compat", test)), expect(dead_code))] + Compat, + /// A native key that is kept working under an old name, in place of + /// the one that replaced it. + Deprecated, + /// A native key, under its current name. + Native, +} + /// The first slot of the keys that are not native, so that slots never /// collide with the native ones, which count up from zero. +#[cfg_attr(not(any(feature = "compat", test)), expect(dead_code))] pub(crate) const COMPAT_SLOTS: u8 = 32; impl Hit { /// Returns a native key, in slot `slot`, which is under 32. const fn native(field: F, slot: u8) -> Hit { - Hit { field, slot } + Hit { + field, + tier: Tier::Native, + slot, + } + } + + /// Returns a deprecated native key, in slot `slot`, which is under 32: + /// an old name of a native key, read only when the current one is not + /// written. + const fn deprecated(field: F, slot: u8) -> Hit { + Hit { + field, + tier: Tier::Deprecated, + slot, + } } /// Returns a key that is not native, which a compatibility layer @@ -59,6 +97,7 @@ impl Hit { pub(crate) const fn compat(field: F, n: u8) -> Hit { Hit { field, + tier: Tier::Compat, slot: COMPAT_SLOTS + n, } } @@ -69,15 +108,10 @@ impl Hit { pub(crate) fn after(self, n: u8) -> Hit { Hit { field: self.field, + tier: self.tier, slot: self.slot + n, } } - - /// Returns whether the key is native. A value from a native key is kept - /// over one from any other, whatever order they are written in. - pub(crate) const fn is_native(&self) -> bool { - self.slot < COMPAT_SLOTS - } } /// Returns the name in `names` of the key in `slot`, if it is a native one. @@ -157,7 +191,7 @@ pub(crate) fn definition(key: &str) -> Option> { return None; } Some(match key { - METHOD => Hit::native(DefinitionField::Method, 0), + METHOD_NAME => Hit::native(DefinitionField::Method, 0), GROUP => Hit::native(DefinitionField::Group(GroupFormat::Array), 1), IGNORE => Hit::native(DefinitionField::Ignore(BoolFormat::Bool), 2), AVAILABILITY => Hit::native(DefinitionField::Availability, 3), @@ -167,7 +201,7 @@ pub(crate) fn definition(key: &str) -> Option> { }) } -const METHOD: &str = "x-forge-method"; +const METHOD_NAME: &str = "x-forge-method-name"; const GROUP: &str = "x-forge-group"; const IGNORE: &str = "x-forge-ignore"; const AVAILABILITY: &str = "x-forge-availability"; @@ -176,15 +210,19 @@ const CONFIRMATION: &str = "x-forge-require-confirmation"; const PARAMETER_NAME: &str = "x-forge-parameter-name"; const MODEL_NAME: &str = "x-forge-model-name"; const PROPERTY_NAME: &str = "x-forge-property-name"; -const SENSITIVE: &str = "x-sensitive"; +const SENSITIVE: &str = "x-forge-sensitive"; +/// The old name of `x-forge-sensitive`, from before every native key began +/// with `x-forge-`. Deprecated: it is read only where `x-forge-sensitive` is +/// not written. +const DEPRECATED_SENSITIVE: &str = "x-sensitive"; const COMMANDS: &str = "x-forge-commands"; /// The names of the native keys of an operation or an alias, by slot. -pub(crate) const DEFINITION_NAMES: [&str; 6] = [METHOD, GROUP, IGNORE, AVAILABILITY, HIDDEN, CONFIRMATION]; +pub(crate) const DEFINITION_NAMES: [&str; 6] = [METHOD_NAME, GROUP, IGNORE, AVAILABILITY, HIDDEN, CONFIRMATION]; /// The names of the native keys of a Parameter Object, by slot. pub(crate) const PARAMETER_NAMES: [&str; 1] = [PARAMETER_NAME]; /// The names of the native keys of a Schema Object, by slot. -pub(crate) const SCHEMA_NAMES: [&str; 4] = [MODEL_NAME, PROPERTY_NAME, IGNORE, SENSITIVE]; +pub(crate) const SCHEMA_NAMES: [&str; 5] = [MODEL_NAME, PROPERTY_NAME, IGNORE, SENSITIVE, DEPRECATED_SENSITIVE]; /// The names of the native keys of the document, by slot. pub(crate) const DOCUMENT_NAMES: [&str; 1] = [COMMANDS]; @@ -208,7 +246,7 @@ pub(crate) fn parameter(key: &str) -> Option> { /// Returns what `key` says on a Schema Object, if it is a native key of one. pub(crate) fn schema(key: &str) -> Option> { // `x-sensitive` is the one native key outside the prefix. - if !key.starts_with(PREFIX) && key != SENSITIVE { + if !key.starts_with(PREFIX) && key != DEPRECATED_SENSITIVE { return None; } Some(match key { @@ -216,6 +254,7 @@ pub(crate) fn schema(key: &str) -> Option> { PROPERTY_NAME => Hit::native(SchemaField::PropertyName, 1), IGNORE => Hit::native(SchemaField::Ignore(BoolFormat::Bool), 2), SENSITIVE => Hit::native(SchemaField::Sensitive, 3), + DEPRECATED_SENSITIVE => Hit::deprecated(SchemaField::Sensitive, 4), _ => return None, }) } @@ -253,7 +292,7 @@ mod tests { fn native_keys() -> [&'static [&'static str]; 4] { [ &[ - "x-forge-method", + "x-forge-method-name", "x-forge-group", "x-forge-ignore", "x-forge-availability", @@ -265,6 +304,7 @@ mod tests { "x-forge-model-name", "x-forge-property-name", "x-forge-ignore", + "x-forge-sensitive", "x-sensitive", ], &["x-forge-commands"], @@ -277,7 +317,7 @@ mod tests { fn read_as_itself(keys: &[&str], names: &[&'static str], read: fn(&str) -> Option>) { for &key in keys { let hit = read(key).unwrap_or_else(|| panic!("{key} is not read")); - assert!(hit.is_native(), "{key}"); + assert_ne!(hit.tier, Tier::Compat, "{key}"); assert_eq!(native_name(names, hit.slot), Some(key)); } assert_eq!(keys.len(), names.len(), "every name is a key that is read"); @@ -292,6 +332,18 @@ mod tests { read_as_itself(documents, &DOCUMENT_NAMES, document); } + #[test] + fn x_sensitive_is_a_deprecated_name_of_x_forge_sensitive() { + let (current, old) = (schema("x-forge-sensitive").unwrap(), schema("x-sensitive").unwrap()); + + assert!(matches!( + (current.field, old.field), + (SchemaField::Sensitive, SchemaField::Sensitive) + )); + assert_eq!((current.tier, old.tier), (Tier::Native, Tier::Deprecated)); + assert!(Tier::Native > Tier::Deprecated && Tier::Deprecated > Tier::Compat && Tier::Compat > Tier::Unset); + } + #[test] fn a_slot_past_the_native_ones_has_no_native_name() { assert_eq!(native_name(&DEFINITION_NAMES, COMPAT_SLOTS), None); @@ -320,6 +372,7 @@ mod tests { "x-custom", "summary", "x-forge-nope", + "x-forge-method", ] { assert!(definition(key).is_none(), "{key}"); assert!(parameter(key).is_none(), "{key}"); diff --git a/src/plugin/ts/tests.rs b/src/plugin/ts/tests.rs index bf7b8c80c..60dc043b5 100644 --- a/src/plugin/ts/tests.rs +++ b/src/plugin/ts/tests.rs @@ -370,11 +370,11 @@ fn models() { r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": { - "/a": {"get": {"x-forge-group": ["pet-store", "list_all"], "x-forge-method": "get", + "/a": {"get": {"x-forge-group": ["pet-store", "list_all"], "x-forge-method-name": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/pet_owner"}}}}}}}, - "/b": {"get": {"x-forge-group": ["pet-store"], "x-forge-method": "get", + "/b": {"get": {"x-forge-group": ["pet-store"], "x-forge-method-name": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/2fa"}}}}}}}, - "/c": {"get": {"x-forge-group": ["other"], "x-forge-method": "get", + "/c": {"get": {"x-forge-group": ["other"], "x-forge-method-name": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/2fa"}}}}}}} }, "components": {"schemas": {"pet_owner": {"type": "string"}, "2fa": {"type": "string"}}} diff --git a/src/types.rs b/src/types.rs index 2386d8467..9d5c26b94 100644 --- a/src/types.rs +++ b/src/types.rs @@ -411,17 +411,19 @@ impl<'f> Field<'f> { /// Reports whether the field holds a secret, such as a password or a /// token, which a client should mask when it shows it. /// - /// Corresponds to the `x-sensitive: true` extension on the property's - /// schema, or on any schema along its chain of `$ref`s. Unlike - /// `readOnly`, an `x-sensitive` next to a `$ref` counts in every + /// Corresponds to the `x-forge-sensitive: true` extension on the property's + /// schema, or on any schema along its chain of `$ref`s. Its old name, + /// `x-sensitive`, is deprecated and still read on a schema without + /// `x-forge-sensitive`. Unlike + /// `readOnly`, an `x-forge-sensitive` next to a `$ref` counts in every /// version of OpenAPI, since tools commonly honour extensions there. A /// value that is not a boolean is an error, as it is for a schema's /// `x-forge-ignore` and an operation's `x-forge-hidden`. /// /// A field for a parameter, whether in the request or in /// [`Method::headers`](crate::Method::headers), is sensitive when the - /// parameter's schema is, in the same way: `x-sensitive` next to its - /// `$ref` or along its chain. An `x-sensitive` on the Parameter Object + /// parameter's schema is, in the same way: `x-forge-sensitive` next to its + /// `$ref` or along its chain. An `x-forge-sensitive` on the Parameter Object /// itself, rather than its schema, is ignored. The `body` field of a /// request, and a path template placeholder that no parameter /// declares, are never sensitive. diff --git a/tests/boundary.rs b/tests/boundary.rs index c91c59d13..95b048d48 100644 --- a/tests/boundary.rs +++ b/tests/boundary.rs @@ -13,6 +13,12 @@ use std::path::{Path, PathBuf}; /// What the extensions of each tool forge reads begin with. const VENDORS: [&str; 2] = ["x-fern", "x-stainless"]; +/// Returns whether `line` names `vendor`, as written or as an identifier +/// spells it, with `_` for `-`, as in a test named after a key. +fn names(line: &str, vendor: &str) -> bool { + line.contains(vendor) || line.contains(&vendor.replace('-', "_")) +} + /// Where those names may be: the layer, its tests, and this file, which /// writes them to look for them. const ALLOWED: [&str; 3] = ["src/compat/", "tests/compat/", "tests/boundary.rs"]; @@ -73,7 +79,7 @@ fn no_extension_of_another_tool_is_named_outside_the_compatibility_layer() { continue; } for (i, line) in text.lines().enumerate() { - if VENDORS.iter().any(|vendor| line.contains(vendor)) { + if VENDORS.iter().any(|vendor| names(line, vendor)) { found.push(format!("{path}:{}: {}", i + 1, line.trim())); } } diff --git a/tests/cli.rs b/tests/cli.rs index f59b30727..bcb1ea4df 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -6,7 +6,7 @@ use std::process::{Command, Output, Stdio}; /// A document with one method, `a.get`. const SPEC: &str = r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", "responses": {"200": {"description": "ok"}}}}} }"#; diff --git a/tests/compat/common.rs b/tests/compat/common.rs index 4649783ea..86c6b96c4 100644 --- a/tests/compat/common.rs +++ b/tests/compat/common.rs @@ -23,7 +23,7 @@ pub fn parse_err(json: &str) -> String { /// Returns an OpenAPI 3.0.3 document with one operation, `GET /a`, that has /// the members `keys`, written as the inside of a JSON object, each followed -/// by a comma, such as `"x-forge-method": "get",`. +/// by a comma, such as `"x-forge-method-name": "get",`. pub fn operation_with(keys: &str) -> String { format!( r#"{{"openapi": "3.0.3", "info": {{"title": "t", "version": "1"}}, @@ -36,7 +36,7 @@ pub fn operation_with(keys: &str) -> String { pub fn responding_with(schema: &str, schemas: &str) -> String { format!( r#"{{"openapi": "3.0.3", "info": {{"title": "t", "version": "1"}}, - "paths": {{"/a": {{"get": {{"x-forge-group": ["a"], "x-forge-method": "get", + "paths": {{"/a": {{"get": {{"x-forge-group": ["a"], "x-forge-method-name": "get", "responses": {{"200": {{"description": "ok", "content": {{"application/json": {{"schema": {schema}}}}}}}}}}}}}}}, "components": {{"schemas": {schemas}}}}}"# ) diff --git a/tests/compat/fern.rs b/tests/compat/fern.rs index 3f7eff586..a710822fd 100644 --- a/tests/compat/fern.rs +++ b/tests/compat/fern.rs @@ -9,7 +9,9 @@ fn method_group_and_availability_are_read_as_native() { &operation_with( r#""x-fern-sdk-group-name": ["accounts"], "x-fern-sdk-method-name": "list", "x-fern-availability": "beta","#, ), - &operation_with(r#""x-forge-group": ["accounts"], "x-forge-method": "list", "x-forge-availability": "beta","#), + &operation_with( + r#""x-forge-group": ["accounts"], "x-forge-method-name": "list", "x-forge-availability": "beta","#, + ), ); } @@ -21,7 +23,7 @@ fn every_availability_is_read() { r#""x-fern-sdk-group-name": ["a"], "x-fern-sdk-method-name": "get", "x-fern-availability": "{availability}","# )), &operation_with(&format!( - r#""x-forge-group": ["a"], "x-forge-method": "get", "x-forge-availability": "{availability}","# + r#""x-forge-group": ["a"], "x-forge-method-name": "get", "x-forge-availability": "{availability}","# )), ); } @@ -31,7 +33,7 @@ fn every_availability_is_read() { fn a_group_written_as_a_string_is_split_on_dots() { assert_same( &operation_with(r#""x-fern-sdk-group-name": "a.b.c", "x-fern-sdk-method-name": "get","#), - &operation_with(r#""x-forge-group": ["a", "b", "c"], "x-forge-method": "get","#), + &operation_with(r#""x-forge-group": ["a", "b", "c"], "x-forge-method-name": "get","#), ); } @@ -39,7 +41,7 @@ fn a_group_written_as_a_string_is_split_on_dots() { fn a_group_written_as_an_array_is_not_split() { assert_same( &operation_with(r#""x-fern-sdk-group-name": ["a.b", "c"], "x-fern-sdk-method-name": "get","#), - &operation_with(r#""x-forge-group": ["a.b", "c"], "x-forge-method": "get","#), + &operation_with(r#""x-forge-group": ["a.b", "c"], "x-forge-method-name": "get","#), ); } @@ -65,7 +67,7 @@ fn a_group_that_is_neither_a_string_nor_an_array_is_an_error() { fn ignore_makes_no_method() { assert_same( &operation_with(r#""x-fern-sdk-group-name": ["a"], "x-fern-sdk-method-name": "get", "x-fern-ignore": true,"#), - &operation_with(r#""x-forge-group": ["a"], "x-forge-method": "get", "x-forge-ignore": true,"#), + &operation_with(r#""x-forge-group": ["a"], "x-forge-method-name": "get", "x-forge-ignore": true,"#), ); assert_eq!( parse(&operation_with( @@ -81,7 +83,7 @@ fn ignore_makes_no_method() { fn ignore_set_to_false_keeps_the_method() { assert_same( &operation_with(r#""x-fern-sdk-group-name": ["a"], "x-fern-sdk-method-name": "get", "x-fern-ignore": false,"#), - &operation_with(r#""x-forge-group": ["a"], "x-forge-method": "get","#), + &operation_with(r#""x-forge-group": ["a"], "x-forge-method-name": "get","#), ); } @@ -89,7 +91,7 @@ fn ignore_set_to_false_keeps_the_method() { fn a_parameter_name_is_read_as_native() { let parameters = |key: &str| { operation_with(&format!( - r#""x-forge-group": ["a"], "x-forge-method": "list", + r#""x-forge-group": ["a"], "x-forge-method-name": "list", "parameters": [{{"name": "q", "in": "query", "{key}": "query", "schema": {{"type": "string"}}}}],"# )) }; @@ -149,7 +151,7 @@ fn the_keys_of_an_alias_are_read_too() { ), &alias( "x-forge-group", - "x-forge-method", + "x-forge-method-name", "x-forge-availability", "x-forge-ignore", ), @@ -178,7 +180,7 @@ fn a_key_that_is_read_is_not_an_extension_of_the_method() { #[test] fn a_key_that_is_not_read_stays_an_extension_of_the_method() { let forge = parse(&operation_with( - r#""x-forge-group": ["a"], "x-forge-method": "get", "x-fern-audiences": ["internal"], "x-fern-pagination": {"offset": "o"},"#, + r#""x-forge-group": ["a"], "x-forge-method-name": "get", "x-fern-audiences": ["internal"], "x-fern-pagination": {"offset": "o"},"#, )); let method = forge.groups().next().unwrap().methods().next().unwrap(); @@ -262,7 +264,7 @@ fn a_key_written_twice_is_an_error_naming_it() { fn the_vendor_keys_of_a_kind_of_object_are_read_only_there() { // A name key on an operation, or a method key on a schema, says nothing. let forge = parse(&operation_with( - r#""x-forge-group": ["a"], "x-forge-method": "get", "x-fern-property-name": "p", "x-fern-type-name": "T", + r#""x-forge-group": ["a"], "x-forge-method-name": "get", "x-fern-property-name": "p", "x-fern-type-name": "T", "x-fern-parameter-name": "q","#, )); let method = forge.groups().next().unwrap().methods().next().unwrap(); diff --git a/tests/compat/native_first.rs b/tests/compat/native_first.rs index 9e97fe767..f26bde3f2 100644 --- a/tests/compat/native_first.rs +++ b/tests/compat/native_first.rs @@ -17,13 +17,13 @@ fn native_wins(native: &str, compat: &str, expected: &str) { #[test] fn the_native_method_wins() { native_wins( - r#""x-forge-group": ["a"], "x-forge-method": "native","#, + r#""x-forge-group": ["a"], "x-forge-method-name": "native","#, r#""x-fern-sdk-method-name": "vendor","#, - r#""x-forge-group": ["a"], "x-forge-method": "native","#, + r#""x-forge-group": ["a"], "x-forge-method-name": "native","#, ); assert!( describe(&parse(&operation_with( - r#""x-forge-group": ["a"], "x-forge-method": "native", "x-fern-sdk-method-name": "vendor","# + r#""x-forge-group": ["a"], "x-forge-method-name": "native", "x-fern-sdk-method-name": "vendor","# ))) .contains("method native") ); @@ -32,36 +32,36 @@ fn the_native_method_wins() { #[test] fn the_native_group_wins() { native_wins( - r#""x-forge-method": "get", "x-forge-group": ["native"],"#, + r#""x-forge-method-name": "get", "x-forge-group": ["native"],"#, r#""x-fern-sdk-group-name": "vendor.group","#, - r#""x-forge-method": "get", "x-forge-group": ["native"],"#, + r#""x-forge-method-name": "get", "x-forge-group": ["native"],"#, ); } #[test] fn the_native_availability_wins_and_an_invalid_compat_one_is_not_checked() { native_wins( - r#""x-forge-group": ["a"], "x-forge-method": "get", "x-forge-availability": "beta","#, + r#""x-forge-group": ["a"], "x-forge-method-name": "get", "x-forge-availability": "beta","#, r#""x-fern-availability": "ga","#, - r#""x-forge-group": ["a"], "x-forge-method": "get", "x-forge-availability": "beta","#, + r#""x-forge-group": ["a"], "x-forge-method-name": "get", "x-forge-availability": "beta","#, ); } #[test] fn a_native_ignore_of_false_keeps_the_method_a_compat_one_would_drop() { native_wins( - r#""x-forge-group": ["a"], "x-forge-method": "get", "x-forge-ignore": false,"#, + r#""x-forge-group": ["a"], "x-forge-method-name": "get", "x-forge-ignore": false,"#, r#""x-fern-ignore": true,"#, - r#""x-forge-group": ["a"], "x-forge-method": "get","#, + r#""x-forge-group": ["a"], "x-forge-method-name": "get","#, ); } #[test] fn a_native_ignore_of_true_drops_the_method_a_compat_one_would_keep() { native_wins( - r#""x-forge-group": ["a"], "x-forge-method": "get", "x-forge-ignore": true,"#, + r#""x-forge-group": ["a"], "x-forge-method-name": "get", "x-forge-ignore": true,"#, r#""x-fern-ignore": false,"#, - r#""x-forge-group": ["a"], "x-forge-method": "get", "x-forge-ignore": true,"#, + r#""x-forge-group": ["a"], "x-forge-method-name": "get", "x-forge-ignore": true,"#, ); } @@ -69,7 +69,7 @@ fn a_native_ignore_of_true_drops_the_method_a_compat_one_would_keep() { fn the_native_parameter_name_wins() { let parameter = |keys: &str| { operation_with(&format!( - r#""x-forge-group": ["a"], "x-forge-method": "list", + r#""x-forge-group": ["a"], "x-forge-method-name": "list", "parameters": [{{"name": "q", "in": "query", {keys} "schema": {{"type": "string"}}}}],"# )) }; @@ -142,10 +142,12 @@ fn both_keys_with_the_same_value_are_the_same_as_one() { assert_same( &operation_with( r#""x-forge-group": ["a", "b"], "x-fern-sdk-group-name": "a.b", - "x-forge-method": "get", "x-fern-sdk-method-name": "get", + "x-forge-method-name": "get", "x-fern-sdk-method-name": "get", "x-forge-availability": "beta", "x-fern-availability": "beta","#, ), - &operation_with(r#""x-forge-group": ["a", "b"], "x-forge-method": "get", "x-forge-availability": "beta","#), + &operation_with( + r#""x-forge-group": ["a", "b"], "x-forge-method-name": "get", "x-forge-availability": "beta","#, + ), ); } @@ -154,7 +156,7 @@ fn each_primitive_is_resolved_on_its_own() { // The group is native and the method and availability are not. assert_same( &operation_with(r#""x-forge-group": ["a"], "x-fern-sdk-method-name": "get", "x-fern-availability": "beta","#), - &operation_with(r#""x-forge-group": ["a"], "x-forge-method": "get", "x-forge-availability": "beta","#), + &operation_with(r#""x-forge-group": ["a"], "x-forge-method-name": "get", "x-forge-availability": "beta","#), ); } @@ -165,7 +167,7 @@ fn a_compat_value_of_the_wrong_type_is_an_error_even_when_a_native_key_wins() { r#""x-fern-availability": 1, "x-forge-availability": "beta","#, ] { let err = parse_err(&operation_with(&format!( - r#""x-forge-group": ["a"], "x-forge-method": "get", {keys}"# + r#""x-forge-group": ["a"], "x-forge-method-name": "get", {keys}"# ))); assert!(err.contains("invalid type"), "{keys}: {err}"); @@ -181,7 +183,9 @@ fn a_native_key_written_twice_is_an_error_naming_it() { #[test] fn a_native_group_written_as_a_string_is_an_error() { - let err = parse_err(&operation_with(r#""x-forge-group": "a.b", "x-forge-method": "get","#)); + let err = parse_err(&operation_with( + r#""x-forge-group": "a.b", "x-forge-method-name": "get","#, + )); assert!(err.contains("an array of strings"), "{err}"); } @@ -189,7 +193,7 @@ fn a_native_group_written_as_a_string_is_an_error() { #[test] fn an_error_names_the_native_key_when_the_native_key_gave_the_value() { let err = parse_err(&operation_with( - r#""x-forge-group": ["a"], "x-forge-method": "get", "x-forge-availability": "ga","#, + r#""x-forge-group": ["a"], "x-forge-method-name": "get", "x-forge-availability": "ga","#, )); assert!(err.contains(r#"GET /a: x-forge-availability "ga" is not"#), "{err}"); diff --git a/tests/compat/stainless.rs b/tests/compat/stainless.rs index 496a2124a..7f4cd1a4c 100644 --- a/tests/compat/stainless.rs +++ b/tests/compat/stainless.rs @@ -67,7 +67,7 @@ fn a_skip_written_twice_is_an_error_naming_it() { #[test] fn a_skip_on_an_operation_says_nothing_and_stays_an_extension() { let forge = parse(&operation_with( - r#""x-forge-group": ["a"], "x-forge-method": "get", "x-stainless-skip": true,"#, + r#""x-forge-group": ["a"], "x-forge-method-name": "get", "x-stainless-skip": true,"#, )); let method = forge.groups().next().unwrap().methods().next().unwrap(); diff --git a/tests/parse/aliases.rs b/tests/parse/aliases.rs index c36579065..bcb6c580b 100644 --- a/tests/parse/aliases.rs +++ b/tests/parse/aliases.rs @@ -10,7 +10,7 @@ fn aliased(aliases: &str, fields: &str) -> String { r#"{{ "openapi": "3.0.3", "info": {{"title": "t", "version": "1"}}, "paths": {{"/a/{{id}}/status": {{"patch": {{{fields} - "x-forge-group": ["a", "status"], "x-forge-method": "edit", + "x-forge-group": ["a", "status"], "x-forge-method-name": "edit", "x-forge-aliases": {aliases}, "summary": "Edit status", "description": "Changes the status.", "parameters": [{{"name": "id", "in": "path", "required": true, "schema": {{"type": "string"}}}}], @@ -39,8 +39,8 @@ fn methods(forge: &Forge) -> Vec { } const PAUSE_AND_TERMINATE: &str = r#"[ - {"x-forge-group": ["a"], "x-forge-method": "pause"}, - {"x-forge-group": ["a"], "x-forge-method": "terminate"} + {"x-forge-group": ["a"], "x-forge-method-name": "pause"}, + {"x-forge-group": ["a"], "x-forge-method-name": "terminate"} ]"#; #[test] @@ -77,10 +77,10 @@ fn aliases_share_the_rest_of_the_operation() { fn each_alias_has_its_own_availability_hidden_confirmation_and_extensions() { let forge = parse(&aliased( r#"[ - {"x-forge-group": ["a"], "x-forge-method": "pause", + {"x-forge-group": ["a"], "x-forge-method-name": "pause", "x-forge-availability": "generally-available", "x-forge-hidden": true, "x-forge-params": {"status": {"default": "pause", "hidden": true}}}, - {"x-forge-group": ["a"], "x-forge-method": "terminate", + {"x-forge-group": ["a"], "x-forge-method-name": "terminate", "x-forge-availability": "beta", "x-forge-require-confirmation": "Sure?", "x-a": 1, "x-b": 2} ]"#, "", @@ -111,7 +111,7 @@ fn each_alias_has_its_own_availability_hidden_confirmation_and_extensions() { #[test] fn alias_does_not_fall_back_to_the_operation() { let forge = parse(&aliased( - r#"[{"x-forge-group": ["a"], "x-forge-method": "pause"}]"#, + r#"[{"x-forge-group": ["a"], "x-forge-method-name": "pause"}]"#, r#""x-forge-availability": "beta", "x-forge-hidden": true, "x-forge-require-confirmation": "Sure?", "x-forge-params": {"status": {}},"#, )); @@ -128,10 +128,10 @@ fn alias_does_not_fall_back_to_the_operation() { fn alias_without_a_group_or_name_is_not_a_method() { let forge = parse(&aliased( r#"[ - {"x-forge-group": ["a"], "x-forge-method": "pause"}, + {"x-forge-group": ["a"], "x-forge-method-name": "pause"}, {"x-forge-group": ["a"]}, - {"x-forge-method": "resume"}, - {"x-forge-group": [], "x-forge-method": "restart"} + {"x-forge-method-name": "resume"}, + {"x-forge-group": [], "x-forge-method-name": "restart"} ]"#, "", )); @@ -142,7 +142,7 @@ fn alias_without_a_group_or_name_is_not_a_method() { #[test] fn element_that_is_not_an_object_is_ignored() { let forge = parse(&aliased( - r#"["pause", null, 1, true, ["a"], {"x-forge-group": ["a"], "x-forge-method": "pause"}]"#, + r#"["pause", null, 1, true, ["a"], {"x-forge-group": ["a"], "x-forge-method-name": "pause"}]"#, "", )); @@ -158,7 +158,10 @@ fn empty_aliases_make_no_method() { #[test] fn aliases_that_are_not_an_array_are_an_error() { - let err = parse_err(&aliased(r#"{"x-forge-group": ["a"], "x-forge-method": "pause"}"#, "")); + let err = parse_err(&aliased( + r#"{"x-forge-group": ["a"], "x-forge-method-name": "pause"}"#, + "", + )); assert!(err.contains("an array of aliases"), "{err}"); } @@ -169,11 +172,11 @@ fn alias_and_another_method_with_equal_names_are_an_error() { r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": { - "/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "pause", + "/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "pause", "responses": {"204": {"description": "ok"}}}}, "/b": {"post": {"x-forge-aliases": [ - {"x-forge-group": ["a"], "x-forge-method": "resume"}, - {"x-forge-group": ["a"], "x-forge-method": "Pause"} + {"x-forge-group": ["a"], "x-forge-method-name": "resume"}, + {"x-forge-group": ["a"], "x-forge-method-name": "Pause"} ], "responses": {"204": {"description": "ok"}}}} } }"#, @@ -189,8 +192,8 @@ fn alias_and_another_method_with_equal_names_are_an_error() { fn two_aliases_with_equal_names_are_an_error() { let err = parse_err(&aliased( r#"[ - {"x-forge-group": ["a"], "x-forge-method": "pause"}, - {"x-forge-group": ["a"], "x-forge-method": "pause"} + {"x-forge-group": ["a"], "x-forge-method-name": "pause"}, + {"x-forge-group": ["a"], "x-forge-method-name": "pause"} ]"#, "", )); @@ -204,7 +207,7 @@ fn two_aliases_with_equal_names_are_an_error() { #[test] fn alias_group_name_without_letters_is_an_error_naming_the_alias() { let err = parse_err(&aliased( - r#"[{"x-forge-group": ["a", "", "b"], "x-forge-method": "pause"}]"#, + r#"[{"x-forge-group": ["a", "", "b"], "x-forge-method-name": "pause"}]"#, "", )); @@ -217,7 +220,7 @@ fn alias_group_name_without_letters_is_an_error_naming_the_alias() { #[test] fn alias_availability_is_checked() { let err = parse_err(&aliased( - r#"[1, {"x-forge-group": ["a"], "x-forge-method": "pause", "x-forge-availability": "ga"}]"#, + r#"[1, {"x-forge-group": ["a"], "x-forge-method-name": "pause", "x-forge-availability": "ga"}]"#, "", )); @@ -230,11 +233,11 @@ fn alias_availability_is_checked() { #[test] fn repeated_key_forge_reads_in_an_alias_is_an_error() { let err = parse_err(&aliased( - r#"[{"x-forge-method": "pause", "x-forge-method": "resume"}]"#, + r#"[{"x-forge-method-name": "pause", "x-forge-method-name": "resume"}]"#, "", )); - assert!(err.contains("duplicate field `x-forge-method`"), "{err}"); + assert!(err.contains("duplicate field `x-forge-method-name`"), "{err}"); } #[test] @@ -248,8 +251,8 @@ fn ignored_operation_still_has_its_aliases() { fn ignored_alias_is_not_a_method() { let forge = parse(&aliased( r#"[ - {"x-forge-group": ["a"], "x-forge-method": "pause", "x-forge-ignore": true}, - {"x-forge-group": ["a"], "x-forge-method": "terminate", "x-forge-ignore": false} + {"x-forge-group": ["a"], "x-forge-method-name": "pause", "x-forge-ignore": true}, + {"x-forge-group": ["a"], "x-forge-method-name": "terminate", "x-forge-ignore": false} ]"#, "", )); @@ -260,8 +263,8 @@ fn ignored_alias_is_not_a_method() { #[test] fn aliases_are_not_extensions() { let forge = parse(&aliased( - r#"[{"x-forge-group": ["a"], "x-forge-method": "pause", "x-a": 1, - "x-forge-aliases": [{"x-forge-group": ["b"], "x-forge-method": "nested"}]}]"#, + r#"[{"x-forge-group": ["a"], "x-forge-method-name": "pause", "x-a": 1, + "x-forge-aliases": [{"x-forge-group": ["b"], "x-forge-method-name": "nested"}]}]"#, "", )); @@ -274,7 +277,7 @@ fn aliases_are_not_extensions() { #[test] fn keys_of_an_alias_that_are_not_extensions_are_ignored() { let forge = parse(&aliased( - r#"[{"x-forge-group": ["a"], "x-forge-method": "pause", + r#"[{"x-forge-group": ["a"], "x-forge-method-name": "pause", "summary": "Pause", "deprecated": false, "path": "/b"}]"#, "", )); @@ -290,7 +293,7 @@ fn operation_without_aliases_keeps_its_own_method() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", "responses": {"204": {"description": "ok"}}}}} }"#, ); @@ -304,10 +307,10 @@ fn model_used_by_aliases_in_two_top_level_groups_is_at_the_top_level() { r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": {"/m": {"get": { - "x-forge-group": ["a", "b"], "x-forge-method": "get", + "x-forge-group": ["a", "b"], "x-forge-method-name": "get", "x-forge-aliases": [ - {"x-forge-group": ["a", "b"], "x-forge-method": "get"}, - {"x-forge-group": ["x"], "x-forge-method": "get"} + {"x-forge-group": ["a", "b"], "x-forge-method-name": "get"}, + {"x-forge-group": ["x"], "x-forge-method-name": "get"} ], "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/M"}}}}}}}}, "components": {"schemas": {"M": {"type": "object", "properties": {"id": {"type": "string"}}}}} @@ -325,10 +328,10 @@ fn model_used_by_aliases_in_one_group_is_in_that_group() { r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": {"/m": {"get": { - "x-forge-group": ["x"], "x-forge-method": "get", + "x-forge-group": ["x"], "x-forge-method-name": "get", "x-forge-aliases": [ - {"x-forge-group": ["a", "b"], "x-forge-method": "get"}, - {"x-forge-group": ["a", "c"], "x-forge-method": "get"} + {"x-forge-group": ["a", "b"], "x-forge-method-name": "get"}, + {"x-forge-group": ["a", "c"], "x-forge-method-name": "get"} ], "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/M"}}}}}}}}, "components": {"schemas": {"M": {"type": "object", "properties": {"id": {"type": "string"}}}}} diff --git a/tests/parse/bodies.rs b/tests/parse/bodies.rs index cb96a46e5..76b28f6fc 100644 --- a/tests/parse/bodies.rs +++ b/tests/parse/bodies.rs @@ -21,7 +21,7 @@ fn body_has_its_required_and_description() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"post": {"x-forge-group": ["a"], "x-forge-method": "create", + "paths": {"/a": {"post": {"x-forge-group": ["a"], "x-forge-method-name": "create", "requestBody": {"required": true, "description": "The pet.", "content": {"application/json": {"schema": {"type": "string"}}}}, "responses": {"204": {"description": "ok"}}}}} @@ -38,7 +38,7 @@ fn body_is_optional_by_default() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"post": {"x-forge-group": ["a"], "x-forge-method": "create", + "paths": {"/a": {"post": {"x-forge-group": ["a"], "x-forge-method-name": "create", "requestBody": {"content": {"application/json": {"schema": {"type": "string"}}}}, "responses": {"204": {"description": "ok"}}}}} }"#, @@ -52,7 +52,7 @@ fn contents_are_sorted_by_media_type() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"post": {"x-forge-group": ["a"], "x-forge-method": "create", + "paths": {"/a": {"post": {"x-forge-group": ["a"], "x-forge-method-name": "create", "requestBody": {"content": { "text/plain": {"schema": {"type": "string"}}, "multipart/form-data": {"schema": {"type": "object"}}, @@ -73,7 +73,7 @@ fn json_is_application_json_before_other_json_types() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"post": {"x-forge-group": ["a"], "x-forge-method": "create", + "paths": {"/a": {"post": {"x-forge-group": ["a"], "x-forge-method-name": "create", "requestBody": {"content": { "application/geo+json": {"schema": {"type": "object"}}, "application/json": {"schema": {"type": "string"}} @@ -90,7 +90,7 @@ fn json_is_otherwise_the_first_json_type() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"patch": {"x-forge-group": ["a"], "x-forge-method": "create", + "paths": {"/a": {"patch": {"x-forge-group": ["a"], "x-forge-method-name": "create", "requestBody": {"content": { "multipart/form-data": {"schema": {"type": "object"}}, "application/scim+json": {"schema": {"type": "object"}}, @@ -111,7 +111,7 @@ fn json_ignores_case_and_parameters() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"post": {"x-forge-group": ["a"], "x-forge-method": "create", + "paths": {"/a": {"post": {"x-forge-group": ["a"], "x-forge-method-name": "create", "requestBody": {"content": {"Application/JSON; charset=utf-8": {"schema": {"type": "string"}}}}, "responses": {"204": {"description": "ok"}}}}} }"#, @@ -127,7 +127,7 @@ fn multipart_content_is_typed() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"post": {"x-forge-group": ["a"], "x-forge-method": "create", + "paths": {"/a": {"post": {"x-forge-group": ["a"], "x-forge-method-name": "create", "requestBody": {"content": {"multipart/form-data": {"schema": { "type": "object", "required": ["file"], "properties": { "file": {"type": "string", "format": "binary"}, @@ -151,7 +151,7 @@ fn octet_stream_content_of_a_binary_string_is_binary() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"post": {"x-forge-group": ["a"], "x-forge-method": "create", + "paths": {"/a": {"post": {"x-forge-group": ["a"], "x-forge-method-name": "create", "requestBody": {"content": {"application/octet-stream": {"schema": { "type": "string", "format": "binary" }}}}, @@ -169,7 +169,7 @@ fn json_ignores_content_that_is_not_json() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"post": {"x-forge-group": ["a"], "x-forge-method": "create", + "paths": {"/a": {"post": {"x-forge-group": ["a"], "x-forge-method-name": "create", "requestBody": {"content": { "multipart/form-data": {"schema": {"type": "object", "properties": {"name": {"type": "string"}}}}, "text/plain": {"schema": {"type": "string"}} @@ -189,7 +189,7 @@ fn content_that_is_not_json_without_a_schema_is_unknown() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"post": {"x-forge-group": ["a"], "x-forge-method": "create", + "paths": {"/a": {"post": {"x-forge-group": ["a"], "x-forge-method-name": "create", "requestBody": {"content": {"application/octet-stream": {}}}, "responses": {"204": {"description": "ok"}}}}} }"#, @@ -204,7 +204,7 @@ fn content_without_a_schema_is_unknown() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"post": {"x-forge-group": ["a"], "x-forge-method": "create", + "paths": {"/a": {"post": {"x-forge-group": ["a"], "x-forge-method-name": "create", "requestBody": {"content": {"application/json": {}}}, "responses": {"204": {"description": "ok"}}}}} }"#, @@ -219,7 +219,7 @@ fn body_ref_is_followed() { let forge = parse( r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"post": {"x-forge-group": ["a"], "x-forge-method": "create", + "paths": {"/a": {"post": {"x-forge-group": ["a"], "x-forge-method-name": "create", "requestBody": {"$ref": "#/components/requestBodies/Pet"}, "responses": {"204": {"description": "ok"}}}}}, "components": {"requestBodies": { @@ -239,7 +239,7 @@ fn with_body_schema(version: &str, schema: &str, schemas: &str) -> String { format!( r#"{{ "openapi": "{version}", "info": {{"title": "t", "version": "1"}}, - "paths": {{"/a": {{"post": {{"x-forge-group": ["a"], "x-forge-method": "create", + "paths": {{"/a": {{"post": {{"x-forge-group": ["a"], "x-forge-method-name": "create", "requestBody": {{"content": {{"application/json": {{"schema": {schema}}}}}}}, "responses": {{"204": {{"description": "ok"}}}}}}}}}}, "components": {{"schemas": {schemas}}} @@ -301,7 +301,7 @@ fn response_content_has_its_schema_description() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", "responses": {"200": {"description": "ok", "content": {"application/json": { "schema": {"type": "string", "description": "The name."}}}}}}}} }"#, diff --git a/tests/parse/common.rs b/tests/parse/common.rs index 39d178462..ddfeccfe9 100644 --- a/tests/parse/common.rs +++ b/tests/parse/common.rs @@ -19,7 +19,7 @@ pub fn responding_with(schema: &str) -> String { format!( r#"{{ "openapi": "3.0.3", "info": {{"title": "t", "version": "1"}}, - "paths": {{"/a": {{"get": {{"x-forge-group": ["a"], "x-forge-method": "get", + "paths": {{"/a": {{"get": {{"x-forge-group": ["a"], "x-forge-method-name": "get", "responses": {{"200": {{"description": "ok", "content": {{"application/json": {{"schema": {schema}}}}}}}}}}}}}}} }}"# ) @@ -31,7 +31,7 @@ pub fn responding_with_schemas(schema: &str, schemas: &str) -> String { format!( r#"{{ "openapi": "3.0.3", "info": {{"title": "t", "version": "1"}}, - "paths": {{"/a": {{"get": {{"x-forge-group": ["a"], "x-forge-method": "get", + "paths": {{"/a": {{"get": {{"x-forge-group": ["a"], "x-forge-method-name": "get", "responses": {{"200": {{"description": "ok", "content": {{"application/json": {{"schema": {schema}}}}}}}}}}}}}}}, "components": {{"schemas": {schemas}}} }}"# diff --git a/tests/parse/document.rs b/tests/parse/document.rs index 6bfec98fa..934a48ae9 100644 --- a/tests/parse/document.rs +++ b/tests/parse/document.rs @@ -103,7 +103,7 @@ fn every_version_of_3_0_3_1_and_3_2_is_read() { let forge = parse(&format!( r#"{{ "openapi": "{version}", "info": {{"title": "t", "version": "1"}}, - "paths": {{"/a": {{"get": {{"x-forge-group": ["a"], "x-forge-method": "get", + "paths": {{"/a": {{"get": {{"x-forge-group": ["a"], "x-forge-method-name": "get", "responses": {{"204": {{"description": "ok"}}}}}}}}}} }}"# )); @@ -118,7 +118,7 @@ fn errors_are_reported_together() { r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "servers": [{"url": "https://{region}.example.com"}], - "paths": {"/a": {"get": {"x-forge-group": ["a", "", "b"], "x-forge-method": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a", "", "b"], "x-forge-method-name": "get", "responses": {"204": {"description": "ok"}}}}} }"#, ); diff --git a/tests/parse/extensions.rs b/tests/parse/extensions.rs index 9726355af..58f214836 100644 --- a/tests/parse/extensions.rs +++ b/tests/parse/extensions.rs @@ -9,7 +9,7 @@ fn operation_with(fields: &str) -> String { r#"{{ "openapi": "3.0.3", "info": {{"title": "t", "version": "1"}}, "paths": {{"/a": {{"get": {{{fields} - "x-forge-group": ["a"], "x-forge-method": "get", + "x-forge-group": ["a"], "x-forge-method-name": "get", "responses": {{"204": {{"description": "ok"}}}}}}}}}} }}"# ) @@ -71,7 +71,7 @@ fn method_without_extensions_has_none() { let get = method(&forge, "a.get"); assert_eq!(get.extensions().len(), 0); - assert_eq!(get.extension("x-forge-method"), None); + assert_eq!(get.extension("x-forge-method-name"), None); } #[test] @@ -113,9 +113,9 @@ fn extensions_forge_reads_are_still_read() { r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": {"/a": { - "get": {"x-forge-group": ["a", "b"], "x-forge-method": "list", "x-a": 1, + "get": {"x-forge-group": ["a", "b"], "x-forge-method-name": "list", "x-a": 1, "responses": {"204": {"description": "ok"}}}, - "post": {"x-forge-group": ["a"], "x-forge-method": "create", "x-forge-ignore": true, + "post": {"x-forge-group": ["a"], "x-forge-method-name": "create", "x-forge-ignore": true, "x-a": 1, "responses": {"204": {"description": "ok"}}} }} }"#, @@ -147,11 +147,11 @@ fn each_method_keeps_its_own_extensions() { r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": { - "/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", "x-a": "get", + "/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", "x-a": "get", "responses": {"204": {"description": "ok"}}}}, - "/b": {"get": {"x-forge-group": ["a"], "x-forge-method": "list", + "/b": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "list", "responses": {"204": {"description": "ok"}}}}, - "/c": {"post": {"x-forge-group": ["a"], "x-forge-method": "create", "x-a": "create", + "/c": {"post": {"x-forge-group": ["a"], "x-forge-method-name": "create", "x-a": "create", "x-b": [], "responses": {"204": {"description": "ok"}}}} } }"#, @@ -170,7 +170,7 @@ fn extensions_outside_the_operation_are_not_kept() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "x-root": 1, - "paths": {"/a": {"x-path-item": 1, "get": {"x-forge-group": ["a"], "x-forge-method": "get", + "paths": {"/a": {"x-path-item": 1, "get": {"x-forge-group": ["a"], "x-forge-method-name": "get", "parameters": [{"name": "q", "in": "query", "x-parameter": 1, "schema": {"type": "string", "x-schema": 1}}], "responses": {"204": {"description": "ok", "x-response": 1}}}}} }"#, @@ -186,11 +186,11 @@ fn operation_of_a_referenced_path_item_keeps_its_extensions() { "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, "paths": { "/a": {"$ref": "#/components/pathItems/A"}, - "/b": {"get": {"x-forge-group": ["b"], "x-forge-method": "get", "x-b": 2, + "/b": {"get": {"x-forge-group": ["b"], "x-forge-method-name": "get", "x-b": 2, "responses": {"204": {"description": "ok"}}}} }, "components": {"pathItems": { - "A": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", "x-a": 1, + "A": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", "x-a": 1, "responses": {"204": {"description": "ok"}}}} }} }"##, diff --git a/tests/parse/groups.rs b/tests/parse/groups.rs index b5d6f918a..b22306d41 100644 --- a/tests/parse/groups.rs +++ b/tests/parse/groups.rs @@ -7,7 +7,7 @@ fn string_group_name_splits_on_dots() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["accounts", "members"], "x-forge-method": "list", + "paths": {"/a": {"get": {"x-forge-group": ["accounts", "members"], "x-forge-method-name": "list", "responses": {"204": {"description": "ok"}}}}} }"#, ); @@ -21,7 +21,7 @@ fn array_group_name_has_a_group_per_element() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["accounts", "members"], "x-forge-method": "list", + "paths": {"/a": {"get": {"x-forge-group": ["accounts", "members"], "x-forge-method-name": "list", "responses": {"204": {"description": "ok"}}}}} }"#, ); @@ -35,7 +35,7 @@ fn array_element_with_a_dot_is_one_group() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["zones.settings"], "x-forge-method": "get", + "paths": {"/a": {"get": {"x-forge-group": ["zones.settings"], "x-forge-method-name": "get", "responses": {"204": {"description": "ok"}}}}} }"#, ); @@ -54,9 +54,9 @@ fn groups_with_equal_names_are_one_group() { r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": { - "/a": {"get": {"x-forge-group": ["abuse-reports"], "x-forge-method": "list", + "/a": {"get": {"x-forge-group": ["abuse-reports"], "x-forge-method-name": "list", "responses": {"204": {"description": "ok"}}}}, - "/b": {"get": {"x-forge-group": ["abuse_reports"], "x-forge-method": "get", + "/b": {"get": {"x-forge-group": ["abuse_reports"], "x-forge-method-name": "get", "responses": {"204": {"description": "ok"}}}} } }"#, @@ -72,9 +72,9 @@ fn subgroup_and_method_can_share_a_name() { r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": { - "/a": {"delete": {"x-forge-group": ["members"], "x-forge-method": "delete", + "/a": {"delete": {"x-forge-group": ["members"], "x-forge-method-name": "delete", "responses": {"204": {"description": "ok"}}}}, - "/b": {"post": {"x-forge-group": ["members", "delete"], "x-forge-method": "bulk", + "/b": {"post": {"x-forge-group": ["members", "delete"], "x-forge-method-name": "bulk", "responses": {"204": {"description": "ok"}}}} } }"#, @@ -89,7 +89,7 @@ fn operation_without_a_group_is_not_a_method() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-method": "get", + "paths": {"/a": {"get": {"x-forge-method-name": "get", "responses": {"204": {"description": "ok"}}}}} }"#, ); @@ -102,7 +102,7 @@ fn empty_group_name_is_no_group() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": [], "x-forge-method": "get", + "paths": {"/a": {"get": {"x-forge-group": [], "x-forge-method-name": "get", "responses": {"204": {"description": "ok"}}}}} }"#, ); @@ -128,7 +128,7 @@ fn empty_method_name_is_not_a_method() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "", "responses": {"204": {"description": "ok"}}}}} }"#, ); @@ -142,9 +142,9 @@ fn ignored_operation_is_not_a_method() { r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": {"/a": { - "get": {"x-forge-group": ["a"], "x-forge-method": "list", + "get": {"x-forge-group": ["a"], "x-forge-method-name": "list", "responses": {"204": {"description": "ok"}}}, - "post": {"x-forge-group": ["a"], "x-forge-method": "create", "x-forge-ignore": true, + "post": {"x-forge-group": ["a"], "x-forge-method-name": "create", "x-forge-ignore": true, "responses": {"204": {"description": "ok"}}} }} }"#, @@ -159,7 +159,7 @@ fn group_name_without_letters_is_an_error() { let err = parse_err( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["accounts", "", "members"], "x-forge-method": "list", + "paths": {"/a": {"get": {"x-forge-group": ["accounts", "", "members"], "x-forge-method-name": "list", "responses": {"204": {"description": "ok"}}}}} }"#, ); @@ -173,9 +173,9 @@ fn duplicate_method_in_a_group_is_an_error() { r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": { - "/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "list", + "/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "list", "responses": {"204": {"description": "ok"}}}}, - "/b": {"get": {"x-forge-group": ["a"], "x-forge-method": "list", + "/b": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "list", "responses": {"204": {"description": "ok"}}}} } }"#, @@ -190,9 +190,9 @@ fn method_names_are_equal_as_names() { r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": { - "/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "list_all", + "/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "list_all", "responses": {"204": {"description": "ok"}}}}, - "/b": {"get": {"x-forge-group": ["a"], "x-forge-method": "listAll", + "/b": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "listAll", "responses": {"204": {"description": "ok"}}}} } }"#, @@ -207,11 +207,11 @@ fn groups_are_depth_first() { r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": { - "/a": {"get": {"x-forge-group": ["a", "b", "c"], "x-forge-method": "get", + "/a": {"get": {"x-forge-group": ["a", "b", "c"], "x-forge-method-name": "get", "responses": {"204": {"description": "ok"}}}}, - "/b": {"get": {"x-forge-group": ["x"], "x-forge-method": "get", + "/b": {"get": {"x-forge-group": ["x"], "x-forge-method-name": "get", "responses": {"204": {"description": "ok"}}}}, - "/c": {"get": {"x-forge-group": ["a", "d"], "x-forge-method": "get", + "/c": {"get": {"x-forge-group": ["a", "d"], "x-forge-method-name": "get", "responses": {"204": {"description": "ok"}}}} } }"#, @@ -241,7 +241,7 @@ fn group_description_comes_from_x_forge_commands() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["accounts"], "x-forge-method": "list", + "paths": {"/a": {"get": {"x-forge-group": ["accounts"], "x-forge-method-name": "list", "responses": {"204": {"description": "ok"}}}}}, "x-forge-commands": {"accounts": {"description": "Accounts."}} }"#, @@ -255,7 +255,7 @@ fn subgroup_description_is_in_its_parents_command() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["accounts", "members"], "x-forge-method": "list", + "paths": {"/a": {"get": {"x-forge-group": ["accounts", "members"], "x-forge-method-name": "list", "responses": {"204": {"description": "ok"}}}}}, "x-forge-commands": {"accounts": {"groups": {"members": {"description": "Members."}}}} }"#, @@ -277,7 +277,7 @@ fn with_commands(groups: &[&str], commands: &str) -> String { .collect::>() .join(", "); format!( - r#""/{i}": {{"get": {{"x-forge-group": [{group}], "x-forge-method": "list", + r#""/{i}": {{"get": {{"x-forge-group": [{group}], "x-forge-method-name": "list", "responses": {{"204": {{"description": "ok"}}}}}}}}"# ) }) @@ -346,7 +346,7 @@ fn command_matches_a_group_with_an_equal_name() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["abuse_reports"], "x-forge-method": "list", + "paths": {"/a": {"get": {"x-forge-group": ["abuse_reports"], "x-forge-method-name": "list", "responses": {"204": {"description": "ok"}}}}}, "x-forge-commands": {"abuse-reports": {"description": "Abuse reports."}} }"#, @@ -360,7 +360,7 @@ fn group_without_a_command_has_no_description() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["accounts"], "x-forge-method": "list", + "paths": {"/a": {"get": {"x-forge-group": ["accounts"], "x-forge-method-name": "list", "responses": {"204": {"description": "ok"}}}}}, "x-forge-commands": {"zones": {"description": "Zones."}} }"#, @@ -389,13 +389,13 @@ fn groups_are_sorted_by_name() { r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": { - "/a": {"get": {"x-forge-group": ["zones"], "x-forge-method": "get", + "/a": {"get": {"x-forge-group": ["zones"], "x-forge-method-name": "get", "responses": {"204": {"description": "ok"}}}}, - "/b": {"get": {"x-forge-group": ["accounts", "members"], "x-forge-method": "get", + "/b": {"get": {"x-forge-group": ["accounts", "members"], "x-forge-method-name": "get", "responses": {"204": {"description": "ok"}}}}, - "/c": {"get": {"x-forge-group": ["dns"], "x-forge-method": "get", + "/c": {"get": {"x-forge-group": ["dns"], "x-forge-method-name": "get", "responses": {"204": {"description": "ok"}}}}, - "/d": {"get": {"x-forge-group": ["accounts", "billing"], "x-forge-method": "get", + "/d": {"get": {"x-forge-group": ["accounts", "billing"], "x-forge-method-name": "get", "responses": {"204": {"description": "ok"}}}} } }"#, @@ -417,11 +417,11 @@ fn methods_are_sorted_by_name() { r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": {"/a": { - "get": {"x-forge-group": ["a"], "x-forge-method": "list", + "get": {"x-forge-group": ["a"], "x-forge-method-name": "list", "responses": {"204": {"description": "ok"}}}, - "post": {"x-forge-group": ["a"], "x-forge-method": "create", + "post": {"x-forge-group": ["a"], "x-forge-method-name": "create", "responses": {"204": {"description": "ok"}}}, - "delete": {"x-forge-group": ["a"], "x-forge-method": "delete", + "delete": {"x-forge-group": ["a"], "x-forge-method-name": "delete", "responses": {"204": {"description": "ok"}}} }} }"#, diff --git a/tests/parse/headers.rs b/tests/parse/headers.rs index 1ab012b02..cbba07620 100644 --- a/tests/parse/headers.rs +++ b/tests/parse/headers.rs @@ -17,7 +17,7 @@ fn header_param_is_a_field() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", "parameters": [ {"name": "X-Request-ID", "in": "header", "required": true, "description": "Traces the request.", "schema": {"type": "integer", "format": "int64"}}, @@ -39,11 +39,11 @@ fn header_param_is_a_field() { } #[test] -fn header_field_is_named_by_x_fern_parameter_name() { +fn header_field_is_named_by_x_forge_parameter_name() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", "parameters": [{"name": "cf-r2-jurisdiction", "in": "header", "x-forge-parameter-name": "jurisdiction", "schema": {"type": "string"}}], "responses": {"204": {"description": "ok"}}}}} @@ -59,7 +59,7 @@ fn header_description_is_the_params_or_else_its_schemas() { let forge = parse( r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", "parameters": [ {"name": "X-Own", "in": "header", "description": "Param.", "schema": {"$ref": "#/components/schemas/Id"}}, {"name": "X-Inherited", "in": "header", "schema": {"$ref": "#/components/schemas/Id"}}, @@ -81,7 +81,7 @@ fn deprecated_header_is_a_deprecated_field() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", "parameters": [{"name": "X-Old", "in": "header", "deprecated": true, "schema": {"type": "string"}}], "responses": {"204": {"description": "ok"}}}}} }"#, @@ -95,14 +95,14 @@ fn header_is_sensitive_when_its_schema_is_and_never_read_only() { let forge = parse( r##"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", "parameters": [ - {"name": "X-Api-Key", "in": "header", "schema": {"type": "string", "x-sensitive": true, "readOnly": true}}, + {"name": "X-Api-Key", "in": "header", "schema": {"type": "string", "x-forge-sensitive": true, "readOnly": true}}, {"name": "X-Auth-Token", "in": "header", "schema": {"$ref": "#/components/schemas/Token"}}, {"name": "X-Trace", "in": "header", "schema": {"type": "string"}} ], "responses": {"204": {"description": "ok"}}}}}, - "components": {"schemas": {"Token": {"type": "string", "x-sensitive": true, "readOnly": true}}} + "components": {"schemas": {"Token": {"type": "string", "x-forge-sensitive": true, "readOnly": true}}} }"##, ); @@ -119,7 +119,7 @@ fn header_has_the_default_of_its_schema() { let forge = parse( r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", "parameters": [ {"name": "X-Version", "in": "header", "schema": {"type": "string", "default": "2024-01-01"}}, {"name": "X-Retries", "in": "header", "schema": {"$ref": "#/components/schemas/Retries"}}, @@ -144,7 +144,7 @@ fn header_param_ref_is_followed() { let forge = parse( r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"post": {"x-forge-group": ["a"], "x-forge-method": "create", + "paths": {"/a": {"post": {"x-forge-group": ["a"], "x-forge-method-name": "create", "parameters": [{"$ref": "#/components/parameters/IdempotencyKey"}], "responses": {"204": {"description": "ok"}}}}}, "components": {"parameters": {"IdempotencyKey": {"name": "Idempotency-Key", "in": "header", @@ -163,8 +163,8 @@ fn path_item_headers_belong_to_each_operation() { "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": {"/a": { "parameters": [{"name": "X-Account", "in": "header", "schema": {"type": "string"}}], - "get": {"x-forge-group": ["a"], "x-forge-method": "get", "responses": {"204": {"description": "ok"}}}, - "delete": {"x-forge-group": ["a"], "x-forge-method": "delete", "responses": {"204": {"description": "ok"}}} + "get": {"x-forge-group": ["a"], "x-forge-method-name": "get", "responses": {"204": {"description": "ok"}}}, + "delete": {"x-forge-group": ["a"], "x-forge-method-name": "delete", "responses": {"204": {"description": "ok"}}} }} }"#, ); @@ -181,7 +181,7 @@ fn operation_header_replaces_the_path_item_header() { "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": {"/a": { "parameters": [{"name": "X-Limit", "in": "header", "schema": {"type": "string"}}], - "get": {"x-forge-group": ["a"], "x-forge-method": "get", + "get": {"x-forge-group": ["a"], "x-forge-method-name": "get", "parameters": [{"name": "X-Limit", "in": "header", "required": true, "schema": {"type": "integer"}}], "responses": {"204": {"description": "ok"}}} }} @@ -202,7 +202,7 @@ fn operation_header_replaces_the_path_item_header_ignoring_case() { "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": {"/a": { "parameters": [{"name": "X-Request-ID", "in": "header", "schema": {"type": "string"}}], - "get": {"x-forge-group": ["a"], "x-forge-method": "get", + "get": {"x-forge-group": ["a"], "x-forge-method-name": "get", "parameters": [{"name": "x-request-id", "in": "header", "schema": {"type": "integer"}}], "responses": {"204": {"description": "ok"}}} }} @@ -221,7 +221,7 @@ fn query_param_does_not_replace_a_header_with_its_name() { "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": {"/a": { "parameters": [{"name": "version", "in": "header", "schema": {"type": "string"}}], - "get": {"x-forge-group": ["a"], "x-forge-method": "get", + "get": {"x-forge-group": ["a"], "x-forge-method-name": "get", "parameters": [{"name": "version", "in": "query", "schema": {"type": "string"}}], "responses": {"204": {"description": "ok"}}} }} @@ -243,7 +243,7 @@ fn headers_are_in_declared_order_the_path_items_first() { {"name": "X-First", "in": "header", "schema": {"type": "string"}}, {"name": "X-Second", "in": "header", "schema": {"type": "string"}} ], - "get": {"x-forge-group": ["a"], "x-forge-method": "get", + "get": {"x-forge-group": ["a"], "x-forge-method-name": "get", "parameters": [ {"name": "X-Third", "in": "header", "schema": {"type": "string"}}, {"name": "x-first", "in": "header", "schema": {"type": "string"}} @@ -264,7 +264,7 @@ fn headers_with_equal_names_are_an_error() { let err = parse_err( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", "parameters": [ {"name": "X-Rate-Limit", "in": "header", "schema": {"type": "string"}}, {"name": "X_Rate_Limit", "in": "header", "schema": {"type": "string"}} @@ -289,7 +289,7 @@ fn path_item_headers_differing_in_case_are_an_error() { {"name": "X-A", "in": "header", "schema": {"type": "string"}}, {"name": "x-a", "in": "header", "schema": {"type": "string"}} ], - "get": {"x-forge-group": ["a"], "x-forge-method": "get", + "get": {"x-forge-group": ["a"], "x-forge-method-name": "get", "responses": {"204": {"description": "ok"}}} }} }"#, @@ -303,7 +303,7 @@ fn header_and_param_with_equal_names_are_not_an_error() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a/{account_id}": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", + "paths": {"/a/{account_id}": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", "parameters": [ {"name": "account_id", "in": "path", "required": true, "schema": {"type": "string"}}, {"name": "Account-Id", "in": "header", "schema": {"type": "string"}} @@ -320,7 +320,7 @@ fn cookie_params_are_not_headers() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", "parameters": [ {"name": "session", "in": "cookie", "schema": {"type": "string"}}, {"name": "X-Trace", "in": "header", "schema": {"type": "string"}} @@ -337,7 +337,7 @@ fn method_with_only_cookie_params_has_no_headers() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", "parameters": [{"name": "session", "in": "cookie", "schema": {"type": "string"}}], "responses": {"204": {"description": "ok"}}}}} }"#, @@ -351,7 +351,7 @@ fn accept_content_type_and_authorization_headers_are_kept() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"put": {"x-forge-group": ["a"], "x-forge-method": "upload", + "paths": {"/a": {"put": {"x-forge-group": ["a"], "x-forge-method-name": "upload", "parameters": [ {"name": "Content-Type", "in": "header", "required": true, "schema": {"type": "string"}}, {"name": "Content-Length", "in": "header", "schema": {"type": "integer"}}, @@ -376,7 +376,7 @@ fn method_without_header_params_has_no_headers() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "list", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "list", "parameters": [{"name": "page", "in": "query", "schema": {"type": "integer"}}], "responses": {"204": {"description": "ok"}}}}} }"#, @@ -391,7 +391,7 @@ fn headers_are_not_in_the_request() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"post": {"x-forge-group": ["a"], "x-forge-method": "create", + "paths": {"/a": {"post": {"x-forge-group": ["a"], "x-forge-method-name": "create", "parameters": [ {"name": "page", "in": "query", "schema": {"type": "integer"}}, {"name": "X-Trace", "in": "header", "schema": {"type": "string"}} @@ -411,7 +411,7 @@ fn method_with_only_headers_has_no_request() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", "parameters": [{"name": "X-Trace", "in": "header", "schema": {"type": "string"}}], "responses": {"204": {"description": "ok"}}}}} }"#, @@ -427,7 +427,7 @@ fn model_used_only_by_a_header_is_a_model() { let forge = parse( r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a", "b"], "x-forge-method": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a", "b"], "x-forge-method-name": "get", "parameters": [{"name": "cf-r2-storage-class", "in": "header", "schema": {"$ref": "#/components/schemas/StorageClass"}}], "responses": {"204": {"description": "ok"}}}}}, "components": {"schemas": {"StorageClass": {"type": "string", "enum": ["Standard", "InfrequentAccess"]}}} @@ -446,7 +446,7 @@ fn header_with_content_is_an_error() { let err = parse_err( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", "parameters": [{"name": "X-Filter", "in": "header", "content": {"application/json": {"schema": { "type": "object", "properties": {"status": {"type": "string"}} }}}}], @@ -465,7 +465,7 @@ fn header_style_other_than_simple_is_an_error() { let err = parse_err( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", "parameters": [{"name": "X-Ids", "in": "header", "style": "form", "schema": {"type": "array", "items": {"type": "string"}}}], "responses": {"204": {"description": "ok"}}}}} diff --git a/tests/parse/methods.rs b/tests/parse/methods.rs index 664905303..c03f128e5 100644 --- a/tests/parse/methods.rs +++ b/tests/parse/methods.rs @@ -9,7 +9,7 @@ fn operation_with(fields: &str) -> String { r#"{{ "openapi": "3.0.3", "info": {{"title": "t", "version": "1"}}, "paths": {{"/a": {{"get": {{{fields} - "x-forge-group": ["a"], "x-forge-method": "get", + "x-forge-group": ["a"], "x-forge-method-name": "get", "responses": {{"204": {{"description": "ok"}}}}}}}}}} }}"# ) @@ -21,14 +21,14 @@ fn each_operation_field_is_its_http_method() { r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": {"/a": { - "get": {"x-forge-group": ["a"], "x-forge-method": "get", "responses": {"204": {"description": "ok"}}}, - "put": {"x-forge-group": ["a"], "x-forge-method": "put", "responses": {"204": {"description": "ok"}}}, - "post": {"x-forge-group": ["a"], "x-forge-method": "post", "responses": {"204": {"description": "ok"}}}, - "delete": {"x-forge-group": ["a"], "x-forge-method": "delete", "responses": {"204": {"description": "ok"}}}, - "options": {"x-forge-group": ["a"], "x-forge-method": "options", "responses": {"204": {"description": "ok"}}}, - "head": {"x-forge-group": ["a"], "x-forge-method": "head", "responses": {"204": {"description": "ok"}}}, - "patch": {"x-forge-group": ["a"], "x-forge-method": "patch", "responses": {"204": {"description": "ok"}}}, - "trace": {"x-forge-group": ["a"], "x-forge-method": "trace", "responses": {"204": {"description": "ok"}}} + "get": {"x-forge-group": ["a"], "x-forge-method-name": "get", "responses": {"204": {"description": "ok"}}}, + "put": {"x-forge-group": ["a"], "x-forge-method-name": "put", "responses": {"204": {"description": "ok"}}}, + "post": {"x-forge-group": ["a"], "x-forge-method-name": "post", "responses": {"204": {"description": "ok"}}}, + "delete": {"x-forge-group": ["a"], "x-forge-method-name": "delete", "responses": {"204": {"description": "ok"}}}, + "options": {"x-forge-group": ["a"], "x-forge-method-name": "options", "responses": {"204": {"description": "ok"}}}, + "head": {"x-forge-group": ["a"], "x-forge-method-name": "head", "responses": {"204": {"description": "ok"}}}, + "patch": {"x-forge-group": ["a"], "x-forge-method-name": "patch", "responses": {"204": {"description": "ok"}}}, + "trace": {"x-forge-group": ["a"], "x-forge-method-name": "trace", "responses": {"204": {"description": "ok"}}} }} }"#, ); @@ -54,7 +54,7 @@ fn query_operation_is_query() { let forge = parse( r#"{ "openapi": "3.2.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"query": {"x-forge-group": ["a"], "x-forge-method": "search", + "paths": {"/a": {"query": {"x-forge-group": ["a"], "x-forge-method-name": "search", "responses": {"204": {"description": "ok"}}}}} }"#, ); @@ -68,7 +68,7 @@ fn additional_operation_is_its_method_as_written() { r#"{ "openapi": "3.2.0", "info": {"title": "t", "version": "1"}, "paths": {"/a": {"additionalOperations": { - "COPY": {"x-forge-group": ["a"], "x-forge-method": "copy", + "COPY": {"x-forge-group": ["a"], "x-forge-method-name": "copy", "responses": {"204": {"description": "ok"}}} }}} }"#, @@ -86,7 +86,7 @@ fn additional_operation_with_the_method_of_a_field_is_that_variant() { r#"{ "openapi": "3.2.0", "info": {"title": "t", "version": "1"}, "paths": {"/a": {"additionalOperations": { - "GET": {"x-forge-group": ["a"], "x-forge-method": "get", + "GET": {"x-forge-group": ["a"], "x-forge-method-name": "get", "responses": {"204": {"description": "ok"}}} }}} }"#, @@ -101,7 +101,7 @@ fn additional_operation_methods_are_case_sensitive() { r#"{ "openapi": "3.2.0", "info": {"title": "t", "version": "1"}, "paths": {"/a": {"additionalOperations": { - "get": {"x-forge-group": ["a"], "x-forge-method": "get", + "get": {"x-forge-group": ["a"], "x-forge-method-name": "get", "responses": {"204": {"description": "ok"}}} }}} }"#, @@ -118,7 +118,7 @@ fn method_has_the_summary_description_and_deprecation_of_its_operation() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", "summary": "Get an a.", "description": "Gets an a.", "deprecated": true, "responses": {"204": {"description": "ok"}}}}} }"#, @@ -135,7 +135,7 @@ fn method_without_docs_is_empty_and_not_deprecated() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", "responses": {"204": {"description": "ok"}}}}} }"#, ); @@ -153,7 +153,7 @@ fn path_item_ref_is_followed() { "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, "paths": {"/a": {"$ref": "#/components/pathItems/A"}}, "components": {"pathItems": { - "A": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", + "A": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", "responses": {"204": {"description": "ok"}}}} }} }"##, @@ -168,11 +168,11 @@ fn extensions_in_paths_and_path_items_are_not_operations() { r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": { - "x-paths-extension": {"get": {"x-forge-group": ["x"], "x-forge-method": "get", + "x-paths-extension": {"get": {"x-forge-group": ["x"], "x-forge-method-name": "get", "responses": {"204": {"description": "ok"}}}}, "/a": { "x-forge-commands": {"a": {"description": "A."}}, - "get": {"x-forge-group": ["a"], "x-forge-method": "get", + "get": {"x-forge-group": ["a"], "x-forge-method-name": "get", "responses": {"204": {"description": "ok"}}} } } @@ -189,9 +189,9 @@ fn additional_operation_with_the_method_of_a_present_field_is_skipped() { r#"{ "openapi": "3.2.0", "info": {"title": "t", "version": "1"}, "paths": {"/a": { - "get": {"x-forge-group": ["a"], "x-forge-method": "get", + "get": {"x-forge-group": ["a"], "x-forge-method-name": "get", "responses": {"204": {"description": "ok"}}}, - "additionalOperations": {"GET": {"x-forge-group": ["a"], "x-forge-method": "shadowed", + "additionalOperations": {"GET": {"x-forge-group": ["a"], "x-forge-method-name": "shadowed", "responses": {"204": {"description": "ok"}}}} }} }"#, @@ -206,7 +206,7 @@ fn webhooks_are_not_methods() { let forge = parse( r#"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "webhooks": {"newPet": {"post": {"x-forge-group": ["pets"], "x-forge-method": "created", + "webhooks": {"newPet": {"post": {"x-forge-group": ["pets"], "x-forge-method-name": "created", "responses": {"204": {"description": "ok"}}}}} }"#, ); @@ -219,9 +219,9 @@ fn callbacks_are_not_methods() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/subscribe": {"post": {"x-forge-group": ["a"], "x-forge-method": "subscribe", + "paths": {"/subscribe": {"post": {"x-forge-group": ["a"], "x-forge-method-name": "subscribe", "callbacks": {"event": {"{$request.query.url}": {"post": { - "x-forge-group": ["a"], "x-forge-method": "event", + "x-forge-group": ["a"], "x-forge-method-name": "event", "responses": {"204": {"description": "ok"}} }}}}, "responses": {"204": {"description": "ok"}}}}} @@ -331,13 +331,13 @@ fn availability_and_deprecation_are_apart() { r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": {"/a": { - "get": {"x-forge-group": ["a"], "x-forge-method": "get", + "get": {"x-forge-group": ["a"], "x-forge-method-name": "get", "deprecated": true, "x-forge-availability": "generally-available", "responses": {"204": {"description": "ok"}}}, - "put": {"x-forge-group": ["a"], "x-forge-method": "put", + "put": {"x-forge-group": ["a"], "x-forge-method-name": "put", "x-forge-availability": "deprecated", "responses": {"204": {"description": "ok"}}}, - "post": {"x-forge-group": ["a"], "x-forge-method": "post", + "post": {"x-forge-group": ["a"], "x-forge-method-name": "post", "deprecated": true, "responses": {"204": {"description": "ok"}}} }} }"#, @@ -353,3 +353,16 @@ fn availability_and_deprecation_are_apart() { assert!(post.is_deprecated()); assert_eq!(post.availability(), None); } + +#[test] +fn x_forge_method_without_name_is_not_a_method_name() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", + "responses": {"204": {"description": "ok"}}}}} + }"#, + ); + + assert_eq!(forge.groups().count(), 0); +} diff --git a/tests/parse/models.rs b/tests/parse/models.rs index 40f7ca110..dcb7af171 100644 --- a/tests/parse/models.rs +++ b/tests/parse/models.rs @@ -14,7 +14,7 @@ fn response_model_is_placed_in_its_group() { "/zones": { "get": { "x-forge-group": ["zones"], - "x-forge-method": "list", + "x-forge-method-name": "list", "responses": { "200": { "description": "OK", @@ -43,9 +43,9 @@ fn model_used_in_sibling_groups_is_in_their_parent() { r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": { - "/c": {"get": {"x-forge-group": ["a", "b", "c"], "x-forge-method": "get", + "/c": {"get": {"x-forge-group": ["a", "b", "c"], "x-forge-method-name": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/M"}}}}}}}, - "/d": {"get": {"x-forge-group": ["a", "b", "d"], "x-forge-method": "get", + "/d": {"get": {"x-forge-group": ["a", "b", "d"], "x-forge-method-name": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/M"}}}}}}} }, "components": {"schemas": {"M": {"type": "object", "properties": {"id": {"type": "string"}}}}} @@ -61,9 +61,9 @@ fn model_used_in_a_group_and_its_subgroup_is_in_the_group() { r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": { - "/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", + "/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/M"}}}}}}}, - "/c": {"get": {"x-forge-group": ["a", "b", "c"], "x-forge-method": "get", + "/c": {"get": {"x-forge-group": ["a", "b", "c"], "x-forge-method-name": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/M"}}}}}}} }, "components": {"schemas": {"M": {"type": "object", "properties": {"id": {"type": "string"}}}}} @@ -79,9 +79,9 @@ fn model_used_in_top_level_groups_is_at_the_top_level() { r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": { - "/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", + "/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/M"}}}}}}}, - "/x": {"get": {"x-forge-group": ["x"], "x-forge-method": "get", + "/x": {"get": {"x-forge-group": ["x"], "x-forge-method-name": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/M"}}}}}}} }, "components": {"schemas": {"M": {"type": "object", "properties": {"id": {"type": "string"}}}}} @@ -99,9 +99,9 @@ fn model_used_through_another_model_is_placed_by_every_use() { r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": { - "/b": {"get": {"x-forge-group": ["a", "b"], "x-forge-method": "get", + "/b": {"get": {"x-forge-group": ["a", "b"], "x-forge-method-name": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Outer"}}}}}}}, - "/c": {"get": {"x-forge-group": ["a", "c"], "x-forge-method": "get", + "/c": {"get": {"x-forge-group": ["a", "c"], "x-forge-method-name": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Inner"}}}}}}} }, "components": {"schemas": { @@ -120,7 +120,7 @@ fn params_bodies_and_errors_use_models() { let forge = parse( r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"post": {"x-forge-group": ["a"], "x-forge-method": "create", + "paths": {"/a": {"post": {"x-forge-group": ["a"], "x-forge-method-name": "create", "parameters": [{"name": "filter", "in": "query", "schema": {"$ref": "#/components/schemas/Filter"}}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Input"}}}}, "responses": {"400": {"description": "no", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Failure"}}}}}}}}, @@ -144,11 +144,11 @@ fn schema_no_method_uses_is_not_a_model() { r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": { - "/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", + "/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Used"}}}}}}}, - "/ignored": {"get": {"x-forge-group": ["a"], "x-forge-method": "ignored", "x-forge-ignore": true, + "/ignored": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "ignored", "x-forge-ignore": true, "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Ignored"}}}}}}}, - "/ungrouped": {"get": {"x-forge-method": "ungrouped", + "/ungrouped": {"get": {"x-forge-method-name": "ungrouped", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Ungrouped"}}}}}}} }, "components": {"schemas": { @@ -186,7 +186,7 @@ fn model_is_named_after_its_schema() { } #[test] -fn model_is_named_by_x_fern_type_name() { +fn model_is_named_by_x_forge_model_name() { let forge = parse(&responding_with_schemas( r##"{"$ref": "#/components/schemas/not_found"}"##, r#"{"not_found": {"type": "object", "x-forge-model-name": "Missing"}}"#, @@ -224,7 +224,7 @@ fn schema_in_defs_is_a_model() { let forge = parse( r##"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Tree/$defs/Node"}}}}}}}}, "components": {"schemas": { "Tree": {"type": "object", "$defs": {"Node": {"type": "string"}}} @@ -256,7 +256,7 @@ fn schema_used_only_by_content_that_is_not_json_is_a_model() { let forge = parse( r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"post": {"x-forge-group": ["a", "files"], "x-forge-method": "upload", + "paths": {"/a": {"post": {"x-forge-group": ["a", "files"], "x-forge-method-name": "upload", "requestBody": {"content": {"multipart/form-data": {"schema": {"$ref": "#/components/schemas/Upload"}}}}, "responses": {"200": {"description": "ok", "content": {"text/csv": {"schema": {"$ref": "#/components/schemas/Report"}}}}}}}}, "components": {"schemas": { @@ -287,9 +287,9 @@ fn use_by_content_that_is_not_json_places_a_model() { r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": { - "/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", + "/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/M"}}}}}}}, - "/b": {"post": {"x-forge-group": ["b"], "x-forge-method": "upload", + "/b": {"post": {"x-forge-group": ["b"], "x-forge-method-name": "upload", "requestBody": {"content": {"multipart/form-data": {"schema": { "type": "object", "properties": {"meta": {"$ref": "#/components/schemas/M"}} }}}}, @@ -307,7 +307,7 @@ fn schema_used_only_in_a_response_header_is_not_a_model() { let forge = parse( r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", "parameters": [{"name": "X-Trace", "in": "header", "schema": {"$ref": "#/components/schemas/Trace"}}], "responses": {"204": {"description": "ok", "headers": {"X-Rate-Limit": {"schema": {"$ref": "#/components/schemas/RateLimit"}}}}}}}}, diff --git a/tests/parse/requests.rs b/tests/parse/requests.rs index 9fe63590c..e87904898 100644 --- a/tests/parse/requests.rs +++ b/tests/parse/requests.rs @@ -14,7 +14,7 @@ fn path_param_is_a_required_request_field() { "/zones/{zone_id}": { "get": { "x-forge-group": ["zones"], - "x-forge-method": "get", + "x-forge-method-name": "get", "parameters": [ { "name": "zone_id", "in": "path", "required": true, "schema": { "type": "string" } } ], @@ -44,7 +44,7 @@ fn query_param_is_optional_and_exploded_by_default() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "list", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "list", "parameters": [{"name": "page", "in": "query", "schema": {"type": "integer"}}], "responses": {"204": {"description": "ok"}}}}} }"#, @@ -61,7 +61,7 @@ fn query_param_can_be_required() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "list", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "list", "parameters": [{"name": "page", "in": "query", "required": true, "schema": {"type": "integer"}}], "responses": {"204": {"description": "ok"}}}}} }"#, @@ -76,7 +76,7 @@ fn query_param_with_explode_false_is_not_exploded() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "list", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "list", "parameters": [{"name": "ids", "in": "query", "style": "form", "explode": false, "schema": {"type": "array", "items": {"type": "string"}}}], "responses": {"204": {"description": "ok"}}}}} @@ -93,7 +93,7 @@ fn header_and_cookie_params_are_not_fields() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", "parameters": [ {"name": "X-Trace", "in": "header", "schema": {"type": "string"}}, {"name": "session", "in": "cookie", "schema": {"type": "string"}} @@ -107,11 +107,11 @@ fn header_and_cookie_params_are_not_fields() { } #[test] -fn param_field_is_named_by_x_fern_parameter_name() { +fn param_field_is_named_by_x_forge_parameter_name() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "list", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "list", "parameters": [{"name": "q", "in": "query", "x-forge-parameter-name": "query", "schema": {"type": "string"}}], "responses": {"204": {"description": "ok"}}}}} }"#, @@ -126,7 +126,7 @@ fn param_description_falls_back_to_an_inline_schema() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "list", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "list", "parameters": [ {"name": "own", "in": "query", "description": "Param.", "schema": {"type": "string", "description": "Schema."}}, {"name": "inherited", "in": "query", "schema": {"type": "string", "description": "Schema."}} @@ -145,7 +145,7 @@ fn param_description_comes_from_a_ref_schema() { let forge = parse( r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "list", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "list", "parameters": [ {"name": "own", "in": "query", "description": "Param.", "schema": {"$ref": "#/components/schemas/Id"}}, {"name": "id", "in": "query", "schema": {"$ref": "#/components/schemas/Id"}} @@ -165,7 +165,7 @@ fn param_description_follows_a_chain_of_refs() { let forge = parse( r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a/{id}": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", + "paths": {"/a/{id}": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"$ref": "#/components/schemas/Id"}}], "responses": {"204": {"description": "ok"}}}}}, "components": {"schemas": { @@ -184,7 +184,7 @@ fn param_description_is_never_a_schema_title() { let forge = parse( r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "list", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "list", "parameters": [ {"name": "id", "in": "query", "schema": {"$ref": "#/components/schemas/Id"}}, {"name": "inline", "in": "query", "schema": {"type": "string", "title": "Inline"}} @@ -204,7 +204,7 @@ fn description_next_to_a_param_schema_ref_is_its_own_in_3_1() { let forge = parse( r##"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "list", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "list", "parameters": [{"name": "id", "in": "query", "schema": {"$ref": "#/components/schemas/Id", "description": "The ID to find."}}], "responses": {"204": {"description": "ok"}}}}}, @@ -221,7 +221,7 @@ fn param_description_comes_from_an_all_of_member_in_3_1() { let forge = parse( r##"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "list", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "list", "parameters": [{"name": "region", "in": "query", "schema": {"allOf": [{"$ref": "#/components/schemas/Region"}, {"default": "us"}]}}], "responses": {"204": {"description": "ok"}}}}}, @@ -238,7 +238,7 @@ fn param_description_from_an_all_of_member_is_ignored_in_3_0() { let forge = parse( r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "list", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "list", "parameters": [{"name": "region", "in": "query", "schema": {"allOf": [{"$ref": "#/components/schemas/Region"}, {"default": "us"}]}}], "responses": {"204": {"description": "ok"}}}}}, @@ -255,7 +255,7 @@ fn param_ref_description_overrides_the_params_in_3_1() { let forge = parse( r##"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "list", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "list", "parameters": [{"$ref": "#/components/parameters/Page", "description": "The page of pets."}], "responses": {"204": {"description": "ok"}}}}}, "components": {"parameters": {"Page": {"name": "page", "in": "query", "description": "A page.", @@ -272,7 +272,7 @@ fn param_ref_description_is_ignored_in_3_0() { let forge = parse( r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "list", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "list", "parameters": [{"$ref": "#/components/parameters/Page", "description": "The page of pets."}], "responses": {"204": {"description": "ok"}}}}}, "components": {"parameters": {"Page": {"name": "page", "in": "query", "description": "A page.", @@ -289,7 +289,7 @@ fn param_ref_without_a_description_has_the_params() { let forge = parse( r##"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "list", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "list", "parameters": [{"$ref": "#/components/parameters/Page"}], "responses": {"204": {"description": "ok"}}}}}, "components": {"parameters": {"Page": {"name": "page", "in": "query", "description": "A page.", @@ -306,7 +306,7 @@ fn param_field_is_never_read_only() { let forge = parse( r##"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a/{id}": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", + "paths": {"/a/{id}": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", "parameters": [ {"name": "id", "in": "path", "required": true, "schema": {"type": "string", "readOnly": true}}, {"name": "since", "in": "query", "schema": {"$ref": "#/components/schemas/Since", "readOnly": true}}, @@ -331,9 +331,9 @@ fn query_string_property_is_never_read_only() { let forge = parse( r#"{ "openapi": "3.2.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "list", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "list", "parameters": [{"name": "filter", "in": "querystring", "content": {"application/x-www-form-urlencoded": {"schema": { - "type": "object", "properties": {"status": {"type": "string", "readOnly": true, "x-sensitive": true}} + "type": "object", "properties": {"status": {"type": "string", "readOnly": true, "x-forge-sensitive": true}} }}}}], "responses": {"204": {"description": "ok"}}}}} }"#, @@ -349,9 +349,9 @@ fn param_field_is_sensitive_when_its_schema_is() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "list", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "list", "parameters": [ - {"name": "token", "in": "query", "schema": {"type": "string", "x-sensitive": true}}, + {"name": "token", "in": "query", "schema": {"type": "string", "x-forge-sensitive": true}}, {"name": "page", "in": "query", "schema": {"type": "integer"}} ], "responses": {"204": {"description": "ok"}}}}} @@ -368,12 +368,12 @@ fn param_field_is_sensitive_when_its_ref_schema_is() { let forge = parse( r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "list", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "list", "parameters": [{"name": "token", "in": "query", "schema": {"$ref": "#/components/schemas/Token"}}], "responses": {"204": {"description": "ok"}}}}}, "components": {"schemas": { "Token": {"$ref": "#/components/schemas/Secret"}, - "Secret": {"type": "string", "x-sensitive": true} + "Secret": {"type": "string", "x-forge-sensitive": true} }} }"##, ); @@ -386,9 +386,9 @@ fn sensitive_next_to_a_param_schema_ref_counts_in_3_0() { let forge = parse( r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "list", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "list", "parameters": [{"name": "token", "in": "query", - "schema": {"$ref": "#/components/schemas/Token", "x-sensitive": true}}], + "schema": {"$ref": "#/components/schemas/Token", "x-forge-sensitive": true}}], "responses": {"204": {"description": "ok"}}}}}, "components": {"schemas": {"Token": {"type": "string"}}} }"##, @@ -402,8 +402,8 @@ fn sensitive_on_the_param_itself_is_ignored() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "list", - "parameters": [{"name": "token", "in": "query", "x-sensitive": true, "schema": {"type": "string"}}], + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "list", + "parameters": [{"name": "token", "in": "query", "x-forge-sensitive": true, "schema": {"type": "string"}}], "responses": {"204": {"description": "ok"}}}}} }"#, ); @@ -416,7 +416,7 @@ fn param_ref_is_followed() { let forge = parse( r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "list", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "list", "parameters": [{"$ref": "#/components/parameters/Page"}], "responses": {"204": {"description": "ok"}}}}}, "components": {"parameters": {"Page": {"name": "page", "in": "query", "schema": {"type": "integer"}}}} @@ -435,8 +435,8 @@ fn path_item_params_belong_to_each_operation() { "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": {"/a/{id}": { "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}], - "get": {"x-forge-group": ["a"], "x-forge-method": "get", "responses": {"204": {"description": "ok"}}}, - "delete": {"x-forge-group": ["a"], "x-forge-method": "delete", "responses": {"204": {"description": "ok"}}} + "get": {"x-forge-group": ["a"], "x-forge-method-name": "get", "responses": {"204": {"description": "ok"}}}, + "delete": {"x-forge-group": ["a"], "x-forge-method-name": "delete", "responses": {"204": {"description": "ok"}}} }} }"#, ); @@ -455,7 +455,7 @@ fn operation_param_replaces_the_path_item_param() { "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": {"/a": { "parameters": [{"name": "page", "in": "query", "schema": {"type": "string"}}], - "get": {"x-forge-group": ["a"], "x-forge-method": "list", + "get": {"x-forge-group": ["a"], "x-forge-method-name": "list", "parameters": [{"name": "page", "in": "query", "schema": {"type": "integer"}}], "responses": {"204": {"description": "ok"}}} }} @@ -472,7 +472,7 @@ fn undeclared_path_placeholder_is_a_required_string() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a/{id}": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", + "paths": {"/a/{id}": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", "responses": {"204": {"description": "ok"}}}}} }"#, ); @@ -491,7 +491,7 @@ fn params_are_in_path_template_then_declared_order() { "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": {"/a/{second}/{first}": { "parameters": [{"name": "item_query", "in": "query", "schema": {"type": "string"}}], - "get": {"x-forge-group": ["a"], "x-forge-method": "get", + "get": {"x-forge-group": ["a"], "x-forge-method-name": "get", "parameters": [ {"name": "op_query", "in": "query", "schema": {"type": "string"}}, {"name": "first", "in": "path", "required": true, "schema": {"type": "string"}}, @@ -511,7 +511,7 @@ fn query_string_object_is_query_fields() { let forge = parse( r#"{ "openapi": "3.2.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "list", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "list", "parameters": [{"name": "filter", "in": "querystring", "content": {"application/x-www-form-urlencoded": {"schema": { "type": "object", "properties": {"status": {"type": "string"}, "limit": {"type": "integer"}} }}}}], @@ -532,7 +532,7 @@ fn no_params_and_no_body_is_no_request() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", "responses": {"204": {"description": "ok"}}}}} }"#, ); @@ -545,7 +545,7 @@ fn body_without_params_is_the_request() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"post": {"x-forge-group": ["a"], "x-forge-method": "create", + "paths": {"/a": {"post": {"x-forge-group": ["a"], "x-forge-method-name": "create", "requestBody": {"content": {"application/json": {"schema": {"type": "array", "items": {"type": "string"}}}}}, "responses": {"204": {"description": "ok"}}}}} }"#, @@ -559,7 +559,7 @@ fn object_body_is_intersected_with_the_params() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a/{id}": {"put": {"x-forge-group": ["a"], "x-forge-method": "update", + "paths": {"/a/{id}": {"put": {"x-forge-group": ["a"], "x-forge-method-name": "update", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}], "requestBody": {"content": {"application/json": {"schema": { "type": "object", "properties": {"name": {"type": "string"}} @@ -582,7 +582,7 @@ fn model_body_is_intersected_with_the_params() { let forge = parse( r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a/{id}": {"put": {"x-forge-group": ["a"], "x-forge-method": "update", + "paths": {"/a/{id}": {"put": {"x-forge-group": ["a"], "x-forge-method-name": "update", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Pet"}}}}, "responses": {"204": {"description": "ok"}}}}}, @@ -604,7 +604,7 @@ fn union_body_is_intersected_with_the_params() { let forge = parse( r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a/{id}": {"put": {"x-forge-group": ["a"], "x-forge-method": "update", + "paths": {"/a/{id}": {"put": {"x-forge-group": ["a"], "x-forge-method-name": "update", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}], "requestBody": {"content": {"application/json": {"schema": { "oneOf": [{"$ref": "#/components/schemas/Cat"}, {"$ref": "#/components/schemas/Dog"}] @@ -631,7 +631,7 @@ fn array_body_is_the_last_field_of_the_params() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a/{id}": {"put": {"x-forge-group": ["a"], "x-forge-method": "update", + "paths": {"/a/{id}": {"put": {"x-forge-group": ["a"], "x-forge-method-name": "update", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}], "requestBody": {"content": {"application/json": {"schema": {"type": "array", "items": {"type": "string"}}}}}, "responses": {"204": {"description": "ok"}}}}} @@ -651,10 +651,10 @@ fn body_field_is_neither_read_only_nor_sensitive() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a/{id}": {"put": {"x-forge-group": ["a"], "x-forge-method": "update", + "paths": {"/a/{id}": {"put": {"x-forge-group": ["a"], "x-forge-method-name": "update", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}], "requestBody": {"content": {"application/json": {"schema": { - "type": "array", "items": {"type": "string"}, "readOnly": true, "x-sensitive": true + "type": "array", "items": {"type": "string"}, "readOnly": true, "x-forge-sensitive": true }}}}, "responses": {"204": {"description": "ok"}}}}} }"#, @@ -670,7 +670,7 @@ fn read_only_field_of_a_body_model_is_kept() { let forge = parse( r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"post": {"x-forge-group": ["a"], "x-forge-method": "create", + "paths": {"/a": {"post": {"x-forge-group": ["a"], "x-forge-method-name": "create", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Pet"}}}}, "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Pet"}}}}}}}}, "components": {"schemas": {"Pet": {"type": "object", "properties": { @@ -690,7 +690,7 @@ fn map_body_is_a_body_field() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a/{id}": {"put": {"x-forge-group": ["a"], "x-forge-method": "update", + "paths": {"/a/{id}": {"put": {"x-forge-group": ["a"], "x-forge-method-name": "update", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}], "requestBody": {"content": {"application/json": {"schema": { "type": "object", "additionalProperties": {"type": "string"} @@ -708,7 +708,7 @@ fn unknown_body_is_a_body_field() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a/{id}": {"put": {"x-forge-group": ["a"], "x-forge-method": "update", + "paths": {"/a/{id}": {"put": {"x-forge-group": ["a"], "x-forge-method-name": "update", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}], "requestBody": {"content": {"application/json": {"schema": {}}}}, "responses": {"204": {"description": "ok"}}}}} @@ -724,7 +724,7 @@ fn nullable_body_is_a_body_field() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a/{id}": {"put": {"x-forge-group": ["a"], "x-forge-method": "update", + "paths": {"/a/{id}": {"put": {"x-forge-group": ["a"], "x-forge-method-name": "update", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}], "requestBody": {"content": {"application/json": {"schema": { "type": "object", "nullable": true, "properties": {"name": {"type": "string"}} @@ -742,7 +742,7 @@ fn body_with_the_name_of_a_param_is_a_body_field() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a/{zone_id}": {"put": {"x-forge-group": ["a"], "x-forge-method": "update", + "paths": {"/a/{zone_id}": {"put": {"x-forge-group": ["a"], "x-forge-method-name": "update", "parameters": [{"name": "zone_id", "in": "path", "required": true, "schema": {"type": "string"}}], "requestBody": {"content": {"application/json": {"schema": { "type": "object", "properties": {"zoneId": {"type": "string"}, "name": {"type": "string"}} @@ -762,8 +762,8 @@ fn field_of_another_method_has_no_location() { "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": {"/a/{id}": { "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}], - "get": {"x-forge-group": ["a"], "x-forge-method": "get", "responses": {"204": {"description": "ok"}}}, - "delete": {"x-forge-group": ["a"], "x-forge-method": "delete", "responses": {"204": {"description": "ok"}}} + "get": {"x-forge-group": ["a"], "x-forge-method-name": "get", "responses": {"204": {"description": "ok"}}}, + "delete": {"x-forge-group": ["a"], "x-forge-method-name": "delete", "responses": {"204": {"description": "ok"}}} }} }"#, ); @@ -779,7 +779,7 @@ fn body_that_is_not_json_is_an_unknown_body_field() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a/{id}": {"post": {"x-forge-group": ["a"], "x-forge-method": "upload", + "paths": {"/a/{id}": {"post": {"x-forge-group": ["a"], "x-forge-method-name": "upload", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}], "requestBody": {"content": {"multipart/form-data": {"schema": { "type": "object", "properties": {"file": {"type": "string", "format": "binary"}} @@ -798,7 +798,7 @@ fn body_that_is_only_multipart_is_an_unknown_request() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"post": {"x-forge-group": ["a"], "x-forge-method": "upload", + "paths": {"/a": {"post": {"x-forge-group": ["a"], "x-forge-method-name": "upload", "requestBody": {"content": {"multipart/form-data": {"schema": { "type": "object", "properties": {"file": {"type": "string", "format": "binary"}} }}}}, @@ -816,7 +816,7 @@ fn path_param_missing_from_the_template_is_dropped() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a/{id}": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", + "paths": {"/a/{id}": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", "parameters": [ {"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}, {"name": "account_id", "in": "path", "required": true, "schema": {"type": "string"}} @@ -833,7 +833,7 @@ fn deprecated_query_param_is_a_deprecated_field() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "list", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "list", "parameters": [{"name": "page", "in": "query", "deprecated": true, "schema": {"type": "integer"}}], "responses": {"204": {"description": "ok"}}}}} }"#, @@ -847,7 +847,7 @@ fn deprecated_path_param_is_not_a_deprecated_field() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a/{id}": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", + "paths": {"/a/{id}": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", "parameters": [{"name": "id", "in": "path", "required": true, "deprecated": true, "schema": {"type": "string"}}], "responses": {"204": {"description": "ok"}}}}} }"#, @@ -861,7 +861,7 @@ fn all_of_body_is_one_member_of_the_intersection() { let forge = parse( r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a/{id}": {"put": {"x-forge-group": ["a"], "x-forge-method": "update", + "paths": {"/a/{id}": {"put": {"x-forge-group": ["a"], "x-forge-method-name": "update", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}], "requestBody": {"content": {"application/json": {"schema": { "allOf": [{"$ref": "#/components/schemas/A"}, {"$ref": "#/components/schemas/B"}] @@ -888,7 +888,7 @@ fn param_with_content_is_an_error() { let err = parse_err( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "list", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "list", "parameters": [{"name": "filter", "in": "query", "content": {"application/json": {"schema": { "type": "object", "properties": {"status": {"type": "string"}} }}}}], @@ -912,7 +912,7 @@ fn query_style_other_than_form_is_an_error() { let err = parse_err(&format!( r#"{{ "openapi": "3.0.3", "info": {{"title": "t", "version": "1"}}, - "paths": {{"/a": {{"get": {{"x-forge-group": ["a"], "x-forge-method": "list", + "paths": {{"/a": {{"get": {{"x-forge-group": ["a"], "x-forge-method-name": "list", "parameters": [{{"name": "filter", "in": "query", "style": "{style}", "schema": {schema}}}], "responses": {{"204": {{"description": "ok"}}}}}}}}}} }}"# @@ -927,7 +927,7 @@ fn two_query_string_params_are_an_error() { let err = parse_err( r#"{ "openapi": "3.2.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "list", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "list", "parameters": [ {"name": "one", "in": "querystring", "content": {"application/x-www-form-urlencoded": {"schema": {"type": "object"}}}}, {"name": "two", "in": "querystring", "content": {"application/x-www-form-urlencoded": {"schema": {"type": "object"}}}} @@ -946,7 +946,7 @@ fn query_and_query_string_params_are_an_error() { "openapi": "3.2.0", "info": {"title": "t", "version": "1"}, "paths": {"/a": { "parameters": [{"name": "page", "in": "query", "schema": {"type": "integer"}}], - "get": {"x-forge-group": ["a"], "x-forge-method": "list", + "get": {"x-forge-group": ["a"], "x-forge-method-name": "list", "parameters": [{"name": "filter", "in": "querystring", "content": {"application/x-www-form-urlencoded": {"schema": {"type": "object"}}}}], "responses": {"204": {"description": "ok"}}} }} @@ -963,10 +963,10 @@ fn body_field_is_required_when_the_body_is() { "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": {"/a/{id}": { "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}], - "put": {"x-forge-group": ["a"], "x-forge-method": "replace", + "put": {"x-forge-group": ["a"], "x-forge-method-name": "replace", "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "array", "items": {"type": "string"}}}}}, "responses": {"204": {"description": "ok"}}}, - "patch": {"x-forge-group": ["a"], "x-forge-method": "append", + "patch": {"x-forge-group": ["a"], "x-forge-method-name": "append", "requestBody": {"content": {"application/json": {"schema": {"type": "array", "items": {"type": "string"}}}}}, "responses": {"204": {"description": "ok"}}} }} @@ -983,7 +983,7 @@ fn params_with_equal_names_are_an_error() { let err = parse_err( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a/{zone_id}": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", + "paths": {"/a/{zone_id}": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", "parameters": [ {"name": "zone_id", "in": "path", "required": true, "schema": {"type": "string"}}, {"name": "zoneId", "in": "query", "schema": {"type": "string"}} @@ -1001,7 +1001,7 @@ fn path_style_other_than_simple_is_an_error() { let err = parse_err(&format!( r#"{{ "openapi": "3.0.3", "info": {{"title": "t", "version": "1"}}, - "paths": {{"/a/{{id}}": {{"get": {{"x-forge-group": ["a"], "x-forge-method": "get", + "paths": {{"/a/{{id}}": {{"get": {{"x-forge-group": ["a"], "x-forge-method-name": "get", "parameters": [{{"name": "id", "in": "path", "required": true, "style": "{style}", "schema": {{"type": "string"}}}}], "responses": {{"204": {{"description": "ok"}}}}}}}}}} }}"# @@ -1016,7 +1016,7 @@ fn param_field_has_the_default_of_its_schema() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "list", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "list", "parameters": [ {"name": "per_page", "in": "query", "schema": {"type": "integer", "default": 20}}, {"name": "page", "in": "query", "schema": {"type": "integer"}} @@ -1035,7 +1035,7 @@ fn param_field_has_the_default_of_its_ref_schema() { let forge = parse( r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a/{order}": {"get": {"x-forge-group": ["a"], "x-forge-method": "list", + "paths": {"/a/{order}": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "list", "parameters": [ {"name": "order", "in": "path", "required": true, "schema": {"$ref": "#/components/schemas/Order"}}, {"$ref": "#/components/parameters/Direction"} @@ -1062,7 +1062,7 @@ fn default_next_to_a_param_schema_ref_is_its_own_in_3_1() { let forge = parse( r##"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "list", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "list", "parameters": [{"name": "per_page", "in": "query", "schema": {"$ref": "#/components/schemas/PerPage", "default": 50}}], "responses": {"204": {"description": "ok"}}}}}, @@ -1079,7 +1079,7 @@ fn default_on_the_param_itself_is_ignored() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "list", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "list", "parameters": [{"name": "per_page", "in": "query", "default": 20, "schema": {"type": "integer"}}], "responses": {"204": {"description": "ok"}}}}} }"#, @@ -1096,7 +1096,7 @@ fn body_field_and_undeclared_placeholder_have_no_default() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a/{id}": {"put": {"x-forge-group": ["a"], "x-forge-method": "update", + "paths": {"/a/{id}": {"put": {"x-forge-group": ["a"], "x-forge-method-name": "update", "requestBody": {"content": {"application/json": {"schema": { "type": "array", "items": {"type": "string"}, "default": ["x"] }}}}, @@ -1114,7 +1114,7 @@ fn array_body_keeps_its_max_items() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"post": {"x-forge-group": ["a"], "x-forge-method": "create", + "paths": {"/a": {"post": {"x-forge-group": ["a"], "x-forge-method-name": "create", "requestBody": {"content": {"application/json": {"schema": { "type": "array", "items": {"type": "string"}, "maxItems": 100 }}}}, diff --git a/tests/parse/responses.rs b/tests/parse/responses.rs index eab8ea51d..7ac12e940 100644 --- a/tests/parse/responses.rs +++ b/tests/parse/responses.rs @@ -7,7 +7,7 @@ fn responses_are_the_2xx_sorted_by_status() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"post": {"x-forge-group": ["a"], "x-forge-method": "create", + "paths": {"/a": {"post": {"x-forge-group": ["a"], "x-forge-method-name": "create", "responses": { "2XX": {"description": "ok"}, "201": {"description": "ok"}, @@ -29,7 +29,7 @@ fn errors_are_the_rest_sorted_with_default_last() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", "responses": { "default": {"description": "no"}, "500": {"description": "no"}, @@ -54,7 +54,7 @@ fn response_has_its_description() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", "responses": {"200": {"description": "The pet."}}}}} }"#, ); @@ -68,7 +68,7 @@ fn response_without_content_has_none() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"delete": {"x-forge-group": ["a"], "x-forge-method": "delete", + "paths": {"/a": {"delete": {"x-forge-group": ["a"], "x-forge-method-name": "delete", "responses": {"204": {"description": "ok"}}}}} }"#, ); @@ -83,7 +83,7 @@ fn response_ref_is_followed() { let forge = parse( r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", "responses": {"4XX": {"$ref": "#/components/responses/Failure"}}}}}, "components": {"responses": { "Failure": {"description": "Failed.", "content": {"application/json": {"schema": {"type": "string"}}}} @@ -102,7 +102,7 @@ fn response_content_that_is_not_json_is_typed() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "download", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "download", "responses": {"200": {"description": "ok", "content": { "application/octet-stream": {"schema": {"type": "string", "format": "binary"}}, "text/csv": {"schema": {"type": "string"}} diff --git a/tests/parse/types/arrays.rs b/tests/parse/types/arrays.rs index bf5d85931..3af14fdf8 100644 --- a/tests/parse/types/arrays.rs +++ b/tests/parse/types/arrays.rs @@ -92,7 +92,7 @@ fn array_without_items_has_unknown_elements() { let forge = parse( r#"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { "type": "array" }}}}}}}} @@ -107,7 +107,7 @@ fn tuple_is_unknown() { let forge = parse( r#"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { "type": "array", "prefixItems": [{"type": "string"}, {"type": "integer"}], "items": false }}}}}}}} diff --git a/tests/parse/types/enums.rs b/tests/parse/types/enums.rs index 101ea434d..a1511c847 100644 --- a/tests/parse/types/enums.rs +++ b/tests/parse/types/enums.rs @@ -59,7 +59,7 @@ fn const_is_an_enum_of_one_value() { let forge = parse( r#"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { "const": "a" }}}}}}}} diff --git a/tests/parse/types/intersections.rs b/tests/parse/types/intersections.rs index ca373866b..26c7b8ae3 100644 --- a/tests/parse/types/intersections.rs +++ b/tests/parse/types/intersections.rs @@ -49,7 +49,7 @@ fn true_member_is_dropped() { let forge = parse( r##"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { "allOf": [{"$ref": "#/components/schemas/A"}, true, {"$ref": "#/components/schemas/B"}] }}}}}}}}, @@ -141,7 +141,7 @@ fn unevaluated_properties_is_an_object_member() { let forge = parse( r##"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { "allOf": [{"$ref": "#/components/schemas/A"}], "unevaluatedProperties": {"type": "string"} diff --git a/tests/parse/types/nulls.rs b/tests/parse/types/nulls.rs index b191b43cc..ecf06e087 100644 --- a/tests/parse/types/nulls.rs +++ b/tests/parse/types/nulls.rs @@ -95,7 +95,7 @@ fn nullable_is_read_in_3_1() { let forge = parse( r#"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { "type": "string", "nullable": true }}}}}}}} @@ -110,7 +110,7 @@ fn null_in_a_type_array_is_a_union_with_null() { let forge = parse( r#"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { "type": ["string", "null"] }}}}}}}} @@ -125,7 +125,7 @@ fn null_type_is_null() { let forge = parse( r#"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { "type": "null" }}}}}}}} @@ -140,7 +140,7 @@ fn const_null_is_null() { let forge = parse( r#"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { "const": null }}}}}}}} diff --git a/tests/parse/types/objects.rs b/tests/parse/types/objects.rs index f3e9bf7dc..7135a1f19 100644 --- a/tests/parse/types/objects.rs +++ b/tests/parse/types/objects.rs @@ -36,7 +36,7 @@ fn required_names_without_a_property_are_ignored() { } #[test] -fn field_is_named_by_x_fern_property_name() { +fn field_is_named_by_x_forge_property_name() { let forge = parse(&responding_with( r#"{"type": "object", "properties": {"owner": {"type": "string", "x-forge-property-name": "person"}}}"#, )); @@ -78,7 +78,7 @@ fn field_is_read_only_when_its_property_is() { #[test] fn field_is_neither_read_only_nor_sensitive_by_default() { let forge = parse(&responding_with( - r#"{"type": "object", "properties": {"name": {"type": "string"}, "shown": {"type": "string", "readOnly": false, "x-sensitive": false}}}"#, + r#"{"type": "object", "properties": {"name": {"type": "string"}, "shown": {"type": "string", "readOnly": false, "x-forge-sensitive": false}}}"#, )); let object = object(response_type(&forge, "a.get")); @@ -100,7 +100,7 @@ fn read_only_that_is_not_a_boolean_is_an_error() { #[test] fn field_is_sensitive_when_its_property_is() { let forge = parse(&responding_with( - r#"{"type": "object", "properties": {"password": {"type": "string", "x-sensitive": true}}}"#, + r#"{"type": "object", "properties": {"password": {"type": "string", "x-forge-sensitive": true}}}"#, )); assert!(field(object(response_type(&forge, "a.get")), "password").is_sensitive()); @@ -109,7 +109,7 @@ fn field_is_sensitive_when_its_property_is() { #[test] fn sensitive_that_is_not_a_boolean_is_an_error() { let err = parse_err(&responding_with( - r#"{"type": "object", "properties": {"password": {"type": "string", "x-sensitive": "true"}}}"#, + r#"{"type": "object", "properties": {"password": {"type": "string", "x-forge-sensitive": "true"}}}"#, )); assert!(err.contains("expected a boolean"), "{err}"); @@ -195,7 +195,7 @@ fn pattern_properties_are_ignored() { let forge = parse( r#"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { "type": "object", "properties": {"id": {"type": "string"}}, "patternProperties": {"^x-": {"type": "string"}} }}}}}}}} @@ -212,7 +212,7 @@ fn false_property_is_no_field() { let forge = parse( r#"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { "type": "object", "properties": {"id": {"type": "string"}, "gone": false} }}}}}}}} @@ -297,3 +297,52 @@ fn default_is_not_checked_against_the_schema() { let limit = field(object(response_type(&forge, "a.get")), "limit"); assert_eq!(limit.default(), Some(&Json::String("ten".into()))); } + +#[test] +fn field_is_sensitive_by_the_deprecated_x_sensitive() { + let forge = parse(&responding_with( + r#"{"type": "object", "properties": {"password": {"type": "string", "x-sensitive": true}}}"#, + )); + + assert!(field(object(response_type(&forge, "a.get")), "password").is_sensitive()); +} + +#[test] +fn x_forge_sensitive_wins_over_the_deprecated_x_sensitive_in_either_order() { + for (keys, sensitive) in [ + (r#""x-forge-sensitive": false, "x-sensitive": true"#, false), + (r#""x-sensitive": true, "x-forge-sensitive": false"#, false), + (r#""x-forge-sensitive": true, "x-sensitive": false"#, true), + (r#""x-sensitive": false, "x-forge-sensitive": true"#, true), + ] { + let forge = parse(&responding_with(&format!( + r#"{{"type": "object", "properties": {{"password": {{"type": "string", {keys}}}}}}}"# + ))); + + assert_eq!( + field(object(response_type(&forge, "a.get")), "password").is_sensitive(), + sensitive, + "{keys}" + ); + } +} + +#[test] +fn deprecated_x_sensitive_that_is_not_a_boolean_is_an_error_even_beside_x_forge_sensitive() { + let err = parse_err(&responding_with( + r#"{"type": "object", "properties": {"password": {"type": "string", "x-forge-sensitive": true, "x-sensitive": "yes"}}}"#, + )); + + assert!(err.contains("invalid type"), "{err}"); +} + +#[test] +fn sensitive_written_twice_under_one_name_is_an_error_naming_it() { + for key in ["x-forge-sensitive", "x-sensitive"] { + let err = parse_err(&responding_with(&format!( + r#"{{"type": "object", "properties": {{"password": {{"type": "string", "{key}": true, "{key}": true}}}}}}"# + ))); + + assert!(err.contains(&format!("duplicate field `{key}`")), "{key}: {err}"); + } +} diff --git a/tests/parse/types/refs.rs b/tests/parse/types/refs.rs index a00c96c9c..c736bf622 100644 --- a/tests/parse/types/refs.rs +++ b/tests/parse/types/refs.rs @@ -65,7 +65,7 @@ fn annotations_next_to_a_ref_keep_the_model_in_3_1() { let forge = parse( r##"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { "$ref": "#/components/schemas/Zone", "description": "The zone.", "deprecated": true }}}}}}}}, @@ -81,7 +81,7 @@ fn shape_next_to_a_ref_is_intersected_in_3_1() { let forge = parse( r##"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { "$ref": "#/components/schemas/Zone", "properties": {"extra": {"type": "string"}} }}}}}}}}, @@ -97,7 +97,7 @@ fn nullable_next_to_a_ref_is_a_union_with_null_in_3_1() { let forge = parse( r##"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { "$ref": "#/components/schemas/Zone", "nullable": true }}}}}}}}, @@ -153,7 +153,7 @@ fn description_next_to_a_property_ref_is_its_own_in_3_1() { let forge = parse( r##"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { "type": "object", "properties": {"home": {"$ref": "#/components/schemas/Zone", "description": "The home zone."}} }}}}}}}}, @@ -324,7 +324,7 @@ fn read_only_next_to_a_property_ref_counts_in_3_1() { let forge = parse( r##"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { "type": "object", "properties": {"id": {"$ref": "#/components/schemas/Id", "readOnly": true}} }}}}}}}}, @@ -350,7 +350,7 @@ fn read_only_false_next_to_a_ref_does_not_undo_its_schemas_in_3_1() { let forge = parse( r##"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { "type": "object", "properties": {"id": {"$ref": "#/components/schemas/Id", "readOnly": false}} }}}}}}}}, @@ -365,7 +365,7 @@ fn read_only_false_next_to_a_ref_does_not_undo_its_schemas_in_3_1() { fn property_that_is_a_ref_is_sensitive_when_its_schema_is() { let forge = parse(&responding_with_schemas( r##"{"type": "object", "properties": {"token": {"$ref": "#/components/schemas/Token"}}}"##, - r##"{"Token": {"$ref": "#/components/schemas/Secret"}, "Secret": {"type": "string", "x-sensitive": true}}"##, + r##"{"Token": {"$ref": "#/components/schemas/Secret"}, "Secret": {"type": "string", "x-forge-sensitive": true}}"##, )); assert!(field(object(response_type(&forge, "a.get")), "token").is_sensitive()); @@ -374,7 +374,7 @@ fn property_that_is_a_ref_is_sensitive_when_its_schema_is() { #[test] fn sensitive_next_to_a_property_ref_counts_in_3_0() { let forge = parse(&responding_with_schemas( - r##"{"type": "object", "properties": {"token": {"$ref": "#/components/schemas/Token", "x-sensitive": true}}}"##, + r##"{"type": "object", "properties": {"token": {"$ref": "#/components/schemas/Token", "x-forge-sensitive": true}}}"##, r#"{"Token": {"type": "string"}}"#, )); @@ -386,9 +386,9 @@ fn sensitive_next_to_a_property_ref_counts_in_3_1() { let forge = parse( r##"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { - "type": "object", "properties": {"token": {"$ref": "#/components/schemas/Token", "x-sensitive": true}} + "type": "object", "properties": {"token": {"$ref": "#/components/schemas/Token", "x-forge-sensitive": true}} }}}}}}}}, "components": {"schemas": {"Token": {"type": "string"}}} }"##, @@ -424,7 +424,7 @@ fn property_default_is_the_first_along_a_chain_of_refs_in_3_1() { let forge = parse( r##"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { "type": "object", "properties": {"size": {"$ref": "#/components/schemas/Size"}} }}}}}}}}, @@ -454,7 +454,7 @@ fn default_next_to_a_property_ref_is_its_own_in_3_1() { let forge = parse( r##"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { "type": "object", "properties": { "size": {"$ref": "#/components/schemas/Size", "default": 5}, diff --git a/tests/parse/types/scalars.rs b/tests/parse/types/scalars.rs index 21e442f53..65b9e455c 100644 --- a/tests/parse/types/scalars.rs +++ b/tests/parse/types/scalars.rs @@ -140,7 +140,7 @@ fn base64_content_encoding_is_bytes() { let forge = parse( r#"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { "type": "string", "contentEncoding": "base64" }}}}}}}} @@ -155,7 +155,7 @@ fn binary_format_is_binary_even_with_base64_content_encoding() { let forge = parse( r#"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { "type": "string", "format": "binary", "contentEncoding": "base64" }}}}}}}} @@ -198,7 +198,7 @@ fn true_schema_is_unknown() { let forge = parse( r#"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": true}}}}}}} }"#, ); diff --git a/tests/parse/types/unions.rs b/tests/parse/types/unions.rs index 40df4abf4..f8349fe71 100644 --- a/tests/parse/types/unions.rs +++ b/tests/parse/types/unions.rs @@ -76,7 +76,7 @@ fn type_array_is_exclusive() { let forge = parse( r#"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { "type": ["string", "integer"] }}}}}}}} @@ -138,7 +138,7 @@ fn types_in_a_type_array_are_a_union() { let forge = parse( r#"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { "type": ["string", "integer"] }}}}}}}} @@ -291,7 +291,7 @@ fn discriminator_of_members_written_in_place_uses_the_one_value_of_an_enum() { } #[test] -fn discriminator_is_named_by_x_fern_property_name() { +fn discriminator_is_named_by_x_forge_property_name() { let forge = parse(&responding_with_schemas( r##"{ "oneOf": [{"$ref": "#/components/schemas/A"}, {"$ref": "#/components/schemas/B"}], @@ -329,7 +329,7 @@ fn discriminator_default_mapping_is_the_default() { let forge = parse( r##"{ "openapi": "3.2.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", + "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { "oneOf": [{"$ref": "#/components/schemas/A"}, {"$ref": "#/components/schemas/B"}], "discriminator": {"propertyName": "kind", "mapping": {"a": "#/components/schemas/A"}, "defaultMapping": "#/components/schemas/B"} diff --git a/tests/wasm.rs b/tests/wasm.rs index 2e733443b..95b4730cd 100644 --- a/tests/wasm.rs +++ b/tests/wasm.rs @@ -41,7 +41,7 @@ const SPEC: &str = r##"{ "paths": { "/pets": { "get": { - "x-forge-group": ["pets"], "x-forge-method": "list", "summary": "Lists pets.", + "x-forge-group": ["pets"], "x-forge-method-name": "list", "summary": "Lists pets.", "parameters": [ {"name": "limit", "in": "query", "schema": {"type": "integer", "format": "int32"}}, {"name": "tags", "in": "query", "explode": false, "schema": {"type": "array", "items": {"type": "string"}}} @@ -52,14 +52,14 @@ const SPEC: &str = r##"{ } }, "post": { - "x-forge-group": ["pets"], "x-forge-method": "create", "deprecated": true, + "x-forge-group": ["pets"], "x-forge-method-name": "create", "deprecated": true, "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/NewPet"}}}}, "responses": {"201": {"description": "created", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Pet"}}}}} } }, "/pets/{pet_id}": { "get": { - "x-forge-group": ["pets"], "x-forge-method": "get", + "x-forge-group": ["pets"], "x-forge-method-name": "get", "parameters": [{"name": "pet_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}}], "responses": { "200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Pet"}}}}, @@ -69,7 +69,7 @@ const SPEC: &str = r##"{ }, "/pets/{pet_id}/photos": { "put": { - "x-forge-group": ["pets", "photos"], "x-forge-method": "upload", + "x-forge-group": ["pets", "photos"], "x-forge-method-name": "upload", "parameters": [{"name": "pet_id", "in": "path", "required": true, "schema": {"type": "string"}}], "requestBody": {"content": {"application/octet-stream": {"schema": {"type": "string", "format": "binary"}}}}, "responses": {"204": {"description": "done"}} @@ -77,7 +77,7 @@ const SPEC: &str = r##"{ }, "/owners": { "get": { - "x-forge-group": ["owners"], "x-forge-method": "list", + "x-forge-group": ["owners"], "x-forge-method-name": "list", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { "type": "object", "properties": { @@ -138,7 +138,7 @@ const FEATURES: &str = r##"{ "/pets/{id}": { "parameters": [{"name": "X-Trace", "in": "header", "schema": {"type": "string"}}], "get": { - "x-forge-group": ["pets"], "x-forge-method": "get", + "x-forge-group": ["pets"], "x-forge-method-name": "get", "x-forge-availability": "beta", "x-forge-hidden": true, "x-custom": {"b": 1, "a": [1.5, "two \"quoted\"\n", null, true, {}], "b": 2}, "x-flag": false, "x-count": 9007199254740993, "x-text": "é 😀", @@ -146,15 +146,15 @@ const FEATURES: &str = r##"{ {"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}, {"name": "limit", "in": "query", "schema": {"type": "integer", "default": 10}}, {"name": "x-trace", "in": "header", "required": true, "description": "Traces the call.", "schema": {"type": "string"}}, - {"name": "Authorization", "in": "header", "schema": {"type": "string", "x-sensitive": true}} + {"name": "Authorization", "in": "header", "schema": {"type": "string", "x-forge-sensitive": true}} ], "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Pet"}}}}} }, "delete": { "x-forge-aliases": [ - {"x-forge-group": ["pets"], "x-forge-method": "remove", "x-forge-availability": "deprecated", + {"x-forge-group": ["pets"], "x-forge-method-name": "remove", "x-forge-availability": "deprecated", "x-forge-require-confirmation": "This deletes the pet.", "x-alias": [1]}, - {"x-forge-group": ["admin", "pets"], "x-forge-method": "delete", "x-forge-require-confirmation": ""} + {"x-forge-group": ["admin", "pets"], "x-forge-method-name": "delete", "x-forge-require-confirmation": ""} ], "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}], "responses": {"204": {"description": "gone"}} @@ -162,7 +162,7 @@ const FEATURES: &str = r##"{ }, "/pets/{id}/photo": { "put": { - "x-forge-group": ["pets", "photos"], "x-forge-method": "upload", + "x-forge-group": ["pets", "photos"], "x-forge-method-name": "upload", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}], "requestBody": {"required": true, "content": { "application/octet-stream": {"schema": {"type": "string", "format": "binary", "description": "The photo."}}, @@ -178,7 +178,7 @@ const FEATURES: &str = r##"{ "components": {"schemas": { "Pet": {"type": "object", "properties": { "id": {"type": "string", "readOnly": true}, - "password": {"type": "string", "x-sensitive": true}, + "password": {"type": "string", "x-forge-sensitive": true}, "tags": {"type": "array", "items": {"type": "string"}, "minItems": 0, "maxItems": 3}, "dataset_scope": {"oneOf": [ {"type": "object", "properties": {"type": {"const": "default"}}, "required": ["type"], "additionalProperties": false}, From afdad0f8efce70e683988f5ef7ef7f1532a3cf62 Mon Sep 17 00:00:00 2001 From: Niels Swimberghe Date: Mon, 26 Oct 2026 12:29:11 -0400 Subject: [PATCH 11/25] docs: RFC 0001 takes x-forge-method-name and x-forge-sensitive --- rfcs/0001-metadata-resolution-layer.md | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/rfcs/0001-metadata-resolution-layer.md b/rfcs/0001-metadata-resolution-layer.md index c94a91d5a..e67994b98 100644 --- a/rfcs/0001-metadata-resolution-layer.md +++ b/rfcs/0001-metadata-resolution-layer.md @@ -67,26 +67,29 @@ Audit of the released aggregate (`openapi.forge.json`, 27.6 MB, sha `99544617… | Native key | Form | Meaning | Compat source (the adapter translates) | |---|---|---|---| -| `x-forge-method` | string | the method's name | `x-fern-sdk-method-name` | +| `x-forge-method-name` | string | the method's name | `x-fern-sdk-method-name` | | `x-forge-group` | **array of strings**, top level first | the group path | `x-fern-sdk-group-name` (dotted string is Fern's format; the split lives in the adapter. Native has no dot syntax) | | `x-forge-ignore` | bool, positional | operation/alias → no method; property schema → no field | `x-fern-ignore` (operation) · `x-stainless-skip` (property; a list of targets counts as `false` — inside the adapter) | | `x-forge-availability` | `alpha` \| `beta` \| `preview` \| `generally-available` \| `deprecated` | lifecycle stage. The values are Forge's shipped vocabulary (public enum across Rust, WIT, JS); Fern merely coincides | `x-fern-availability` | | `x-forge-model-name` | string | the model's name (components / `$defs` schema) | `x-fern-type-name` | | `x-forge-property-name` | string | the property's field name | `x-fern-property-name` | | `x-forge-parameter-name` | string | the parameter's field name | `x-fern-parameter-name` | -| `x-forge-hidden`, `x-forge-require-confirmation`, `x-forge-aliases`, `x-forge-commands`, `x-sensitive` | *(existing, unchanged)* | | — no vendor source; now owned by the native table | +| `x-forge-sensitive` | bool | the schema's value is a secret, to mask | — (replaces `x-sensitive`, below) | +| `x-sensitive` | bool | **deprecated** name of `x-forge-sensitive`, read only where `x-forge-sensitive` is not written | — (a native key, not a vendor one) | +| `x-forge-hidden`, `x-forge-require-confirmation`, `x-forge-aliases`, `x-forge-commands` | *(existing, unchanged)* | | — no vendor source; now owned by the native table | Design notes: - **Explicit position keys over one positional name key.** Fern used three name keys because its reader architecture had one visitor per OpenAPI object kind. We use explicit keys for a different, real reason: tools in the wild mangle `$ref` structure (inlining targets, dropping siblings), and when placement lies, the key name still carries the role — `x-forge-model-name` on a property schema can be ignored or flagged, where a positional `x-forge-name` would silently become a field name. Explicitness here is corruption-detection, not verbosity. - **One exclusion word, two positions.** "Excluded from generated surfaces" is one concept at two scopes; two words (`ignore` + `skip`) would be a memory tax for no disambiguation, and `skip` is Stainless's word. - **Arrays for groups.** JSON's native way to write a path. Accepting dotted strings natively would permanently import Fern's format into the native vocabulary; array-only now keeps the option to loosen later (the reverse — tightening later — would break specs). Fern's dotted strings keep working through the adapter for as long as Fern keys do. +- **Every native key begins with `x-forge-`.** The `-name` keys (`method-name`, `model-name`, `property-name`, `parameter-name`) say the same kind of thing and are spelled alike. `x-sensitive` predates the rule; it becomes `x-forge-sensitive`, and the old name stays readable as a deprecated alias (155 production uses), so no spec breaks. A deprecated alias is part of the native vocabulary, not of the compatibility layer: it keeps working with compat off, and deleting compat does not remove it. - **No misplacement validation in v1.** A known native key in a wrong position is silently ignored, exactly as Fern's keys are today. Adding validation later is a pure tightening that breaks no valid spec. ## Resolution rules -1. **Native applied, compat fallback** — per primitive, independently (a spec mid-migration may take the group from native and availability from Fern). -2. **Silent native-wins** — when both a native key and a compat key are present, the compat value is ignored without comparison machinery. Only the winning value is validated: an invalid compat `availability` does not error when a native one wins. A value of the wrong JSON type (a number where a string is expected) is still a document-shape error at read time, whichever key it is on and in whatever order the keys are written, so errors never depend on key order. Consequence, to be documented in the metadata docs: a stale compat key silently diverges, and editing the compat key when a native key is present appears to do nothing. +1. **Native applied, compat fallback** — per primitive, independently (a spec mid-migration may take the group from native and availability from Fern). Keys rank in three tiers: a native key under its current name, then a deprecated native alias (`x-sensitive`), then a compat key. The strongest tier present wins. +2. **Silent native-wins** — when keys of two tiers are present (a native key and a deprecated alias or compat key), the weaker value is ignored without comparison machinery. Only the winning value is validated: an invalid compat `availability` does not error when a native one wins. A value of the wrong JSON type (a number where a string is expected) is still a document-shape error at read time, whichever key it is on and in whatever order the keys are written, so errors never depend on key order. Consequence, to be documented in the metadata docs: a stale compat key silently diverges, and editing the compat key when a native key is present appears to do nothing. 3. **Same key twice** → error (today's `duplicate field` behavior, unchanged). 4. **Empty or punctuation-only values** fall back to the positional wire name, as today; validation errors name the key the author actually wrote (provenance), so existing Fern-keyed error expectations hold. 5. **Dual-writing** (native and compat keys carrying the same value) is the supported pattern for keeping specs portable across engine versions during the transition: old engines read the compat key, new engines apply native. @@ -97,9 +100,9 @@ Design notes: src/ ├─ metadata/ THE NATIVE LIBRARY — pure │ │ The x-forge-* table (per kind of object: definition, -│ │ parameter, schema, document), the primitives and format -│ │ descriptors, the availability vocabulary, the consumed-key -│ │ set. Imports neither compat nor link. No serde, no arena +│ │ parameter, schema, document), deprecated native aliases, +│ │ the primitives and format descriptors, the availability +│ │ vocabulary, the consumed-key set. Imports neither compat nor link. No serde, no arena │ │ coupling: its inputs are &str keys, its outputs small │ │ pure values. ├─ compat/ THE COMPATIBILITY LAYER — feature "compat" (default on) @@ -145,10 +148,10 @@ Properties, all deliberate: The implementation was checked against the unmodified engine on the released aggregate (`openapi.forge.json`, 27.6 MB, sha `99544617…`): - **Fern-keyed behavior.** The original, untouched suite — 379 parse tests written with Fern keys — passed against the new engine before any fixture was migrated. -- **Generated output.** `forge --target ts` on the aggregate writes the same 3,381 files, byte for byte (`diff -r`). +- **Generated output.** `forge --target ts` on the aggregate writes the same 3,381 files, byte for byte (`diff -r`). The aggregate's 155 `x-sensitive` keys are read through the deprecated alias; the dump's 210 sensitive fields are unchanged. - **The model.** The full model dump (the repo's own `tests/wasm/dump.rs`: every group, method, availability, hidden flag, confirmation, extension and field name) is identical between the old and new engines: 197,702 lines, covering 3,021 availability values, 1,259 hidden flags, 217 confirmations and 4,695 extension entries. The generated TypeScript consumes none of those four, which is why the dump, not the output diff, is what proves them. - **Across the plugin boundary.** `tests/wasm.rs`, including the ignored `bench` on the aggregate, reports identical output and dump, native versus WASM, for 1,317 groups and 3,381 files; `wit_is_current` holds with the regenerated WIT. -- **Independence.** `cargo test --no-default-features --features cli` (compat off) passes all 439 tests of the native suite; with compat on, 489 pass. Clippy is clean in the library-alone, compat-off and all-features configurations. +- **Independence.** `cargo test --no-default-features --features cli` (compat off) passes all 445 tests of the native suite, including the deprecated `x-sensitive` alias; with compat on, 495 pass. Clippy is clean in the library-alone, compat-off and all-features configurations. - **The checks can fail.** Breaking the native-wins rule fails 8 compat tests; planting a vendor name in a doc comment fails the boundary test, naming the file and line. - **Performance.** Measured with `benches/compare.sh` (`just bench-compare`): parse and link of the aggregate, paired against the unmodified engine, each pair run base–new–new–base so that drift in the machine's speed cancels, and pairs in which the machine changed speed left out. The working tree is 0–1% slower (about +0.45 ms of 41.8 ms, standard error 0.17 ms, on a machine at full speed; −0.04 ms, standard error 0.28, on the same machine throttled). Peak memory is +0.55%, with the same peak allocation count. Parsing is under a tenth of a whole `forge` run on the aggregate (about 0.5 s), so the end-to-end change is under 0.1%. - *Where it went.* Profiling (samply, with line tables, attributing inlined frames) found no hot spot of the layer's own: most of the difference is LLVM inlining parts of serde_json's key loop into `SchemaObject::read_object` and laying the code out differently, which moves with any change. Three things were measured and kept: each table turns away keys outside its prefix (`x-forge-`, `x-fern-`, `x-stainless-`) before matching, and only `x-` keys are asked about, so a skipped key costs one compare; and a `Hit` carries only its field and slot, the key's name looked up by slot when an error needs it, so the reader's per-key result fits in registers (16 bytes, as before, not 24). @@ -156,7 +159,7 @@ The implementation was checked against the unmodified engine on the released agg ## Release shape -Non-breaking. The WIT changes are doc comments only — `forge:plugin@0.3.0` stays, bindings regenerate identically, no plugin rebuilds anywhere; npm gets an additive release (new native keys accepted, compat module added); the spec surface grows append-only; generated output is byte-identical. Vocabulary renames that *are* breaking (group→namespace, the availability word) are deliberately deferred to FORGE-3 so one breaking release absorbs all of them; the versioning policy that governs this is FORGE-4's RFC. +Non-breaking. `x-sensitive` keeps working, deprecated in favor of `x-forge-sensitive`; when it stops being read is FORGE-4's policy question. The WIT changes are doc comments only — `forge:plugin@0.3.0` stays, bindings regenerate identically, no plugin rebuilds anywhere; npm gets an additive release (new native keys accepted, compat module added); the spec surface grows append-only; generated output is byte-identical. Vocabulary renames that *are* breaking (group→namespace, the availability word) are deliberately deferred to FORGE-3 so one breaking release absorbs all of them; the versioning policy that governs this is FORGE-4's RFC. ## Alternatives considered @@ -173,7 +176,7 @@ Non-breaking. The WIT changes are doc comments only — `forge:plugin@0.3.0` sta | Ticket | What | |---|---| -| FORGE-3 | Vocabulary renames in one breaking release: `group` → `namespace`, plus the availability word review; `x-forge-group`/`x-forge-availability` become permanent alias keys (one table row each). | +| FORGE-3 | Vocabulary renames in one breaking release: `group` → `namespace`, plus the availability word review; `x-forge-group`/`x-forge-availability` become deprecated aliases (one table row each, the same tier `x-sensitive` uses). | | FORGE-4 | The versioning RFC: the four contracts, the rules this RFC assumes, and open questions (1.0 timing, warning channel, vendor-key EoL policy). | | FORGE-5 | Informational Forge CI lint flagging vendor keys the engine doesn't read, sourced from this layer's key table. | | (FORGE-1 follow-ups) | cf codebase Fern→Forge rename; dotted `operationId` derivation behind an opt-in flag. | From 7ee6854118a3d743e86c62d10a6f18c799316a70 Mon Sep 17 00:00:00 2001 From: Niels Swimberghe Date: Mon, 26 Oct 2026 12:33:20 -0400 Subject: [PATCH 12/25] docs: correct RFC 0001 on empty values, the npm release and a stray decision number - Rule 4 now says per key what an empty or punctuation-only value does: names fall back to the wire name; an empty method name or group path is no method, and a punctuation-only one is an error. - The npm release adds no compat module: the engine binary it ships is built with the default compat feature. - "the D4 rule" is resolution rule 2; the D-numbers were never defined. --- rfcs/0001-metadata-resolution-layer.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/rfcs/0001-metadata-resolution-layer.md b/rfcs/0001-metadata-resolution-layer.md index e67994b98..79e463098 100644 --- a/rfcs/0001-metadata-resolution-layer.md +++ b/rfcs/0001-metadata-resolution-layer.md @@ -91,7 +91,10 @@ Design notes: 1. **Native applied, compat fallback** — per primitive, independently (a spec mid-migration may take the group from native and availability from Fern). Keys rank in three tiers: a native key under its current name, then a deprecated native alias (`x-sensitive`), then a compat key. The strongest tier present wins. 2. **Silent native-wins** — when keys of two tiers are present (a native key and a deprecated alias or compat key), the weaker value is ignored without comparison machinery. Only the winning value is validated: an invalid compat `availability` does not error when a native one wins. A value of the wrong JSON type (a number where a string is expected) is still a document-shape error at read time, whichever key it is on and in whatever order the keys are written, so errors never depend on key order. Consequence, to be documented in the metadata docs: a stale compat key silently diverges, and editing the compat key when a native key is present appears to do nothing. 3. **Same key twice** → error (today's `duplicate field` behavior, unchanged). -4. **Empty or punctuation-only values** fall back to the positional wire name, as today; validation errors name the key the author actually wrote (provenance), so existing Fern-keyed error expectations hold. +4. **Empty or punctuation-only values** behave as today, which depends on the key: + - `x-forge-property-name`, `x-forge-parameter-name` and `x-forge-model-name`: a value with no letters or digits, empty included, is ignored, and the field or model is named after its wire name (the property, parameter or schema name). + - `x-forge-method-name` and `x-forge-group`: an empty method name, or an empty group path (`[]`, or Fern's `""`), means the operation or alias is not a method. A method name, or any group name in the path, that is not empty but has no letters or digits is an error. + - Validation errors name the key the author actually wrote (provenance), so existing Fern-keyed error expectations hold. 5. **Dual-writing** (native and compat keys carrying the same value) is the supported pattern for keeping specs portable across engine versions during the transition: old engines read the compat key, new engines apply native. ## Architecture @@ -123,7 +126,7 @@ src/ Properties, all deliberate: -- **Dependencies**: `link → metadata` and `link → compat` (behind the feature), and `compat → metadata` (an adapter produces metadata's primitives). `metadata` imports neither of the others, so compat sits *beside* the native table rather than under it. `link/resolve.rs` asks native first, compat second — that fallback arm *is* the D4 rule. +- **Dependencies**: `link → metadata` and `link → compat` (behind the feature), and `compat → metadata` (an adapter produces metadata's primitives). `metadata` imports neither of the others, so compat sits *beside* the native table rather than under it. `link/resolve.rs` asks native first, compat second — that fallback arm *is* resolution rule 2. - **Purity**: the library's contract is `&str` keys in and small values out: primitives, key names, and *format descriptors as data* (for example "a string splits on `.`" or "a list of targets counts as `false`"). The serde reading and the arena writes stay in `link` and are generic, driven by those descriptors. This is what makes the library pluggable, standalone-consumable (a future `forge-ci validate` needs the key table, not the engine), and mechanically liftable into a real separate crate when the first standalone consumer appears (FORGE-5's work). - **The availability vocabulary is native.** The five values live in the native table (they are shipped public API); `fern.rs` only maps `x-fern-availability` onto that primitive. - **Deletion is the designed end state.** `cargo build --no-default-features --features cli` proves the native vocabulary stands alone; when vendor keys reach end-of-life, deleting `compat/` and the cfg arms in `link/resolve.rs` (one for each kind of object) touches nothing else — no `x-forge-*` extension changes, in behavior, docs, or WIT. @@ -159,7 +162,7 @@ The implementation was checked against the unmodified engine on the released agg ## Release shape -Non-breaking. `x-sensitive` keeps working, deprecated in favor of `x-forge-sensitive`; when it stops being read is FORGE-4's policy question. The WIT changes are doc comments only — `forge:plugin@0.3.0` stays, bindings regenerate identically, no plugin rebuilds anywhere; npm gets an additive release (new native keys accepted, compat module added); the spec surface grows append-only; generated output is byte-identical. Vocabulary renames that *are* breaking (group→namespace, the availability word) are deliberately deferred to FORGE-3 so one breaking release absorbs all of them; the versioning policy that governs this is FORGE-4's RFC. +Non-breaking. `x-sensitive` keeps working, deprecated in favor of `x-forge-sensitive`; when it stops being read is FORGE-4's policy question. The WIT changes are doc comments only — `forge:plugin@0.3.0` stays, bindings regenerate identically, no plugin rebuilds anywhere; npm gets an additive release: the engine binary it ships accepts the new native keys and, built with the default `compat` feature, still reads the vendor keys, and the JS runtime changes only in doc comments; the spec surface grows append-only; generated output is byte-identical. Vocabulary renames that *are* breaking (group→namespace, the availability word) are deliberately deferred to FORGE-3 so one breaking release absorbs all of them; the versioning policy that governs this is FORGE-4's RFC. ## Alternatives considered From 17af41c351395cd24b8cd9c6579d45b8411b6660 Mon Sep 17 00:00:00 2001 From: Niels Swimberghe Date: Mon, 26 Oct 2026 12:36:25 -0400 Subject: [PATCH 13/25] docs: record in RFC 0001 that an empty group path should be the root Today x-forge-group: [] makes no method. The intended meaning is a method at the root, which needs root methods in the model, the WIT and the generators, so it is documented as a known gap rather than fixed here. --- rfcs/0001-metadata-resolution-layer.md | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/rfcs/0001-metadata-resolution-layer.md b/rfcs/0001-metadata-resolution-layer.md index 79e463098..a6e1126c4 100644 --- a/rfcs/0001-metadata-resolution-layer.md +++ b/rfcs/0001-metadata-resolution-layer.md @@ -68,7 +68,7 @@ Audit of the released aggregate (`openapi.forge.json`, 27.6 MB, sha `99544617… | Native key | Form | Meaning | Compat source (the adapter translates) | |---|---|---|---| | `x-forge-method-name` | string | the method's name | `x-fern-sdk-method-name` | -| `x-forge-group` | **array of strings**, top level first | the group path | `x-fern-sdk-group-name` (dotted string is Fern's format; the split lives in the adapter. Native has no dot syntax) | +| `x-forge-group` | **array of strings**, top level first | the group path; `[]` is meant to be the root, but is not yet (see [Known gaps](#known-gaps)) | `x-fern-sdk-group-name` (dotted string is Fern's format; the split lives in the adapter. Native has no dot syntax) | | `x-forge-ignore` | bool, positional | operation/alias → no method; property schema → no field | `x-fern-ignore` (operation) · `x-stainless-skip` (property; a list of targets counts as `false` — inside the adapter) | | `x-forge-availability` | `alpha` \| `beta` \| `preview` \| `generally-available` \| `deprecated` | lifecycle stage. The values are Forge's shipped vocabulary (public enum across Rust, WIT, JS); Fern merely coincides | `x-fern-availability` | | `x-forge-model-name` | string | the model's name (components / `$defs` schema) | `x-fern-type-name` | @@ -93,10 +93,20 @@ Design notes: 3. **Same key twice** → error (today's `duplicate field` behavior, unchanged). 4. **Empty or punctuation-only values** behave as today, which depends on the key: - `x-forge-property-name`, `x-forge-parameter-name` and `x-forge-model-name`: a value with no letters or digits, empty included, is ignored, and the field or model is named after its wire name (the property, parameter or schema name). - - `x-forge-method-name` and `x-forge-group`: an empty method name, or an empty group path (`[]`, or Fern's `""`), means the operation or alias is not a method. A method name, or any group name in the path, that is not empty but has no letters or digits is an error. + - `x-forge-method-name` and `x-forge-group`: an empty method name, or an empty group path (`[]`, or Fern's `""`), means the operation or alias is not a method. For `[]` this is the current behavior, not the intended one: see [Known gaps](#known-gaps). A method name, or any group name in the path, that is not empty but has no letters or digits is an error. - Validation errors name the key the author actually wrote (provenance), so existing Fern-keyed error expectations hold. 5. **Dual-writing** (native and compat keys carrying the same value) is the supported pattern for keeping specs portable across engine versions during the transition: old engines read the compat key, new engines apply native. +## Known gaps + +### An empty group path + +- **Intended:** `x-forge-group: []` attaches the method to the root, in no group. An operation without `x-forge-group` at all is still not a method. +- **Today:** `[]` makes no method, exactly like a missing key. `add_method` in `src/link.rs` returns before building the method when the path is empty; `Method::group`'s docs in `src/lib.rs` say so; and `tests/parse/groups.rs::empty_group_name_is_no_group` and the empty-group alias case in `tests/parse/aliases.rs` assert it. +- **Why it is not fixed here:** the model has no place for a root method. Methods exist only inside groups (`Forge::groups` → `Group::methods`); `Forge` has top-level models but no top-level methods, and neither does the WIT. The fix needs a root method list in the model, a way for plugins to read it (a new WIT function is additive; a new field in an existing record is not), and generators that render root methods. That is beyond FORGE-1, which leaves the model unchanged. +- **Impact:** none today. The released aggregate has no empty group path and no method name without a group, so the fix will not change its output. +- **To decide with the fix:** whether Fern's empty string (`x-fern-sdk-group-name: ""`) should also mean the root, or keep meaning "not a method" in the compatibility layer. + ## Architecture ``` From dc80bba4a40fb19661f2abdf2d2b436aa761a95c Mon Sep 17 00:00:00 2001 From: Niels Swimberghe Date: Mon, 26 Oct 2026 12:58:52 -0400 Subject: [PATCH 14/25] test: native keys win inside an alias, and only the winning names are checked - In an alias, the native method name, group, availability and ignore win over Fern's, in either order. - A Fern group path or method name that is an error on its own is not checked when a native key wins. Breaking the precedence rule fails all three. --- tests/compat/native_first.rs | 84 ++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/tests/compat/native_first.rs b/tests/compat/native_first.rs index f26bde3f2..368968940 100644 --- a/tests/compat/native_first.rs +++ b/tests/compat/native_first.rs @@ -198,3 +198,87 @@ fn an_error_names_the_native_key_when_the_native_key_gave_the_value() { assert!(err.contains(r#"GET /a: x-forge-availability "ga" is not"#), "{err}"); } + +#[test] +fn only_the_winning_group_and_method_name_are_checked() { + // Each compat value is an error on its own, so the document parsing at + // all shows it was not checked. + native_wins( + r#""x-forge-group": ["a"], "x-forge-method-name": "get","#, + r#""x-fern-sdk-group-name": "a..b","#, + r#""x-forge-group": ["a"], "x-forge-method-name": "get","#, + ); + native_wins( + r#""x-forge-group": ["a"], "x-forge-method-name": "get","#, + r#""x-fern-sdk-method-name": "-","#, + r#""x-forge-group": ["a"], "x-forge-method-name": "get","#, + ); + assert!( + parse_err(&operation_with( + r#""x-fern-sdk-group-name": "a..b", "x-forge-method-name": "get","# + )) + .contains("x-fern-sdk-group-name") + ); + assert!( + parse_err(&operation_with( + r#""x-forge-group": ["a"], "x-fern-sdk-method-name": "-","# + )) + .contains("x-fern-sdk-method-name") + ); +} + +/// Asserts that every ordering of `native` and `compat`, which are members of +/// an alias, builds what an alias of `expected` alone does, and that it has a +/// method named `method`. +#[track_caller] +fn native_wins_in_an_alias(native: &str, compat: &str, expected: &str, method: &str) { + let alias = |keys: &str| operation_with(&format!(r#""x-forge-aliases": [{{{keys}}}],"#)); + for keys in [format!("{native}, {compat}"), format!("{compat}, {native}")] { + assert_same(&alias(&keys), &alias(expected)); + } + let described = describe(&parse(&alias(expected))); + assert!(described.contains(&format!("method {method} ")), "{described}"); +} + +#[test] +fn the_native_keys_of_an_alias_win() { + native_wins_in_an_alias( + r#""x-forge-group": ["a"], "x-forge-method-name": "native""#, + r#""x-fern-sdk-method-name": "vendor""#, + r#""x-forge-group": ["a"], "x-forge-method-name": "native""#, + "native", + ); + native_wins_in_an_alias( + r#""x-forge-method-name": "get", "x-forge-group": ["native"]"#, + r#""x-fern-sdk-group-name": "vendor.group""#, + r#""x-forge-method-name": "get", "x-forge-group": ["native"]"#, + "get", + ); + native_wins_in_an_alias( + r#""x-forge-group": ["a"], "x-forge-method-name": "get", "x-forge-availability": "beta""#, + r#""x-fern-availability": "ga""#, + r#""x-forge-group": ["a"], "x-forge-method-name": "get", "x-forge-availability": "beta""#, + "get", + ); + native_wins_in_an_alias( + r#""x-forge-group": ["a"], "x-forge-method-name": "get", "x-forge-ignore": false"#, + r#""x-fern-ignore": true"#, + r#""x-forge-group": ["a"], "x-forge-method-name": "get""#, + "get", + ); +} + +#[test] +fn a_native_ignore_in_an_alias_drops_the_method_a_compat_one_would_keep() { + let alias = |keys: &str| { + operation_with(&format!( + r#""x-forge-aliases": [{{"x-forge-group": ["a"], "x-forge-method-name": "get", {keys}}}],"# + )) + }; + for keys in [ + r#""x-forge-ignore": true, "x-fern-ignore": false"#, + r#""x-fern-ignore": false, "x-forge-ignore": true"#, + ] { + assert_eq!(parse(&alias(keys)).groups().count(), 0, "{keys}"); + } +} From 1c1d5ea81f75fab22872e377169abe0e5186f8cb Mon Sep 17 00:00:00 2001 From: Niels Swimberghe Date: Mon, 26 Oct 2026 13:16:15 -0400 Subject: [PATCH 15/25] docs: correct RFC 0001 where it claimed more than the code does - The boundary goal covers the engine; the docs packages read vendor keys themselves and are a non-goal. - resolve.rs is the only place link calls compat, not the only cfg point. - Deleting the layer lists every step, and what it leaves behind. - With compat off, vendor keys become ordinary extensions for plugins. - The name lookups are a second way vendor key names leave compat, as data, for errors. - Test counts, the mutation result, and performance and memory are re-measured on the final code. --- rfcs/0001-metadata-resolution-layer.md | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/rfcs/0001-metadata-resolution-layer.md b/rfcs/0001-metadata-resolution-layer.md index a6e1126c4..9508b2c4a 100644 --- a/rfcs/0001-metadata-resolution-layer.md +++ b/rfcs/0001-metadata-resolution-layer.md @@ -44,7 +44,7 @@ Audit of the released aggregate (`openapi.forge.json`, 27.6 MB, sha `99544617… ## Goals -- Nothing outside the resolution layer references `x-fern-*` or `x-stainless-*` — enforced mechanically. +- Nothing in the engine outside the resolution layer references `x-fern-*` or `x-stainless-*` — enforced mechanically. The engine is `src/`, `tests/` and the `packages/forge` npm package; the docs packages in this repository (`astro-fern`, `fern-forge`, `cloudflare-api-site`) read vendor keys themselves and are outside this RFC (see Non-goals). - Output for existing specs is unchanged, proven by byte-equality tests on the real aggregate. - Tests cover each supported primitive, from each accepted key. - No meaningful performance regression; parse throughput is benchmarked before and after. @@ -61,7 +61,7 @@ Audit of the released aggregate (`openapi.forge.json`, 27.6 MB, sha `99544617… - Dotted `operationId` derivation (`a.b.c.d` → group `[a,b,c]` + method `d`) — a follow-up that will land *in this layer* behind an opt-in flag. - Audiences. The ticket lists audiences as a lower-priority primitive; this RFC deliberately leaves `x-fern-audiences` unread (it needs a visibility concept Forge has not designed), flagged by FORGE-5. This is a scope deviation from the ticket text and needs the reviewers' agreement. - New primitives for `x-fern-enum` (enum-value names — needs a model primitive that does not exist yet), `x-stability`, `x-forge-sunset`, `x-stainless-deprecation-message`. These are flagged (FORGE-5) and stay unread; each would be a one-row additive addition later. -- Treating the `fern-forge` docs package's own `x-fern-*` reading (a downstream consumer, like cf) — follow-up. +- The docs packages' own `x-fern-*` reading (`fern-forge`, `astro-fern`, `cloudflare-api-site`: downstream consumers, like cf) — follow-up. ## The native vocabulary @@ -120,7 +120,8 @@ src/ │ │ pure values. ├─ compat/ THE COMPATIBILITY LAYER — feature "compat" (default on) │ ├─ mod.rs the aggregator: one resolver per kind of object, trying -│ │ each vendor in turn. The only thing link imports from here. +│ │ each vendor in turn, and one name lookup per kind, for +│ │ errors. The only things link imports from here. │ │ The designed end state: delete this folder + the feature. │ ├─ fern.rs every x-fern-* literal, mapped onto metadata's primitives; │ │ Fern's value formats, as data (e.g. "a string splits on @@ -129,7 +130,9 @@ src/ │ (a list of targets counts as false); unit tests. └─ link/ ├─ resolve.rs the orchestrator: native first, then compat behind - │ #[cfg(feature = "compat")] — the only cfg point. + │ #[cfg(feature = "compat")] — the only place link calls + │ compat. (The feature's other cfg points: `mod compat` in + │ lib.rs, and the compat-only items in metadata.) ├─ openapi.rs OpenAPI's own keys only; every x- key asks resolve.rs. └─ read.rs serde helpers, as today. ``` @@ -139,12 +142,14 @@ Properties, all deliberate: - **Dependencies**: `link → metadata` and `link → compat` (behind the feature), and `compat → metadata` (an adapter produces metadata's primitives). `metadata` imports neither of the others, so compat sits *beside* the native table rather than under it. `link/resolve.rs` asks native first, compat second — that fallback arm *is* resolution rule 2. - **Purity**: the library's contract is `&str` keys in and small values out: primitives, key names, and *format descriptors as data* (for example "a string splits on `.`" or "a list of targets counts as `false`"). The serde reading and the arena writes stay in `link` and are generic, driven by those descriptors. This is what makes the library pluggable, standalone-consumable (a future `forge-ci validate` needs the key table, not the engine), and mechanically liftable into a real separate crate when the first standalone consumer appears (FORGE-5's work). - **The availability vocabulary is native.** The five values live in the native table (they are shipped public API); `fern.rs` only maps `x-fern-availability` onto that primitive. -- **Deletion is the designed end state.** `cargo build --no-default-features --features cli` proves the native vocabulary stands alone; when vendor keys reach end-of-life, deleting `compat/` and the cfg arms in `link/resolve.rs` (one for each kind of object) touches nothing else — no `x-forge-*` extension changes, in behavior, docs, or WIT. +- **Deletion is the designed end state.** `cargo build --no-default-features --features cli` proves the native vocabulary stands alone. When vendor keys reach end-of-life, deleting the layer means removing `src/compat/` and `tests/compat/`, `mod compat` in `src/lib.rs`, the `compat` cfg arms in `link/resolve.rs` (a resolver and a name lookup for each kind of object the layer reads: operations and aliases, parameters, schemas), and the `compat` feature and its `[[test]]` entry in `Cargo.toml`. No `x-forge-*` extension changes, in behavior, docs, or WIT. + - *What a deletion leaves behind.* Some native-side code exists only for the layer and compiles, unused, without it: `Hit::compat` and `Hit::after`, `Tier::Compat`, `COMPAT_SLOTS`, the value formats only vendors use (`GroupFormat::StringOrArray`, `BoolFormat::BoolOrTargets`) and their readers in `link/openapi.rs`, and the sentence in `Method::extensions`'s docs about the keys the compatibility layer reads. Removing them is a mechanical follow-on step, not a prerequisite. +- **With compat off, vendor keys are ordinary extensions.** Built without the feature, the engine does not drop `x-fern-*` and `x-stainless-*` keys: like any other `x-` key it does not read, an operation's vendor keys are kept and given to plugins by `Method::extensions`. Native behavior is the same either way; what plugins see in `extensions()` is not. - **Dispatch, not semantics**: the layer maps keys to primitives and normalizes simple values. Structural behavior — `x-forge-aliases` becoming methods, `x-forge-commands` describing groups — stays in the builder. ## Enforcement -- **Structural boundary — compiler-enforced (built-in Rust):** every vendor key literal is private to its adapter; the only thing outside `compat/` that can touch vendor knowledge is the aggregator's per-object resolvers, which `link/resolve.rs` calls. The feature gate makes compat removable, and builds without the `compat` feature keep proving it. +- **Structural boundary — compiler-enforced (built-in Rust):** every vendor key literal is private to its adapter; the only things outside `compat/` that can touch vendor knowledge are the aggregator's per-object resolvers and name lookups, which `link/resolve.rs` calls. The name lookups are how a vendor key's name reaches an error message (rule 4's provenance) without `link` holding the literal: it reaches `link` as data, never as code that matches on it. The feature gate makes compat removable, and builds without the `compat` feature keep proving it. - **Textual boundary — `tests/boundary.rs`:** scans `src/`, `tests/`, and `packages/forge/` (including the shipped `plugin/forge.wit`) for `x-fern` / `x-stainless` outside `src/compat/` and `tests/compat/`. Doc comments count — moving the WIT's "from `x-fern-availability`" attribution to `x-forge-availability` is part of the work. The test runs wherever `cargo test` runs; a `just boundary` alias targets it. - **Criterion mapping:** (1) nothing outside the layer references `x-fern-*` → the two mechanisms above; (2) output unchanged → zero model-shape changes, Fern-keyed specs resolve identically (equivalence tests + the full-aggregate native-vs-wasm byte-equality bench); (3) tests per primitive → below. @@ -164,9 +169,9 @@ The implementation was checked against the unmodified engine on the released agg - **Generated output.** `forge --target ts` on the aggregate writes the same 3,381 files, byte for byte (`diff -r`). The aggregate's 155 `x-sensitive` keys are read through the deprecated alias; the dump's 210 sensitive fields are unchanged. - **The model.** The full model dump (the repo's own `tests/wasm/dump.rs`: every group, method, availability, hidden flag, confirmation, extension and field name) is identical between the old and new engines: 197,702 lines, covering 3,021 availability values, 1,259 hidden flags, 217 confirmations and 4,695 extension entries. The generated TypeScript consumes none of those four, which is why the dump, not the output diff, is what proves them. - **Across the plugin boundary.** `tests/wasm.rs`, including the ignored `bench` on the aggregate, reports identical output and dump, native versus WASM, for 1,317 groups and 3,381 files; `wit_is_current` holds with the regenerated WIT. -- **Independence.** `cargo test --no-default-features --features cli` (compat off) passes all 445 tests of the native suite, including the deprecated `x-sensitive` alias; with compat on, 495 pass. Clippy is clean in the library-alone, compat-off and all-features configurations. -- **The checks can fail.** Breaking the native-wins rule fails 8 compat tests; planting a vendor name in a doc comment fails the boundary test, naming the file and line. -- **Performance.** Measured with `benches/compare.sh` (`just bench-compare`): parse and link of the aggregate, paired against the unmodified engine, each pair run base–new–new–base so that drift in the machine's speed cancels, and pairs in which the machine changed speed left out. The working tree is 0–1% slower (about +0.45 ms of 41.8 ms, standard error 0.17 ms, on a machine at full speed; −0.04 ms, standard error 0.28, on the same machine throttled). Peak memory is +0.55%, with the same peak allocation count. Parsing is under a tenth of a whole `forge` run on the aggregate (about 0.5 s), so the end-to-end change is under 0.1%. +- **Independence.** `cargo test --no-default-features --features cli` (compat off) passes all 445 tests of the native suite, including the deprecated `x-sensitive` alias; with compat on, 498 pass. Clippy is clean in the library-alone, compat-off and all-features configurations. +- **The checks can fail.** Breaking the precedence rule (keeping the last value written, whatever its tier) fails 11 compat tests, and 1 `tests/parse` test, the deprecated `x-sensitive` one, with compat on and off; planting a vendor name in a doc comment fails the boundary test, naming the file and line. +- **Performance.** Measured with `benches/compare.sh` (`just bench-compare`): parse and link of the aggregate, paired against the unmodified engine, each pair run base–new–new–base so that drift in the machine's speed cancels, and pairs in which the machine changed speed left out. On the final code (`4e31404`), over 12 pairs, it is 0.8% slower: +0.34 ms of 42.0 ms, standard error 0.18 ms (t = 1.9), slower in 9 of 12 pairs. Earlier runs during the work put it between −0.1% and +1.1%, depending on the machine's speed state. Peak memory is 68.80 MB against 68.75 MB (+0.07%), with the same peak allocation count. Parsing is under a tenth of a whole `forge` run on the aggregate (about 0.5 s), so the end-to-end change is under 0.1%. - *Where it went.* Profiling (samply, with line tables, attributing inlined frames) found no hot spot of the layer's own: most of the difference is LLVM inlining parts of serde_json's key loop into `SchemaObject::read_object` and laying the code out differently, which moves with any change. Three things were measured and kept: each table turns away keys outside its prefix (`x-forge-`, `x-fern-`, `x-stainless-`) before matching, and only `x-` keys are asked about, so a skipped key costs one compare; and a `Hit` carries only its field and slot, the key's name looked up by slot when an error needs it, so the reader's per-key result fits in registers (16 bytes, as before, not 24). - *Ruling things out.* Unpaired, sequential runs had suggested +1.5–1.9%; that was the machine warming up through the session, which the ABBA order removes. A single run of either binary differs from the next by about as much as the change measured here, so a smaller difference needs more pairs, not a better single run. From aed8392e662701c8d62700f1a1ed46377448d06c Mon Sep 17 00:00:00 2001 From: Niels Swimberghe Date: Mon, 26 Oct 2026 16:12:20 -0400 Subject: [PATCH 16/25] feat: x-forge-group accepts a dotted string, as Cloudflare's specs write groups --- rfcs/0001-metadata-resolution-layer.md | 26 ++++++------ src/compat/fern.rs | 18 +++++---- src/lib.rs | 11 +++--- src/link/openapi.rs | 35 ++++++---------- src/metadata/mod.rs | 19 +++------ tests/compat/fern.rs | 4 +- tests/compat/native_first.rs | 12 +++--- tests/parse/groups.rs | 55 +++++++++++++++++--------- 8 files changed, 93 insertions(+), 87 deletions(-) diff --git a/rfcs/0001-metadata-resolution-layer.md b/rfcs/0001-metadata-resolution-layer.md index 9508b2c4a..4176308f2 100644 --- a/rfcs/0001-metadata-resolution-layer.md +++ b/rfcs/0001-metadata-resolution-layer.md @@ -20,7 +20,7 @@ After the boundary, the engine — reader, builder, linked model, plugin host, n Today the engine couples to vendors in five places: 1. **Wire keys** — vendor names are hardcoded in the serde key enums (`DefinitionKey`, `ParameterKey::FieldName`, `SchemaKey::{ModelName, FieldName, Skip}` in `src/link/openapi.rs`). -2. **Vendor value formats** — Fern's dotted-or-array group strings, Fern's five-value availability vocabulary, Stainless's `x-stainless-skip` (where a list of target names means *false*). +2. **Vendor value formats** — Fern's five-value availability vocabulary, Stainless's `x-stainless-skip` (where a list of target names means *false*). 3. **Error strings** — validation messages name vendor keys ("`x-fern-availability "ga" is not …`"). 4. **The consumed-key set** — the seven names excluded from the plugin passthrough are hand-listed in the deserializers and re-documented in the model docs. 5. **Documentation** — the public model, the WIT, and the JS runtime attribute primitives to vendor keys; the WIT's doc comments ship in the npm package. @@ -68,7 +68,7 @@ Audit of the released aggregate (`openapi.forge.json`, 27.6 MB, sha `99544617… | Native key | Form | Meaning | Compat source (the adapter translates) | |---|---|---|---| | `x-forge-method-name` | string | the method's name | `x-fern-sdk-method-name` | -| `x-forge-group` | **array of strings**, top level first | the group path; `[]` is meant to be the root, but is not yet (see [Known gaps](#known-gaps)) | `x-fern-sdk-group-name` (dotted string is Fern's format; the split lives in the adapter. Native has no dot syntax) | +| `x-forge-group` | **string of names separated by dots**, or **array of strings**, top level first (array elements are not split) | the group path; `""` and `[]` are meant to be the root, but are not yet (see [Known gaps](#known-gaps)) | `x-fern-sdk-group-name` (read the same way as the native key) | | `x-forge-ignore` | bool, positional | operation/alias → no method; property schema → no field | `x-fern-ignore` (operation) · `x-stainless-skip` (property; a list of targets counts as `false` — inside the adapter) | | `x-forge-availability` | `alpha` \| `beta` \| `preview` \| `generally-available` \| `deprecated` | lifecycle stage. The values are Forge's shipped vocabulary (public enum across Rust, WIT, JS); Fern merely coincides | `x-fern-availability` | | `x-forge-model-name` | string | the model's name (components / `$defs` schema) | `x-fern-type-name` | @@ -82,7 +82,8 @@ Design notes: - **Explicit position keys over one positional name key.** Fern used three name keys because its reader architecture had one visitor per OpenAPI object kind. We use explicit keys for a different, real reason: tools in the wild mangle `$ref` structure (inlining targets, dropping siblings), and when placement lies, the key name still carries the role — `x-forge-model-name` on a property schema can be ignored or flagged, where a positional `x-forge-name` would silently become a field name. Explicitness here is corruption-detection, not verbosity. - **One exclusion word, two positions.** "Excluded from generated surfaces" is one concept at two scopes; two words (`ignore` + `skip`) would be a memory tax for no disambiguation, and `skip` is Stainless's word. -- **Arrays for groups.** JSON's native way to write a path. Accepting dotted strings natively would permanently import Fern's format into the native vocabulary; array-only now keeps the option to loosen later (the reverse — tightening later — would break specs). Fern's dotted strings keep working through the adapter for as long as Fern keys do. +- **Groups as a dotted string or an array.** The dotted string is how Cloudflare's specs write a group path today, and Forge CI's lint requires it, so moving a team to `x-forge-group` is a rename of the key, with the value unchanged. The array is JSON's own way to write a path, and the way to write a group name that has a dot in it. Both are read exactly as forge has always read groups. +- **The dotted string is Cloudflare's, not Fern's.** Fern reads a string `x-fern-sdk-group-name` as *one* group (`"a.b"` is a group named `a.b`) and an array as a path; it never splits on dots. The release pipeline converts Cloudflare's dotted strings to arrays before Fern sees them (the released aggregate has 3,591 arrays and no strings). Forge has always split them, and the adapter reads the Fern key the same way as the native one, so for a string with a dot the adapter and Fern disagree. That is deliberate: the specs forge reads write Fern's key in Cloudflare's convention, and reading the two keys alike keeps a key rename from changing a value's meaning. - **Every native key begins with `x-forge-`.** The `-name` keys (`method-name`, `model-name`, `property-name`, `parameter-name`) say the same kind of thing and are spelled alike. `x-sensitive` predates the rule; it becomes `x-forge-sensitive`, and the old name stays readable as a deprecated alias (155 production uses), so no spec breaks. A deprecated alias is part of the native vocabulary, not of the compatibility layer: it keeps working with compat off, and deleting compat does not remove it. - **No misplacement validation in v1.** A known native key in a wrong position is silently ignored, exactly as Fern's keys are today. Adding validation later is a pure tightening that breaks no valid spec. @@ -93,7 +94,7 @@ Design notes: 3. **Same key twice** → error (today's `duplicate field` behavior, unchanged). 4. **Empty or punctuation-only values** behave as today, which depends on the key: - `x-forge-property-name`, `x-forge-parameter-name` and `x-forge-model-name`: a value with no letters or digits, empty included, is ignored, and the field or model is named after its wire name (the property, parameter or schema name). - - `x-forge-method-name` and `x-forge-group`: an empty method name, or an empty group path (`[]`, or Fern's `""`), means the operation or alias is not a method. For `[]` this is the current behavior, not the intended one: see [Known gaps](#known-gaps). A method name, or any group name in the path, that is not empty but has no letters or digits is an error. + - `x-forge-method-name` and `x-forge-group`: an empty method name, or an empty group path (`""` or `[]`), means the operation or alias is not a method. For the empty group path this is the current behavior, not the intended one: see [Known gaps](#known-gaps). A method name, or any group name in the path, that is not empty but has no letters or digits is an error. - Validation errors name the key the author actually wrote (provenance), so existing Fern-keyed error expectations hold. 5. **Dual-writing** (native and compat keys carrying the same value) is the supported pattern for keeping specs portable across engine versions during the transition: old engines read the compat key, new engines apply native. @@ -101,11 +102,11 @@ Design notes: ### An empty group path -- **Intended:** `x-forge-group: []` attaches the method to the root, in no group. An operation without `x-forge-group` at all is still not a method. -- **Today:** `[]` makes no method, exactly like a missing key. `add_method` in `src/link.rs` returns before building the method when the path is empty; `Method::group`'s docs in `src/lib.rs` say so; and `tests/parse/groups.rs::empty_group_name_is_no_group` and the empty-group alias case in `tests/parse/aliases.rs` assert it. +- **Intended:** `x-forge-group: []` (or `""`) attaches the method to the root, in no group. An operation without `x-forge-group` at all is still not a method. +- **Today:** an empty group path makes no method, exactly like a missing key. `add_method` in `src/link.rs` returns before building the method when the path is empty; `Method::group`'s docs in `src/lib.rs` say so; and `tests/parse/groups.rs::empty_group_name_is_no_group` and the empty-group alias case in `tests/parse/aliases.rs` assert it. - **Why it is not fixed here:** the model has no place for a root method. Methods exist only inside groups (`Forge::groups` → `Group::methods`); `Forge` has top-level models but no top-level methods, and neither does the WIT. The fix needs a root method list in the model, a way for plugins to read it (a new WIT function is additive; a new field in an existing record is not), and generators that render root methods. That is beyond FORGE-1, which leaves the model unchanged. - **Impact:** none today. The released aggregate has no empty group path and no method name without a group, so the fix will not change its output. -- **To decide with the fix:** whether Fern's empty string (`x-fern-sdk-group-name: ""`) should also mean the root, or keep meaning "not a method" in the compatibility layer. +- **To decide with the fix:** whether an empty `x-fern-sdk-group-name` (`""` or `[]`) should also mean the root, or keep meaning "not a method" in the compatibility layer. Fern itself puts a method with no group, or an empty array, at the root of the client, so meaning the root would bring the adapter closer to Fern. ## Architecture @@ -124,8 +125,8 @@ src/ │ │ errors. The only things link imports from here. │ │ The designed end state: delete this folder + the feature. │ ├─ fern.rs every x-fern-* literal, mapped onto metadata's primitives; -│ │ Fern's value formats, as data (e.g. "a string splits on -│ │ dots"); Fern unit tests. +│ │ the value formats of those keys, as data; Fern unit +│ │ tests. │ └─ stainless.rs the x-stainless-skip mapping and its format-as-data │ (a list of targets counts as false); unit tests. └─ link/ @@ -143,7 +144,7 @@ Properties, all deliberate: - **Purity**: the library's contract is `&str` keys in and small values out: primitives, key names, and *format descriptors as data* (for example "a string splits on `.`" or "a list of targets counts as `false`"). The serde reading and the arena writes stay in `link` and are generic, driven by those descriptors. This is what makes the library pluggable, standalone-consumable (a future `forge-ci validate` needs the key table, not the engine), and mechanically liftable into a real separate crate when the first standalone consumer appears (FORGE-5's work). - **The availability vocabulary is native.** The five values live in the native table (they are shipped public API); `fern.rs` only maps `x-fern-availability` onto that primitive. - **Deletion is the designed end state.** `cargo build --no-default-features --features cli` proves the native vocabulary stands alone. When vendor keys reach end-of-life, deleting the layer means removing `src/compat/` and `tests/compat/`, `mod compat` in `src/lib.rs`, the `compat` cfg arms in `link/resolve.rs` (a resolver and a name lookup for each kind of object the layer reads: operations and aliases, parameters, schemas), and the `compat` feature and its `[[test]]` entry in `Cargo.toml`. No `x-forge-*` extension changes, in behavior, docs, or WIT. - - *What a deletion leaves behind.* Some native-side code exists only for the layer and compiles, unused, without it: `Hit::compat` and `Hit::after`, `Tier::Compat`, `COMPAT_SLOTS`, the value formats only vendors use (`GroupFormat::StringOrArray`, `BoolFormat::BoolOrTargets`) and their readers in `link/openapi.rs`, and the sentence in `Method::extensions`'s docs about the keys the compatibility layer reads. Removing them is a mechanical follow-on step, not a prerequisite. + - *What a deletion leaves behind.* Some native-side code exists only for the layer and compiles, unused, without it: `Hit::compat` and `Hit::after`, `Tier::Compat`, `COMPAT_SLOTS`, the value format only a vendor uses (`BoolFormat::BoolOrTargets`) and its reader in `link/openapi.rs`, and the sentence in `Method::extensions`'s docs about the keys the compatibility layer reads. Removing them is a mechanical follow-on step, not a prerequisite. - **With compat off, vendor keys are ordinary extensions.** Built without the feature, the engine does not drop `x-fern-*` and `x-stainless-*` keys: like any other `x-` key it does not read, an operation's vendor keys are kept and given to plugins by `Method::extensions`. Native behavior is the same either way; what plugins see in `extensions()` is not. - **Dispatch, not semantics**: the layer maps keys to primitives and normalizes simple values. Structural behavior — `x-forge-aliases` becoming methods, `x-forge-commands` describing groups — stays in the builder. @@ -156,7 +157,7 @@ Properties, all deliberate: ## Test plan - `tests/parse/` — every fixture rewrites to native keys. Not find-and-replace: group values `"a.b"` become `["a","b"]`; the three Fern name keys become their explicit native keys; error assertions naming Fern keys move out. -- `tests/compat/` — the vendor suite, mirroring `tests/parse`'s harness: for each primitive, parse(Fern-keyed fixture) == parse(native-keyed fixture); Fern format specifics (dotted vs array groups, duplicate-key errors, provenance error strings, Stainless's skip-list semantics). +- `tests/compat/` — the vendor suite, mirroring `tests/parse`'s harness: for each primitive, parse(Fern-keyed fixture) == parse(native-keyed fixture); format specifics (dotted vs array groups, duplicate-key errors, provenance error strings, Stainless's skip-list semantics). - `tests/boundary.rs` — the guard. - Existing tripwires keep working: `wit_is_current` (WIT regeneration is mechanical via `wit.mjs`), the wasm dump equality, native-vs-wasm generated-output byte equality. - Performance: divan parse benches before/after on the real aggregate (`FORGE_SPEC`); `tests/wasm.rs::bench` re-run; forge-bench re-pinned upstream after merge. @@ -185,7 +186,8 @@ Non-breaking. `x-sensitive` keeps working, deprecated in favor of `x-forge-sensi - **Vendor-trait machinery** (a `Vendor` trait with Fern/Stainless/Scalar impls): no primitive overlaps across vendors today; the key table grows rows when one does. Rejected as over-engineering for now. - **One positional `x-forge-name`**: mirrors the model's single `Name` concept and is cleaner in the abstract — but when toolchains inline `$ref` targets, the model's name key rides into property positions and silently renames fields. Explicit keys turn that corruption into something ignorable or flaggable. Rejected on robustness. - **Separate `x-forge-skip` for properties**: two words for one concept, and `skip` is Stainless's word. Rejected. -- **Accepting dotted group strings natively**: ergonomic continuity, but it permanently imports Fern's format into the native vocabulary; array-only now is the reversible choice. Rejected. +- **Arrays only for `x-forge-group`**: one form, which every tool reads the same way, and loosening later would break nothing. Rejected: Cloudflare's specs write dotted strings, so every migration would reshape values as well as rename keys, and Forge CI's lint would need an array rule. +- **Reading a string `x-fern-sdk-group-name` as one group, as Fern does**: faithful to Fern, but the native and Fern keys would then read the same value differently, and the specs forge reads use dots. Rejected. - **Error on native/compat disagreement**: the strictest conflict rule, and consistent with the engine's general strictness — deferred as an open question in FORGE-4 in favor of silent native-wins (zero comparison machinery, no cost for correct specs; the drift risk is documented). - **Full crate split now** (a separate `metadata` member crate): delivers "separate library" literally today. The root `Cargo.toml` is already a `[workspace]`, so adding a member does not mean moving the engine; the real cost is that a separate crate cannot depend on the engine, so `Availability` and the other primitive value types would move into it and be re-exported by `forge`. Deferred: the pure contract keeps that move mechanical for when the first standalone consumer exists. - **Renaming `Group`/`Availability` now**: both are Fern-inherited words, but both renames break the plugin contract. Deferred to FORGE-3's single breaking wave. diff --git a/src/compat/fern.rs b/src/compat/fern.rs index fe7358fdf..925f7e47a 100644 --- a/src/compat/fern.rs +++ b/src/compat/fern.rs @@ -3,17 +3,22 @@ //! | Key | Says | Format | //! |---|---|---| //! | `x-fern-sdk-method-name` | the name of the method | a string | -//! | `x-fern-sdk-group-name` | the group path of the method | an array of group names, or one string of them separated by dots | +//! | `x-fern-sdk-group-name` | the group path of the method | as `x-forge-group`: an array of group names, or one string of them separated by dots | //! | `x-fern-ignore` | the definition makes no method | a boolean | //! | `x-fern-availability` | the stage of the API | a string, one of the native spellings | //! | `x-fern-parameter-name` | the name of a parameter's field | a string | //! | `x-fern-property-name` | the name of a property's field | a string | //! | `x-fern-type-name` | the name of a model | a string | //! +//! A group path written as one string is split on dots, as for +//! `x-forge-group`, which Fern itself does not do: Fern reads a string as a +//! single group. A document written for Fern uses the array, which means the +//! same in both. +//! //! Its other extensions, such as `x-fern-enum` and `x-fern-audiences`, are //! not read: they say things forge has no primitive for. -use crate::metadata::{BoolFormat, DefinitionField, GroupFormat, Hit, ParameterField, SchemaField}; +use crate::metadata::{BoolFormat, DefinitionField, Hit, ParameterField, SchemaField}; /// What every key of the adapter begins with. A key without it is turned /// away before it is matched against the table. @@ -39,7 +44,7 @@ pub(super) fn definition(key: &str) -> Option> { } Some(match key { METHOD_NAME => Hit::compat(DefinitionField::Method, 0), - GROUP_NAME => Hit::compat(DefinitionField::Group(GroupFormat::StringOrArray { separator: '.' }), 1), + GROUP_NAME => Hit::compat(DefinitionField::Group, 1), IGNORE => Hit::compat(DefinitionField::Ignore(BoolFormat::Bool), 2), AVAILABILITY => Hit::compat(DefinitionField::Availability, 3), _ => return None, @@ -108,12 +113,9 @@ mod tests { } #[test] - fn the_group_is_a_string_split_on_dots_or_an_array() { + fn the_group_is_read_as_the_native_group() { let hit = definition(GROUP_NAME).unwrap(); - assert!(matches!( - hit.field, - DefinitionField::Group(GroupFormat::StringOrArray { separator: '.' }) - )); + assert!(matches!(hit.field, DefinitionField::Group)); } #[test] diff --git a/src/lib.rs b/src/lib.rs index bc07c7d84..df76e0b69 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -265,11 +265,12 @@ impl<'f> Method<'f> { /// Returns the group the method is in. /// /// Corresponds to the `x-forge-group` extension of the operation, or - /// of the alias the method comes from: an array with one group name per - /// element, top level first, such as `[accounts, members]`. An - /// operation or alias without one, with an empty one, or with - /// `x-forge-ignore: true` is not a method. A group name with no letters - /// or digits is an error. + /// of the alias the method comes from: a string of group names + /// separated by dots, such as `accounts.members`, or an array with one + /// group name per element, top level first, whose elements are not + /// split. An operation or alias without one, with an empty one, or + /// with `x-forge-ignore: true` is not a method. A group name with no + /// letters or digits is an error. pub fn group(self) -> Group<'f> { self.forge.group(self.data().group) } diff --git a/src/link/openapi.rs b/src/link/openapi.rs index 05b4867ce..dd1b2b093 100644 --- a/src/link/openapi.rs +++ b/src/link/openapi.rs @@ -28,9 +28,7 @@ use super::resolve; use crate::Name; use crate::arena::{Id, List, Span, Str, Strings}; use crate::json::{self, Json}; -use crate::metadata::{ - self, BoolFormat, DefinitionField, DocumentField, GroupFormat, Hit, ParameterField, SchemaField, Tier, -}; +use crate::metadata::{self, BoolFormat, DefinitionField, DocumentField, Hit, ParameterField, SchemaField, Tier}; /// An OpenAPI document: the fields of the OpenAPI Object at its root, and /// the arenas of the objects in it. @@ -896,36 +894,29 @@ impl Read for Types { } } -/// What reads a group path into a `Doc`, in the format it is written in: -/// an array of strings, which are not split, and, if the format says so, a -/// string split on its separator, where the empty string is no group. -struct GroupNames<'a>(&'a mut Doc, GroupFormat); +/// Reads a group path: a string split on dots, or an array of strings that +/// are not split. The empty string is no group. +struct GroupNames; -impl<'de> DeserializeSeed<'de> for GroupNames<'_> { - type Value = Span; +impl Read for GroupNames { + type Out = Span; - fn deserialize>(self, deserializer: D) -> Result, D::Error> { - struct GroupNamesVisitor<'a>(&'a mut Doc, GroupFormat); + fn read<'de, D: Deserializer<'de>>(doc: &mut Doc, deserializer: D) -> Result, D::Error> { + struct GroupNamesVisitor<'a>(&'a mut Doc); impl<'de> Visitor<'de> for GroupNamesVisitor<'_> { type Value = Span; fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self.1 { - GroupFormat::Array => f.write_str("an array of strings"), - GroupFormat::StringOrArray { .. } => f.write_str("a string or an array of strings"), - } + f.write_str("a string or an array of strings") } fn visit_str(self, v: &str) -> Result, E> { - let GroupFormat::StringOrArray { separator } = self.1 else { - return Err(E::invalid_type(de::Unexpected::Str(v), &self)); - }; if v.is_empty() { return Ok(Span::default()); } let doc = self.0; - Ok(doc.strings.push_split(v, separator, &mut doc.str_lists)) + Ok(doc.strings.push_split(v, '.', &mut doc.str_lists)) } fn visit_seq>(self, seq: A) -> Result, A::Error> { @@ -934,7 +925,7 @@ impl<'de> DeserializeSeed<'de> for GroupNames<'_> { } } - deserializer.deserialize_any(GroupNamesVisitor(self.0, self.1)) + deserializer.deserialize_any(GroupNamesVisitor(doc)) } } @@ -1312,8 +1303,8 @@ impl Definition { let name = map.next_value_seed(seed::(doc))?; self.method_name.set(&hit, name); } - DefinitionField::Group(format) => { - let names = map.next_value_seed(GroupNames(doc, format))?; + DefinitionField::Group => { + let names = map.next_value_seed(seed::(doc))?; self.group_names.set(&hit, names); } DefinitionField::Ignore(format) => { diff --git a/src/metadata/mod.rs b/src/metadata/mod.rs index ae40ba42c..dc59da651 100644 --- a/src/metadata/mod.rs +++ b/src/metadata/mod.rs @@ -124,8 +124,10 @@ pub(crate) fn native_name(names: &[&'static str], slot: u8) -> Option<&'static s pub(crate) enum DefinitionField { /// The name of the method. Method, - /// The group path of the method. - Group(GroupFormat), + /// The group path of the method: an array of group names, top level + /// first, or one string of them separated by dots. The empty string is + /// no group. + Group, /// Whether the definition makes no method. Ignore(BoolFormat), /// The stage of the API the method is in. @@ -163,17 +165,6 @@ pub(crate) enum DocumentField { Commands, } -/// How the value of a group path is written. -#[derive(Clone, Copy)] -pub(crate) enum GroupFormat { - /// An array of group names, top level first. - Array, - /// An array of group names, or one string of them separated by - /// `separator`. The empty string is no group. - #[cfg_attr(not(feature = "compat"), expect(dead_code))] - StringOrArray { separator: char }, -} - /// How the value of a boolean is written. #[derive(Clone, Copy)] pub(crate) enum BoolFormat { @@ -192,7 +183,7 @@ pub(crate) fn definition(key: &str) -> Option> { } Some(match key { METHOD_NAME => Hit::native(DefinitionField::Method, 0), - GROUP => Hit::native(DefinitionField::Group(GroupFormat::Array), 1), + GROUP => Hit::native(DefinitionField::Group, 1), IGNORE => Hit::native(DefinitionField::Ignore(BoolFormat::Bool), 2), AVAILABILITY => Hit::native(DefinitionField::Availability, 3), HIDDEN => Hit::native(DefinitionField::Hidden, 4), diff --git a/tests/compat/fern.rs b/tests/compat/fern.rs index a710822fd..bfd0a41ac 100644 --- a/tests/compat/fern.rs +++ b/tests/compat/fern.rs @@ -29,11 +29,13 @@ fn every_availability_is_read() { } } +/// As `x-forge-group` reads it, which Fern does not: Fern reads the string +/// as one group. #[test] fn a_group_written_as_a_string_is_split_on_dots() { assert_same( &operation_with(r#""x-fern-sdk-group-name": "a.b.c", "x-fern-sdk-method-name": "get","#), - &operation_with(r#""x-forge-group": ["a", "b", "c"], "x-forge-method-name": "get","#), + &operation_with(r#""x-forge-group": "a.b.c", "x-forge-method-name": "get","#), ); } diff --git a/tests/compat/native_first.rs b/tests/compat/native_first.rs index 368968940..9e0db4d6f 100644 --- a/tests/compat/native_first.rs +++ b/tests/compat/native_first.rs @@ -182,12 +182,12 @@ fn a_native_key_written_twice_is_an_error_naming_it() { } #[test] -fn a_native_group_written_as_a_string_is_an_error() { - let err = parse_err(&operation_with( - r#""x-forge-group": "a.b", "x-forge-method-name": "get","#, - )); - - assert!(err.contains("an array of strings"), "{err}"); +fn the_native_group_written_as_a_string_wins() { + native_wins( + r#""x-forge-method-name": "get", "x-forge-group": "native.group","#, + r#""x-fern-sdk-group-name": ["vendor"],"#, + r#""x-forge-method-name": "get", "x-forge-group": ["native", "group"],"#, + ); } #[test] diff --git a/tests/parse/groups.rs b/tests/parse/groups.rs index b22306d41..0e06d3322 100644 --- a/tests/parse/groups.rs +++ b/tests/parse/groups.rs @@ -7,7 +7,7 @@ fn string_group_name_splits_on_dots() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["accounts", "members"], "x-forge-method-name": "list", + "paths": {"/a": {"get": {"x-forge-group": "accounts.members", "x-forge-method-name": "list", "responses": {"204": {"description": "ok"}}}}} }"#, ); @@ -16,6 +16,19 @@ fn string_group_name_splits_on_dots() { assert_eq!(members.parent(), Some(group(&forge, "accounts"))); } +#[test] +fn group_name_that_is_neither_a_string_nor_an_array_is_an_error() { + let err = parse_err( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-forge-group": 1, "x-forge-method-name": "list", + "responses": {"204": {"description": "ok"}}}}} + }"#, + ); + + assert!(err.contains("a string or an array of strings"), "{err}"); +} + #[test] fn array_group_name_has_a_group_per_element() { let forge = parse( @@ -99,15 +112,17 @@ fn operation_without_a_group_is_not_a_method() { #[test] fn empty_group_name_is_no_group() { - let forge = parse( - r#"{ - "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": [], "x-forge-method-name": "get", - "responses": {"204": {"description": "ok"}}}}} - }"#, - ); - - assert_eq!(forge.groups().count(), 0); + for group in [r#""""#, "[]"] { + let forge = parse(&format!( + r#"{{ + "openapi": "3.0.3", "info": {{"title": "t", "version": "1"}}, + "paths": {{"/a": {{"get": {{"x-forge-group": {group}, "x-forge-method-name": "get", + "responses": {{"204": {{"description": "ok"}}}}}}}}}} + }}"# + )); + + assert_eq!(forge.groups().count(), 0, "{group}"); + } } #[test] @@ -156,15 +171,17 @@ fn ignored_operation_is_not_a_method() { #[test] fn group_name_without_letters_is_an_error() { - let err = parse_err( - r#"{ - "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["accounts", "", "members"], "x-forge-method-name": "list", - "responses": {"204": {"description": "ok"}}}}} - }"#, - ); - - assert!(err.contains("GET /a"), "{err}"); + for group in [r#"["accounts", "", "members"]"#, r#""accounts..members""#] { + let err = parse_err(&format!( + r#"{{ + "openapi": "3.0.3", "info": {{"title": "t", "version": "1"}}, + "paths": {{"/a": {{"get": {{"x-forge-group": {group}, "x-forge-method-name": "list", + "responses": {{"204": {{"description": "ok"}}}}}}}}}} + }}"# + )); + + assert!(err.contains("GET /a: x-forge-group has a group name"), "{group}: {err}"); + } } #[test] From e84e0e92ddcb34c7adfc727355ebdcd3c50565ff Mon Sep 17 00:00:00 2001 From: Niels Swimberghe Date: Mon, 26 Oct 2026 16:42:58 -0400 Subject: [PATCH 17/25] refactor: keep extension tiers local to the reader, and test one key per tier --- src/compat/mod.rs | 27 +++++++++++++++-- src/link/openapi.rs | 72 +++++++++++++++++++++++++++------------------ src/metadata/mod.rs | 33 +++++++++++++++++++++ 3 files changed, 102 insertions(+), 30 deletions(-) diff --git a/src/compat/mod.rs b/src/compat/mod.rs index 48a6a6e42..f4b590ee4 100644 --- a/src/compat/mod.rs +++ b/src/compat/mod.rs @@ -64,8 +64,7 @@ pub(crate) fn schema_name(n: u8) -> &'static str { #[cfg(test)] mod tests { use super::*; - use crate::metadata::COMPAT_SLOTS; - use crate::metadata::Tier; + use crate::metadata::{self, COMPAT_SLOTS, Tier}; /// Asserts that each key in `names` is read by `read` as a key that is /// not native, whose slot `name` names it by. @@ -86,6 +85,30 @@ mod tests { read_as_itself(&stainless::SCHEMA_NAMES, schema, schema_name); } + #[test] + fn no_two_keys_say_the_same_thing_with_the_same_tier() { + // As `link` reads them: native first, then the adapters. + let keys = |native: &[&'static str], adapters: &[&[&'static str]]| -> Vec<&'static str> { + native + .iter() + .chain(adapters.iter().flat_map(|keys| keys.iter())) + .copied() + .collect() + }; + let definitions = keys(&metadata::DEFINITION_NAMES, &[&fern::DEFINITION_NAMES]); + let parameters = keys(&metadata::PARAMETER_NAMES, &[&fern::PARAMETER_NAMES]); + let schemas = keys( + &metadata::SCHEMA_NAMES, + &[&fern::SCHEMA_NAMES, &stainless::SCHEMA_NAMES], + ); + + metadata::assert_one_key_per_tier(&definitions, |key| { + metadata::definition(key).or_else(|| definition(key)) + }); + metadata::assert_one_key_per_tier(¶meters, |key| metadata::parameter(key).or_else(|| parameter(key))); + metadata::assert_one_key_per_tier(&schemas, |key| metadata::schema(key).or_else(|| schema(key))); + } + #[test] fn the_slots_of_the_adapters_do_not_collide_and_fit_in_a_set() { let mut slots: Vec = fern::SCHEMA_NAMES diff --git a/src/link/openapi.rs b/src/link/openapi.rs index dd1b2b093..9041ee621 100644 --- a/src/link/openapi.rs +++ b/src/link/openapi.rs @@ -239,33 +239,42 @@ pub(super) struct Operation { } /// A value an extension gives, and the key it came from, so that an error can -/// name the key the document wrote. The value of the strongest [`Tier`] of -/// key is kept, whatever order the keys are written in. +/// name the key the document wrote. #[derive(Default)] pub(super) struct Val { pub(super) value: T, /// The slot of the key that gave the value, which names it. It means /// nothing if no key gave one. pub(super) slot: u8, - tier: Tier, } impl Val { /// Keeps `value`, which `hit` gave, unless a key of a stronger tier - /// gave the value held. - fn set(&mut self, hit: &Hit, value: T) { - if takes(hit, &mut self.tier) { + /// than `tier`, the tier of the value held, gave it. + fn set(&mut self, hit: &Hit, value: T, tier: &mut Tier) { + if takes(hit, tier) { self.value = value; self.slot = hit.slot; } } } +/// The tier of the key that gave each value of a [`Definition`] so far, while +/// it is read. +#[derive(Default)] +struct DefinitionTiers { + method_name: Tier, + group_names: Tier, + ignore: Tier, + availability: Tier, +} + /// Reports whether a value from the key of `hit` replaces the one read so /// far, which came from a key of tier `tier`, and records the tier of the -/// value kept. Keys of the same tier that say the same thing are different -/// keys of the same object, which no table has, so equal tiers do not -/// happen; were they to, the last written would be kept. +/// value kept. Equal tiers do not happen: a key written twice is an error +/// before it gets here, and no two keys of one tier say the same thing, +/// which the tests of `metadata` and `compat` check. Were they to, the last +/// written would be kept. fn takes(hit: &Hit, tier: &mut Tier) -> bool { if hit.tier >= *tier { *tier = hit.tier; @@ -318,8 +327,6 @@ pub(super) struct Parameter { pub(super) content: Span<(Str, Schema)>, /// The name of the parameter's field, or empty for none. pub(super) field_name: Str, - /// The tier of the key that gave `field_name`. - field_name_tier: Tier, } /// Where a parameter is sent: the `in` of a Parameter Object. @@ -445,12 +452,16 @@ pub(super) struct SchemaObject { /// Whether a property's schema is marked to be left out: it makes no /// field. pub(super) ignore: bool, - /// The tier of the key that gave each of `model_name`, `field_name`, - /// `ignore` and `sensitive`, so that no weaker key replaces it. - model_name_tier: Tier, - field_name_tier: Tier, - ignore_tier: Tier, - sensitive_tier: Tier, +} + +/// The tier of the key that gave each extension value of a [`SchemaObject`] +/// so far, while it is read. +#[derive(Default)] +struct SchemaTiers { + model_name: Tier, + field_name: Tier, + ignore: Tier, + sensitive: Tier, } /// A value of `type` in a Schema Object. @@ -1297,23 +1308,24 @@ impl Definition { doc: &mut Doc, map: &mut A, hit: Hit, + tiers: &mut DefinitionTiers, ) -> Result<(), A::Error> { match hit.field { DefinitionField::Method => { let name = map.next_value_seed(seed::(doc))?; - self.method_name.set(&hit, name); + self.method_name.set(&hit, name, &mut tiers.method_name); } DefinitionField::Group => { let names = map.next_value_seed(seed::(doc))?; - self.group_names.set(&hit, names); + self.group_names.set(&hit, names, &mut tiers.group_names); } DefinitionField::Ignore(format) => { let ignore = read_bool(map, format)?; - self.ignore.set(&hit, ignore); + self.ignore.set(&hit, ignore, &mut tiers.ignore); } DefinitionField::Availability => { let availability = map.next_value_seed(seed::(doc))?; - self.availability.set(&hit, Some(availability)); + self.availability.set(&hit, Some(availability), &mut tiers.availability); } DefinitionField::Hidden => self.hidden = map.next_value()?, DefinitionField::Confirmation => { @@ -1337,13 +1349,14 @@ impl ReadObject for Alias { fn read_object<'de, A: MapAccess<'de>>(doc: &mut Doc, mut map: A) -> Result { let mut definition = Definition::default(); let mut seen = 0; + let mut tiers = DefinitionTiers::default(); // As for an operation. let mut extensions = Vec::new(); while let Some(field) = map.next_key::()? { match field { AliasField::Definition(hit) => { resolve::once(&hit, &mut seen)?; - definition.read_value(doc, &mut map, hit)?; + definition.read_value(doc, &mut map, hit, &mut tiers)?; } AliasField::Extension(name) => json::insert(&mut extensions, name, map.next_value()?), AliasField::Other => skip(&mut map)?, @@ -1502,6 +1515,7 @@ impl ReadObject for Operation { let mut operation = Operation::default(); let mut seen = 0; let mut seen_definition = 0; + let mut tiers = DefinitionTiers::default(); // Kept apart until the operation is read, so that an extension // written again can replace the first. Empty, it allocates nothing. let mut extensions = Vec::new(); @@ -1510,7 +1524,7 @@ impl ReadObject for Operation { OperationField::Key(key) => key, OperationField::Definition(hit) => { resolve::once(&hit, &mut seen_definition)?; - operation.definition.read_value(doc, &mut map, hit)?; + operation.definition.read_value(doc, &mut map, hit, &mut tiers)?; continue; } OperationField::Aliases => { @@ -1612,6 +1626,7 @@ impl ReadObject for Parameter { let mut param = Parameter::default(); let mut seen = 0; let mut seen_extensions = 0; + let mut field_name_tier = Tier::Unset; while let Some(entry) = map.next_key::()? { let key = match entry { ParameterEntry::Extension(hit) => { @@ -1619,7 +1634,7 @@ impl ReadObject for Parameter { match hit.field { ParameterField::Name => { let name = map.next_value_seed(seed::(doc))?; - if takes(&hit, &mut param.field_name_tier) { + if takes(&hit, &mut field_name_tier) { param.field_name = name; } } @@ -1758,6 +1773,7 @@ impl ReadObject for SchemaObject { let mut schema = SchemaObject::default(); let mut seen = 0; let mut seen_extensions = 0; + let mut tiers = SchemaTiers::default(); while let Some(entry) = map.next_key::()? { let key = match entry { SchemaEntry::Extension(hit) => { @@ -1765,25 +1781,25 @@ impl ReadObject for SchemaObject { match hit.field { SchemaField::ModelName => { let name = map.next_value_seed(seed::(doc))?; - if takes(&hit, &mut schema.model_name_tier) { + if takes(&hit, &mut tiers.model_name) { schema.model_name = name; } } SchemaField::PropertyName => { let name = map.next_value_seed(seed::(doc))?; - if takes(&hit, &mut schema.field_name_tier) { + if takes(&hit, &mut tiers.field_name) { schema.field_name = name; } } SchemaField::Ignore(format) => { let ignore = read_bool(&mut map, format)?; - if takes(&hit, &mut schema.ignore_tier) { + if takes(&hit, &mut tiers.ignore) { schema.ignore = ignore; } } SchemaField::Sensitive => { let sensitive = map.next_value()?; - if takes(&hit, &mut schema.sensitive_tier) { + if takes(&hit, &mut tiers.sensitive) { schema.sensitive = sensitive; } } diff --git a/src/metadata/mod.rs b/src/metadata/mod.rs index dc59da651..9eba8e7ff 100644 --- a/src/metadata/mod.rs +++ b/src/metadata/mod.rs @@ -275,6 +275,24 @@ pub(crate) fn availability(value: &str) -> Option { } } +/// Asserts that no two of `keys` that `read` reads say the same thing with +/// the same tier. Which key's value is kept is decided by tier alone, so two +/// keys of one tier for one thing would make it depend on the order the +/// keys are written in. +#[cfg(test)] +#[track_caller] +pub(crate) fn assert_one_key_per_tier(keys: &[&str], read: impl Fn(&str) -> Option>) { + let mut said: Vec<(std::mem::Discriminant, Tier, &str)> = Vec::new(); + for &key in keys { + let hit = read(key).unwrap_or_else(|| panic!("{key} is not read")); + let what = std::mem::discriminant(&hit.field); + if let Some(&(_, _, other)) = said.iter().find(|&&(w, tier, _)| w == what && tier == hit.tier) { + panic!("{other} and {key} say the same thing with the same tier"); + } + said.push((what, hit.tier, key)); + } +} + #[cfg(test)] mod tests { use super::*; @@ -323,6 +341,21 @@ mod tests { read_as_itself(documents, &DOCUMENT_NAMES, document); } + #[test] + fn no_two_native_keys_say_the_same_thing_with_the_same_tier() { + let [definitions, parameters, schemas, documents] = native_keys(); + assert_one_key_per_tier(definitions, definition); + assert_one_key_per_tier(parameters, parameter); + assert_one_key_per_tier(schemas, schema); + assert_one_key_per_tier(documents, document); + } + + #[test] + #[should_panic(expected = "x-forge-sensitive and x-forge-sensitive say the same thing with the same tier")] + fn one_key_per_tier_finds_two() { + assert_one_key_per_tier(&["x-forge-sensitive", "x-forge-sensitive"], schema); + } + #[test] fn x_sensitive_is_a_deprecated_name_of_x_forge_sensitive() { let (current, old) = (schema("x-forge-sensitive").unwrap(), schema("x-sensitive").unwrap()); From 471acdf496575b026e58e89800b9a427b91bc92f Mon Sep 17 00:00:00 2001 From: Niels Swimberghe Date: Mon, 26 Oct 2026 17:44:11 -0400 Subject: [PATCH 18/25] refactor: address review on RFC 0001 - Rename x-forge-ignore to x-forge-skip, internal names included. - Rename x-forge-group to x-forge-group-name, to match the other -name keys and stay apart from the legacy x-forge-group-info. - Remove benches/compare.sh and its just recipe; the RFC describes the method inline. - RFC: record why the key is model-name, and that CI compiles but does not test the build without compat, on purpose. --- benches/compare.sh | 81 ---------------- justfile | 4 - rfcs/0001-metadata-resolution-layer.md | 24 ++--- src/compat/fern.rs | 6 +- src/compat/stainless.rs | 4 +- src/lib.rs | 12 +-- src/link.rs | 4 +- src/link/openapi.rs | 22 ++--- src/metadata/mod.rs | 24 ++--- src/plugin/ts/tests.rs | 6 +- src/types.rs | 4 +- tests/cli.rs | 2 +- tests/compat/common.rs | 2 +- tests/compat/fern.rs | 24 ++--- tests/compat/native_first.rs | 96 ++++++++++--------- tests/compat/stainless.rs | 31 +++--- tests/parse/aliases.rs | 68 ++++++------- tests/parse/bodies.rs | 28 +++--- tests/parse/common.rs | 4 +- tests/parse/document.rs | 4 +- tests/parse/extensions.rs | 28 +++--- tests/parse/groups.rs | 73 +++++++------- tests/parse/headers.rs | 50 +++++----- tests/parse/methods.rs | 54 +++++------ tests/parse/models.rs | 38 ++++---- tests/parse/requests.rs | 126 ++++++++++++------------- tests/parse/responses.rs | 12 +-- tests/parse/types/arrays.rs | 4 +- tests/parse/types/enums.rs | 2 +- tests/parse/types/intersections.rs | 4 +- tests/parse/types/nulls.rs | 8 +- tests/parse/types/objects.rs | 8 +- tests/parse/types/refs.rs | 18 ++-- tests/parse/types/scalars.rs | 6 +- tests/parse/types/unions.rs | 6 +- tests/wasm.rs | 18 ++-- 36 files changed, 416 insertions(+), 489 deletions(-) delete mode 100755 benches/compare.sh diff --git a/benches/compare.sh b/benches/compare.sh deleted file mode 100755 index d79406d66..000000000 --- a/benches/compare.sh +++ /dev/null @@ -1,81 +0,0 @@ -#!/usr/bin/env bash -# Compares the parse benchmark of the working tree against a git revision: -# -# FORGE_SPEC=/path/to/openapi.json benches/compare.sh [REV] [PAIRS] -# -# REV defaults to main and PAIRS to 12. The revision is built in a throwaway -# worktree, so the working tree is left as it is. -# -# Each pair runs the two in ABBA order (base, new, new, base) and takes the -# mean of each side's two runs, so a machine that warms up or cools down -# through the session does not bias one side. Three warm-up pairs are -# discarded first. The statistics are paired, on divan's fastest sample of -# each run. A pair whose four runs differ by more than 10% is reported and -# left out: the machine changed speed in the middle of it. -# -# A difference under about twice its standard error is not one this can see. -set -eu - -REV=${1:-main} -PAIRS=${2:-12} -: "${FORGE_SPEC:?set FORGE_SPEC to the document to benchmark}" -export FORGE_SPEC - -ROOT=$(git rev-parse --show-toplevel) -WORK=$(mktemp -d) -trap 'git -C "$ROOT" worktree remove --force "$WORK/base" > /dev/null 2>&1 || true; rm -rf "$WORK"' EXIT - -# Builds the parse benchmark of the tree at $1 into the target folder $2, and -# copies it to $3. -build() { - (cd "$1" && CARGO_TARGET_DIR=$2 cargo bench --locked --bench parse --no-run 2>&1 | tail -1) >&2 - local bin - bin=$(ls -t "$2"/release/deps/parse-* | grep -vE '\.(d|o)$' | head -1) - cp "$bin" "$3" -} - -git -C "$ROOT" worktree add --detach "$WORK/base" "$REV" > /dev/null 2>&1 -build "$WORK/base" "$WORK/target-base" "$WORK/base.bin" -build "$ROOT" "$ROOT/target" "$WORK/new.bin" - -# Writes the fastest sample of a divan run of $1, in ms. -measure() { - "$1" --bench forge > "$WORK/run.out" 2> /dev/null - awk ' - /├─ forge/ {f = 1} - f && /╰─|├─/ && /[0-9] (ms|µs|s) / { - for (i = 1; i <= NF; i++) - if ($i ~ /^[0-9]+(\.[0-9]+)?$/) { - v = $i; u = $(i+1) - print (u == "µs" ? v / 1000 : u == "s" ? v * 1000 : v) - exit - } - }' "$WORK/run.out" -} - -for i in 1 2 3; do - measure "$WORK/base.bin" > /dev/null; measure "$WORK/new.bin" > /dev/null -done -: > "$WORK/pairs" -for i in $(seq "$PAIRS"); do - b1=$(measure "$WORK/base.bin"); n1=$(measure "$WORK/new.bin") - n2=$(measure "$WORK/new.bin"); b2=$(measure "$WORK/base.bin") - echo "$b1 $n1 $n2 $b2" >> "$WORK/pairs" - printf 'pair %2d base %s %s ms new %s %s ms\n' "$i" "$b1" "$b2" "$n1" "$n2" >&2 -done - -awk -v rev="$REV" ' - { - lo = $1; hi = $1 - for (i = 2; i <= 4; i++) { if ($i < lo) lo = $i; if ($i > hi) hi = $i } - if (hi > lo * 1.1) { printf "pair %d left out: the machine changed speed\n", NR; next } - n++; b = ($1 + $4) / 2; d = ($2 + $3) / 2 - b - sb += b; sd += d; sd2 += d * d; if (d > 0) up++ - } - END { - if (n < 2) { print "too few pairs to compare"; exit 1 } - mean = sd / n; se = sqrt((sd2 - n * mean * mean) / (n - 1) / n) - printf "%s: %.2f ms\n", rev, sb / n - printf "working tree - %s: %+.3f ms (%+.2f%%), standard error %.3f ms, t = %.1f, slower in %d of %d pairs\n", - rev, mean, 100 * mean / (sb / n), se, (se > 0 ? mean / se : 0), up, n - }' "$WORK/pairs" diff --git a/justfile b/justfile index ffbadbc88..c3b7d5819 100644 --- a/justfile +++ b/justfile @@ -27,10 +27,6 @@ lint: cargo clippy --locked --all-targets --all-features -- -D warnings cargo clippy --locked --all-targets --no-default-features -- -D warnings -# Compare the parse benchmark against a revision, with FORGE_SPEC set to a document -bench-compare rev="main" pairs="12": - benches/compare.sh {{rev}} {{pairs}} - # Check that no other tool's extension is named outside the compatibility layer boundary: cargo test --locked --test boundary diff --git a/rfcs/0001-metadata-resolution-layer.md b/rfcs/0001-metadata-resolution-layer.md index 4176308f2..2620cc6a9 100644 --- a/rfcs/0001-metadata-resolution-layer.md +++ b/rfcs/0001-metadata-resolution-layer.md @@ -68,8 +68,8 @@ Audit of the released aggregate (`openapi.forge.json`, 27.6 MB, sha `99544617… | Native key | Form | Meaning | Compat source (the adapter translates) | |---|---|---|---| | `x-forge-method-name` | string | the method's name | `x-fern-sdk-method-name` | -| `x-forge-group` | **string of names separated by dots**, or **array of strings**, top level first (array elements are not split) | the group path; `""` and `[]` are meant to be the root, but are not yet (see [Known gaps](#known-gaps)) | `x-fern-sdk-group-name` (read the same way as the native key) | -| `x-forge-ignore` | bool, positional | operation/alias → no method; property schema → no field | `x-fern-ignore` (operation) · `x-stainless-skip` (property; a list of targets counts as `false` — inside the adapter) | +| `x-forge-group-name` | **string of names separated by dots**, or **array of strings**, top level first (array elements are not split) | the group path; `""` and `[]` are meant to be the root, but are not yet (see [Known gaps](#known-gaps)) | `x-fern-sdk-group-name` (read the same way as the native key) | +| `x-forge-skip` | bool, positional | operation/alias → no method; property schema → no field | `x-fern-ignore` (operation) · `x-stainless-skip` (property; a list of targets counts as `false` — inside the adapter) | | `x-forge-availability` | `alpha` \| `beta` \| `preview` \| `generally-available` \| `deprecated` | lifecycle stage. The values are Forge's shipped vocabulary (public enum across Rust, WIT, JS); Fern merely coincides | `x-fern-availability` | | `x-forge-model-name` | string | the model's name (components / `$defs` schema) | `x-fern-type-name` | | `x-forge-property-name` | string | the property's field name | `x-fern-property-name` | @@ -81,10 +81,11 @@ Audit of the released aggregate (`openapi.forge.json`, 27.6 MB, sha `99544617… Design notes: - **Explicit position keys over one positional name key.** Fern used three name keys because its reader architecture had one visitor per OpenAPI object kind. We use explicit keys for a different, real reason: tools in the wild mangle `$ref` structure (inlining targets, dropping siblings), and when placement lies, the key name still carries the role — `x-forge-model-name` on a property schema can be ignored or flagged, where a positional `x-forge-name` would silently become a field name. Explicitness here is corruption-detection, not verbosity. -- **One exclusion word, two positions.** "Excluded from generated surfaces" is one concept at two scopes; two words (`ignore` + `skip`) would be a memory tax for no disambiguation, and `skip` is Stainless's word. -- **Groups as a dotted string or an array.** The dotted string is how Cloudflare's specs write a group path today, and Forge CI's lint requires it, so moving a team to `x-forge-group` is a rename of the key, with the value unchanged. The array is JSON's own way to write a path, and the way to write a group name that has a dot in it. Both are read exactly as forge has always read groups. +- **`model-name`, not `type-name` or `schema-name`.** The key sets `Model::name`, and the Rust API, the WIT (`model-info`, `get-model`) and the JS runtime all call the thing a model, so the key uses the word a plugin author sees. `type-name`, Fern's word, would promise more than the key does: a `Type` is any type, scalars, arrays and unions included, of which `Type::Model` is one kind, and only a schema that becomes a model is named by the key. `schema-name` is taken: `Model::schema_name` is the key under `components.schemas`, which is the very name this key replaces. `class-name` and `struct-name` are words of particular target languages, and `title` is OpenAPI's own display string. `method-name` and `model-name` are named after what they set in the model; `property-name` and `parameter-name` both set a field's name, so they are named after their position instead, for the reason above. +- **One exclusion word, two positions.** "Excluded from generated surfaces" is one concept at two scopes; two words would be a memory tax for no disambiguation. The word is `skip` rather than `ignore`: it says what happens (the thing is not generated), where `ignore` reads as though the reader ignores the key; it stands further apart from `x-forge-hidden` (generated, but not listed); and it reads naturally should the key one day take a list of targets. That Stainless uses the same word, and Fern the other, played no part. +- **Groups as a dotted string or an array.** The dotted string is how Cloudflare's specs write a group path today, and Forge CI's lint requires it, so moving a team to `x-forge-group-name` is a rename of the key, with the value unchanged. The array is JSON's own way to write a path, and the way to write a group name that has a dot in it. Both are read exactly as forge has always read groups. - **The dotted string is Cloudflare's, not Fern's.** Fern reads a string `x-fern-sdk-group-name` as *one* group (`"a.b"` is a group named `a.b`) and an array as a path; it never splits on dots. The release pipeline converts Cloudflare's dotted strings to arrays before Fern sees them (the released aggregate has 3,591 arrays and no strings). Forge has always split them, and the adapter reads the Fern key the same way as the native one, so for a string with a dot the adapter and Fern disagree. That is deliberate: the specs forge reads write Fern's key in Cloudflare's convention, and reading the two keys alike keeps a key rename from changing a value's meaning. -- **Every native key begins with `x-forge-`.** The `-name` keys (`method-name`, `model-name`, `property-name`, `parameter-name`) say the same kind of thing and are spelled alike. `x-sensitive` predates the rule; it becomes `x-forge-sensitive`, and the old name stays readable as a deprecated alias (155 production uses), so no spec breaks. A deprecated alias is part of the native vocabulary, not of the compatibility layer: it keeps working with compat off, and deleting compat does not remove it. +- **Every native key begins with `x-forge-`.** The `-name` keys (`method-name`, `group-name`, `model-name`, `property-name`, `parameter-name`) say the same kind of thing and are spelled alike. `x-forge-group-name` holds a path rather than one name, as `x-fern-sdk-group-name` does; keeping the suffix makes moving a team a rename of the key's prefix, and keeps it apart from the legacy `x-forge-group-info`, which Forge CI bans. FORGE-3 expects to rename it to `x-forge-namespace`. `x-sensitive` predates the rule; it becomes `x-forge-sensitive`, and the old name stays readable as a deprecated alias (155 production uses), so no spec breaks. A deprecated alias is part of the native vocabulary, not of the compatibility layer: it keeps working with compat off, and deleting compat does not remove it. - **No misplacement validation in v1.** A known native key in a wrong position is silently ignored, exactly as Fern's keys are today. Adding validation later is a pure tightening that breaks no valid spec. ## Resolution rules @@ -94,7 +95,7 @@ Design notes: 3. **Same key twice** → error (today's `duplicate field` behavior, unchanged). 4. **Empty or punctuation-only values** behave as today, which depends on the key: - `x-forge-property-name`, `x-forge-parameter-name` and `x-forge-model-name`: a value with no letters or digits, empty included, is ignored, and the field or model is named after its wire name (the property, parameter or schema name). - - `x-forge-method-name` and `x-forge-group`: an empty method name, or an empty group path (`""` or `[]`), means the operation or alias is not a method. For the empty group path this is the current behavior, not the intended one: see [Known gaps](#known-gaps). A method name, or any group name in the path, that is not empty but has no letters or digits is an error. + - `x-forge-method-name` and `x-forge-group-name`: an empty method name, or an empty group path (`""` or `[]`), means the operation or alias is not a method. For the empty group path this is the current behavior, not the intended one: see [Known gaps](#known-gaps). A method name, or any group name in the path, that is not empty but has no letters or digits is an error. - Validation errors name the key the author actually wrote (provenance), so existing Fern-keyed error expectations hold. 5. **Dual-writing** (native and compat keys carrying the same value) is the supported pattern for keeping specs portable across engine versions during the transition: old engines read the compat key, new engines apply native. @@ -102,7 +103,7 @@ Design notes: ### An empty group path -- **Intended:** `x-forge-group: []` (or `""`) attaches the method to the root, in no group. An operation without `x-forge-group` at all is still not a method. +- **Intended:** `x-forge-group-name: []` (or `""`) attaches the method to the root, in no group. An operation without `x-forge-group-name` at all is still not a method. - **Today:** an empty group path makes no method, exactly like a missing key. `add_method` in `src/link.rs` returns before building the method when the path is empty; `Method::group`'s docs in `src/lib.rs` say so; and `tests/parse/groups.rs::empty_group_name_is_no_group` and the empty-group alias case in `tests/parse/aliases.rs` assert it. - **Why it is not fixed here:** the model has no place for a root method. Methods exist only inside groups (`Forge::groups` → `Group::methods`); `Forge` has top-level models but no top-level methods, and neither does the WIT. The fix needs a root method list in the model, a way for plugins to read it (a new WIT function is additive; a new field in an existing record is not), and generators that render root methods. That is beyond FORGE-1, which leaves the model unchanged. - **Impact:** none today. The released aggregate has no empty group path and no method name without a group, so the fix will not change its output. @@ -152,6 +153,7 @@ Properties, all deliberate: - **Structural boundary — compiler-enforced (built-in Rust):** every vendor key literal is private to its adapter; the only things outside `compat/` that can touch vendor knowledge are the aggregator's per-object resolvers and name lookups, which `link/resolve.rs` calls. The name lookups are how a vendor key's name reaches an error message (rule 4's provenance) without `link` holding the literal: it reaches `link` as data, never as code that matches on it. The feature gate makes compat removable, and builds without the `compat` feature keep proving it. - **Textual boundary — `tests/boundary.rs`:** scans `src/`, `tests/`, and `packages/forge/` (including the shipped `plugin/forge.wit`) for `x-fern` / `x-stainless` outside `src/compat/` and `tests/compat/`. Doc comments count — moving the WIT's "from `x-fern-availability`" attribution to `x-forge-availability` is part of the work. The test runs wherever `cargo test` runs; a `just boundary` alias targets it. +- **Compat off is compiled in CI, not tested — on purpose.** The `compat` feature is a tool for organizing the code: it keeps the vendor knowledge in one place that can be deleted, rather than being a build that anyone ships. CI's clippy step already builds the crate without it (`--no-default-features`), so the code can't quietly come to depend on the layer. But CI runs the tests only with `--all-features`. A second test run with compat off would compile the crate and every test binary again, for 30–60 s on each push, to cover a configuration nothing ships. The compat-off run in *Verification* was done locally. - **Criterion mapping:** (1) nothing outside the layer references `x-fern-*` → the two mechanisms above; (2) output unchanged → zero model-shape changes, Fern-keyed specs resolve identically (equivalence tests + the full-aggregate native-vs-wasm byte-equality bench); (3) tests per primitive → below. ## Test plan @@ -172,7 +174,7 @@ The implementation was checked against the unmodified engine on the released agg - **Across the plugin boundary.** `tests/wasm.rs`, including the ignored `bench` on the aggregate, reports identical output and dump, native versus WASM, for 1,317 groups and 3,381 files; `wit_is_current` holds with the regenerated WIT. - **Independence.** `cargo test --no-default-features --features cli` (compat off) passes all 445 tests of the native suite, including the deprecated `x-sensitive` alias; with compat on, 498 pass. Clippy is clean in the library-alone, compat-off and all-features configurations. - **The checks can fail.** Breaking the precedence rule (keeping the last value written, whatever its tier) fails 11 compat tests, and 1 `tests/parse` test, the deprecated `x-sensitive` one, with compat on and off; planting a vendor name in a doc comment fails the boundary test, naming the file and line. -- **Performance.** Measured with `benches/compare.sh` (`just bench-compare`): parse and link of the aggregate, paired against the unmodified engine, each pair run base–new–new–base so that drift in the machine's speed cancels, and pairs in which the machine changed speed left out. On the final code (`4e31404`), over 12 pairs, it is 0.8% slower: +0.34 ms of 42.0 ms, standard error 0.18 ms (t = 1.9), slower in 9 of 12 pairs. Earlier runs during the work put it between −0.1% and +1.1%, depending on the machine's speed state. Peak memory is 68.80 MB against 68.75 MB (+0.07%), with the same peak allocation count. Parsing is under a tenth of a whole `forge` run on the aggregate (about 0.5 s), so the end-to-end change is under 0.1%. +- **Performance.** Measured with the divan `parse` bench (parse and link of the aggregate), paired against the unmodified engine: each pair ran base–new–new–base so that drift in the machine's speed cancels, took the fastest sample of each run, and left out pairs whose four runs differed by more than 10%. On the final code, over 12 pairs, it is 0.8% slower: +0.34 ms of 42.0 ms, standard error 0.18 ms (t = 1.9), slower in 9 of 12 pairs. Earlier runs during the work put it between −0.1% and +1.1%, depending on the machine's speed state. Peak memory is 68.80 MB against 68.75 MB (+0.07%), with the same peak allocation count. Parsing is under a tenth of a whole `forge` run on the aggregate (about 0.5 s), so the end-to-end change is under 0.1%. - *Where it went.* Profiling (samply, with line tables, attributing inlined frames) found no hot spot of the layer's own: most of the difference is LLVM inlining parts of serde_json's key loop into `SchemaObject::read_object` and laying the code out differently, which moves with any change. Three things were measured and kept: each table turns away keys outside its prefix (`x-forge-`, `x-fern-`, `x-stainless-`) before matching, and only `x-` keys are asked about, so a skipped key costs one compare; and a `Hit` carries only its field and slot, the key's name looked up by slot when an error needs it, so the reader's per-key result fits in registers (16 bytes, as before, not 24). - *Ruling things out.* Unpaired, sequential runs had suggested +1.5–1.9%; that was the machine warming up through the session, which the ABBA order removes. A single run of either binary differs from the next by about as much as the change measured here, so a smaller difference needs more pairs, not a better single run. @@ -185,8 +187,8 @@ Non-breaking. `x-sensitive` keeps working, deprecated in favor of `x-forge-sensi - **Resolve-after-parse** (keep all `x-` keys as raw JSON, normalize in a second pass): most literal reading of "a resolution step", trivially provenance-aware — but it must retain every schema-level extension on the 27 MB aggregate (5,366 `x-auditable` alone) unless the reader consults a key table anyway, at which point it collapses into the chosen design with extra memory and a reimplementation of duplicate-key errors. Rejected on performance and diff size. - **Vendor-trait machinery** (a `Vendor` trait with Fern/Stainless/Scalar impls): no primitive overlaps across vendors today; the key table grows rows when one does. Rejected as over-engineering for now. - **One positional `x-forge-name`**: mirrors the model's single `Name` concept and is cleaner in the abstract — but when toolchains inline `$ref` targets, the model's name key rides into property positions and silently renames fields. Explicit keys turn that corruption into something ignorable or flaggable. Rejected on robustness. -- **Separate `x-forge-skip` for properties**: two words for one concept, and `skip` is Stainless's word. Rejected. -- **Arrays only for `x-forge-group`**: one form, which every tool reads the same way, and loosening later would break nothing. Rejected: Cloudflare's specs write dotted strings, so every migration would reshape values as well as rename keys, and Forge CI's lint would need an array rule. +- **Separate words for an operation and a property** (`x-forge-ignore` and `x-forge-skip`, as Fern and Stainless split them): two words for one concept. Rejected. +- **Arrays only for `x-forge-group-name`**: one form, which every tool reads the same way, and loosening later would break nothing. Rejected: Cloudflare's specs write dotted strings, so every migration would reshape values as well as rename keys, and Forge CI's lint would need an array rule. - **Reading a string `x-fern-sdk-group-name` as one group, as Fern does**: faithful to Fern, but the native and Fern keys would then read the same value differently, and the specs forge reads use dots. Rejected. - **Error on native/compat disagreement**: the strictest conflict rule, and consistent with the engine's general strictness — deferred as an open question in FORGE-4 in favor of silent native-wins (zero comparison machinery, no cost for correct specs; the drift risk is documented). - **Full crate split now** (a separate `metadata` member crate): delivers "separate library" literally today. The root `Cargo.toml` is already a `[workspace]`, so adding a member does not mean moving the engine; the real cost is that a separate crate cannot depend on the engine, so `Availability` and the other primitive value types would move into it and be re-exported by `forge`. Deferred: the pure contract keeps that move mechanical for when the first standalone consumer exists. @@ -196,7 +198,7 @@ Non-breaking. `x-sensitive` keeps working, deprecated in favor of `x-forge-sensi | Ticket | What | |---|---| -| FORGE-3 | Vocabulary renames in one breaking release: `group` → `namespace`, plus the availability word review; `x-forge-group`/`x-forge-availability` become deprecated aliases (one table row each, the same tier `x-sensitive` uses). | +| FORGE-3 | Vocabulary renames in one breaking release: `group` → `namespace`, plus the availability word review; `x-forge-group-name`/`x-forge-availability` become deprecated aliases (one table row each, the same tier `x-sensitive` uses). | | FORGE-4 | The versioning RFC: the four contracts, the rules this RFC assumes, and open questions (1.0 timing, warning channel, vendor-key EoL policy). | | FORGE-5 | Informational Forge CI lint flagging vendor keys the engine doesn't read, sourced from this layer's key table. | | (FORGE-1 follow-ups) | cf codebase Fern→Forge rename; dotted `operationId` derivation behind an opt-in flag. | diff --git a/src/compat/fern.rs b/src/compat/fern.rs index 925f7e47a..1fbf2fcab 100644 --- a/src/compat/fern.rs +++ b/src/compat/fern.rs @@ -3,7 +3,7 @@ //! | Key | Says | Format | //! |---|---|---| //! | `x-fern-sdk-method-name` | the name of the method | a string | -//! | `x-fern-sdk-group-name` | the group path of the method | as `x-forge-group`: an array of group names, or one string of them separated by dots | +//! | `x-fern-sdk-group-name` | the group path of the method | as `x-forge-group-name`: an array of group names, or one string of them separated by dots | //! | `x-fern-ignore` | the definition makes no method | a boolean | //! | `x-fern-availability` | the stage of the API | a string, one of the native spellings | //! | `x-fern-parameter-name` | the name of a parameter's field | a string | @@ -11,7 +11,7 @@ //! | `x-fern-type-name` | the name of a model | a string | //! //! A group path written as one string is split on dots, as for -//! `x-forge-group`, which Fern itself does not do: Fern reads a string as a +//! `x-forge-group-name`, which Fern itself does not do: Fern reads a string as a //! single group. A document written for Fern uses the array, which means the //! same in both. //! @@ -45,7 +45,7 @@ pub(super) fn definition(key: &str) -> Option> { Some(match key { METHOD_NAME => Hit::compat(DefinitionField::Method, 0), GROUP_NAME => Hit::compat(DefinitionField::Group, 1), - IGNORE => Hit::compat(DefinitionField::Ignore(BoolFormat::Bool), 2), + IGNORE => Hit::compat(DefinitionField::Skip(BoolFormat::Bool), 2), AVAILABILITY => Hit::compat(DefinitionField::Availability, 3), _ => return None, }) diff --git a/src/compat/stainless.rs b/src/compat/stainless.rs index 320957172..caa76ba0c 100644 --- a/src/compat/stainless.rs +++ b/src/compat/stainless.rs @@ -23,7 +23,7 @@ pub(super) fn schema(key: &str) -> Option> { return None; } Some(match key { - SKIP => Hit::compat(SchemaField::Ignore(BoolFormat::BoolOrTargets), 0), + SKIP => Hit::compat(SchemaField::Skip(BoolFormat::BoolOrTargets), 0), _ => return None, }) } @@ -37,7 +37,7 @@ mod tests { fn the_skip_is_read_on_a_schema_as_a_boolean_or_targets_and_not_native() { let hit = schema(SKIP).unwrap(); assert_eq!(hit.tier, Tier::Compat, "{SKIP}"); - assert!(matches!(hit.field, SchemaField::Ignore(BoolFormat::BoolOrTargets))); + assert!(matches!(hit.field, SchemaField::Skip(BoolFormat::BoolOrTargets))); } #[test] diff --git a/src/lib.rs b/src/lib.rs index df76e0b69..1274ce41e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -226,7 +226,7 @@ impl std::fmt::Debug for Group<'_> { /// own group and name, with the `x-forge-aliases` extension: an array of /// objects, each an alias that is a method of the operation. The /// operation's own group and name then make no method, and its own -/// `x-forge-ignore` does not stop its aliases. An empty array makes no +/// `x-forge-skip` does not stop its aliases. An empty array makes no /// method, an element that is not an object is ignored, and a value that /// is not an array is an error. Aliases do not nest: an `x-forge-aliases` /// in an alias is ignored, as are its keys that are not extensions. @@ -236,7 +236,7 @@ impl std::fmt::Debug for Group<'_> { /// [`availability`](Method::availability), whether it is /// [hidden](Method::is_hidden), its [`confirmation`](Method::confirmation) /// and its [`extensions`](Method::extensions), and whether it is ignored -/// with `x-forge-ignore`. None of these come from the operation: an alias +/// with `x-forge-skip`. None of these come from the operation: an alias /// without an extension does not have the operation's. The rest, from the /// HTTP method and path to the responses, is the operation's, and every /// alias has it. Each alias is a method of its own group, so a model one @@ -264,12 +264,12 @@ impl<'f> Method<'f> { /// Returns the group the method is in. /// - /// Corresponds to the `x-forge-group` extension of the operation, or + /// Corresponds to the `x-forge-group-name` extension of the operation, or /// of the alias the method comes from: a string of group names /// separated by dots, such as `accounts.members`, or an array with one /// group name per element, top level first, whose elements are not /// split. An operation or alias without one, with an empty one, or - /// with `x-forge-ignore: true` is not a method. A group name with no + /// with `x-forge-skip: true` is not a method. A group name with no /// letters or digits is an error. pub fn group(self) -> Group<'f> { self.forge.group(self.data().group) @@ -339,7 +339,7 @@ impl<'f> Method<'f> { /// /// Corresponds to the `x-forge-hidden` extension of the operation, a /// boolean, which is false without it. A value that is not a boolean - /// is an error, as for `x-forge-ignore`. Unlike `x-forge-ignore`, a + /// is an error, as for `x-forge-skip`. Unlike `x-forge-skip`, a /// hidden operation is still a method. pub fn is_hidden(self) -> bool { self.data().hidden @@ -499,7 +499,7 @@ impl<'f> Method<'f> { /// An extension is a key of the /// [Operation Object](https://spec.openapis.org/oas/latest.html#operation-object) /// that starts with `x-`. Those forge reads, `x-forge-method-name`, - /// `x-forge-group`, `x-forge-ignore`, `x-forge-availability`, + /// `x-forge-group-name`, `x-forge-skip`, `x-forge-availability`, /// `x-forge-hidden`, `x-forge-require-confirmation` and /// `x-forge-aliases`, are left out, and so are the keys the /// compatibility layer reads in their place, when forge is built with diff --git a/src/link.rs b/src/link.rs index 197ab42da..f78561134 100644 --- a/src/link.rs +++ b/src/link.rs @@ -565,7 +565,7 @@ impl<'d> Builder<'d> { http_method: HttpMethod, ) { let doc = self.doc; - if definition.ignore.value { + if definition.skip.value { return; } if definition.method_name.value.is_empty() { @@ -1595,7 +1595,7 @@ impl<'d> Builder<'d> { Schema::Bool(true) => {} Schema::Object(object) => { let object = doc.get(object); - if object.ignore { + if object.skip { continue; } extension = object.field_name; diff --git a/src/link/openapi.rs b/src/link/openapi.rs index 9041ee621..14925079c 100644 --- a/src/link/openapi.rs +++ b/src/link/openapi.rs @@ -265,7 +265,7 @@ impl Val { struct DefinitionTiers { method_name: Tier, group_names: Tier, - ignore: Tier, + skip: Tier, availability: Tier, } @@ -292,7 +292,7 @@ pub(super) struct Definition { /// The group path, top level first, or empty for none. pub(super) group_names: Val>, /// Whether the definition makes no method. - pub(super) ignore: Val, + pub(super) skip: Val, /// Whether the method is left out of listings. pub(super) hidden: bool, /// The sentence to confirm with, or `None` for none. An empty one is @@ -451,7 +451,7 @@ pub(super) struct SchemaObject { pub(super) field_name: Str, /// Whether a property's schema is marked to be left out: it makes no /// field. - pub(super) ignore: bool, + pub(super) skip: bool, } /// The tier of the key that gave each extension value of a [`SchemaObject`] @@ -460,7 +460,7 @@ pub(super) struct SchemaObject { struct SchemaTiers { model_name: Tier, field_name: Tier, - ignore: Tier, + skip: Tier, sensitive: Tier, } @@ -1319,9 +1319,9 @@ impl Definition { let names = map.next_value_seed(seed::(doc))?; self.group_names.set(&hit, names, &mut tiers.group_names); } - DefinitionField::Ignore(format) => { - let ignore = read_bool(map, format)?; - self.ignore.set(&hit, ignore, &mut tiers.ignore); + DefinitionField::Skip(format) => { + let skip = read_bool(map, format)?; + self.skip.set(&hit, skip, &mut tiers.skip); } DefinitionField::Availability => { let availability = map.next_value_seed(seed::(doc))?; @@ -1791,10 +1791,10 @@ impl ReadObject for SchemaObject { schema.field_name = name; } } - SchemaField::Ignore(format) => { - let ignore = read_bool(&mut map, format)?; - if takes(&hit, &mut tiers.ignore) { - schema.ignore = ignore; + SchemaField::Skip(format) => { + let skip = read_bool(&mut map, format)?; + if takes(&hit, &mut tiers.skip) { + schema.skip = skip; } } SchemaField::Sensitive => { diff --git a/src/metadata/mod.rs b/src/metadata/mod.rs index 9eba8e7ff..84b91cf2c 100644 --- a/src/metadata/mod.rs +++ b/src/metadata/mod.rs @@ -129,7 +129,7 @@ pub(crate) enum DefinitionField { /// no group. Group, /// Whether the definition makes no method. - Ignore(BoolFormat), + Skip(BoolFormat), /// The stage of the API the method is in. Availability, /// Whether the method is left out of listings. @@ -153,7 +153,7 @@ pub(crate) enum SchemaField { /// The name of a property's field. PropertyName, /// Whether the property makes no field. - Ignore(BoolFormat), + Skip(BoolFormat), /// Whether the schema's value is a secret. Sensitive, } @@ -183,8 +183,8 @@ pub(crate) fn definition(key: &str) -> Option> { } Some(match key { METHOD_NAME => Hit::native(DefinitionField::Method, 0), - GROUP => Hit::native(DefinitionField::Group, 1), - IGNORE => Hit::native(DefinitionField::Ignore(BoolFormat::Bool), 2), + GROUP_NAME => Hit::native(DefinitionField::Group, 1), + SKIP => Hit::native(DefinitionField::Skip(BoolFormat::Bool), 2), AVAILABILITY => Hit::native(DefinitionField::Availability, 3), HIDDEN => Hit::native(DefinitionField::Hidden, 4), CONFIRMATION => Hit::native(DefinitionField::Confirmation, 5), @@ -193,8 +193,8 @@ pub(crate) fn definition(key: &str) -> Option> { } const METHOD_NAME: &str = "x-forge-method-name"; -const GROUP: &str = "x-forge-group"; -const IGNORE: &str = "x-forge-ignore"; +const GROUP_NAME: &str = "x-forge-group-name"; +const SKIP: &str = "x-forge-skip"; const AVAILABILITY: &str = "x-forge-availability"; const HIDDEN: &str = "x-forge-hidden"; const CONFIRMATION: &str = "x-forge-require-confirmation"; @@ -209,11 +209,11 @@ const DEPRECATED_SENSITIVE: &str = "x-sensitive"; const COMMANDS: &str = "x-forge-commands"; /// The names of the native keys of an operation or an alias, by slot. -pub(crate) const DEFINITION_NAMES: [&str; 6] = [METHOD_NAME, GROUP, IGNORE, AVAILABILITY, HIDDEN, CONFIRMATION]; +pub(crate) const DEFINITION_NAMES: [&str; 6] = [METHOD_NAME, GROUP_NAME, SKIP, AVAILABILITY, HIDDEN, CONFIRMATION]; /// The names of the native keys of a Parameter Object, by slot. pub(crate) const PARAMETER_NAMES: [&str; 1] = [PARAMETER_NAME]; /// The names of the native keys of a Schema Object, by slot. -pub(crate) const SCHEMA_NAMES: [&str; 5] = [MODEL_NAME, PROPERTY_NAME, IGNORE, SENSITIVE, DEPRECATED_SENSITIVE]; +pub(crate) const SCHEMA_NAMES: [&str; 5] = [MODEL_NAME, PROPERTY_NAME, SKIP, SENSITIVE, DEPRECATED_SENSITIVE]; /// The names of the native keys of the document, by slot. pub(crate) const DOCUMENT_NAMES: [&str; 1] = [COMMANDS]; @@ -243,7 +243,7 @@ pub(crate) fn schema(key: &str) -> Option> { Some(match key { MODEL_NAME => Hit::native(SchemaField::ModelName, 0), PROPERTY_NAME => Hit::native(SchemaField::PropertyName, 1), - IGNORE => Hit::native(SchemaField::Ignore(BoolFormat::Bool), 2), + SKIP => Hit::native(SchemaField::Skip(BoolFormat::Bool), 2), SENSITIVE => Hit::native(SchemaField::Sensitive, 3), DEPRECATED_SENSITIVE => Hit::deprecated(SchemaField::Sensitive, 4), _ => return None, @@ -302,8 +302,8 @@ mod tests { [ &[ "x-forge-method-name", - "x-forge-group", - "x-forge-ignore", + "x-forge-group-name", + "x-forge-skip", "x-forge-availability", "x-forge-hidden", "x-forge-require-confirmation", @@ -312,7 +312,7 @@ mod tests { &[ "x-forge-model-name", "x-forge-property-name", - "x-forge-ignore", + "x-forge-skip", "x-forge-sensitive", "x-sensitive", ], diff --git a/src/plugin/ts/tests.rs b/src/plugin/ts/tests.rs index 60dc043b5..58ad0a549 100644 --- a/src/plugin/ts/tests.rs +++ b/src/plugin/ts/tests.rs @@ -370,11 +370,11 @@ fn models() { r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": { - "/a": {"get": {"x-forge-group": ["pet-store", "list_all"], "x-forge-method-name": "get", + "/a": {"get": {"x-forge-group-name": ["pet-store", "list_all"], "x-forge-method-name": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/pet_owner"}}}}}}}, - "/b": {"get": {"x-forge-group": ["pet-store"], "x-forge-method-name": "get", + "/b": {"get": {"x-forge-group-name": ["pet-store"], "x-forge-method-name": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/2fa"}}}}}}}, - "/c": {"get": {"x-forge-group": ["other"], "x-forge-method-name": "get", + "/c": {"get": {"x-forge-group-name": ["other"], "x-forge-method-name": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/2fa"}}}}}}} }, "components": {"schemas": {"pet_owner": {"type": "string"}, "2fa": {"type": "string"}}} diff --git a/src/types.rs b/src/types.rs index 9d5c26b94..06bc6642c 100644 --- a/src/types.rs +++ b/src/types.rs @@ -293,7 +293,7 @@ impl<'f> Object<'f> { /// except in a [`Method::request`](crate::Method::request) and in /// [`Method::headers`](crate::Method::headers). /// - /// A property marked `x-forge-ignore: true`, or whose schema is + /// A property marked `x-forge-skip: true`, or whose schema is /// `false`, has no field. A name in `required` that is not a property is /// ignored. Two fields with equal names are an error. pub fn fields(self) -> impl ExactSizeIterator> { @@ -418,7 +418,7 @@ impl<'f> Field<'f> { /// `readOnly`, an `x-forge-sensitive` next to a `$ref` counts in every /// version of OpenAPI, since tools commonly honour extensions there. A /// value that is not a boolean is an error, as it is for a schema's - /// `x-forge-ignore` and an operation's `x-forge-hidden`. + /// `x-forge-skip` and an operation's `x-forge-hidden`. /// /// A field for a parameter, whether in the request or in /// [`Method::headers`](crate::Method::headers), is sensitive when the diff --git a/tests/cli.rs b/tests/cli.rs index bcb1ea4df..8926f77f0 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -6,7 +6,7 @@ use std::process::{Command, Output, Stdio}; /// A document with one method, `a.get`. const SPEC: &str = r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "get", "responses": {"200": {"description": "ok"}}}}} }"#; diff --git a/tests/compat/common.rs b/tests/compat/common.rs index 86c6b96c4..7899d1d32 100644 --- a/tests/compat/common.rs +++ b/tests/compat/common.rs @@ -36,7 +36,7 @@ pub fn operation_with(keys: &str) -> String { pub fn responding_with(schema: &str, schemas: &str) -> String { format!( r#"{{"openapi": "3.0.3", "info": {{"title": "t", "version": "1"}}, - "paths": {{"/a": {{"get": {{"x-forge-group": ["a"], "x-forge-method-name": "get", + "paths": {{"/a": {{"get": {{"x-forge-group-name": ["a"], "x-forge-method-name": "get", "responses": {{"200": {{"description": "ok", "content": {{"application/json": {{"schema": {schema}}}}}}}}}}}}}}}, "components": {{"schemas": {schemas}}}}}"# ) diff --git a/tests/compat/fern.rs b/tests/compat/fern.rs index bfd0a41ac..7dc3d6998 100644 --- a/tests/compat/fern.rs +++ b/tests/compat/fern.rs @@ -10,7 +10,7 @@ fn method_group_and_availability_are_read_as_native() { r#""x-fern-sdk-group-name": ["accounts"], "x-fern-sdk-method-name": "list", "x-fern-availability": "beta","#, ), &operation_with( - r#""x-forge-group": ["accounts"], "x-forge-method-name": "list", "x-forge-availability": "beta","#, + r#""x-forge-group-name": ["accounts"], "x-forge-method-name": "list", "x-forge-availability": "beta","#, ), ); } @@ -23,19 +23,19 @@ fn every_availability_is_read() { r#""x-fern-sdk-group-name": ["a"], "x-fern-sdk-method-name": "get", "x-fern-availability": "{availability}","# )), &operation_with(&format!( - r#""x-forge-group": ["a"], "x-forge-method-name": "get", "x-forge-availability": "{availability}","# + r#""x-forge-group-name": ["a"], "x-forge-method-name": "get", "x-forge-availability": "{availability}","# )), ); } } -/// As `x-forge-group` reads it, which Fern does not: Fern reads the string +/// As `x-forge-group-name` reads it, which Fern does not: Fern reads the string /// as one group. #[test] fn a_group_written_as_a_string_is_split_on_dots() { assert_same( &operation_with(r#""x-fern-sdk-group-name": "a.b.c", "x-fern-sdk-method-name": "get","#), - &operation_with(r#""x-forge-group": "a.b.c", "x-forge-method-name": "get","#), + &operation_with(r#""x-forge-group-name": "a.b.c", "x-forge-method-name": "get","#), ); } @@ -43,7 +43,7 @@ fn a_group_written_as_a_string_is_split_on_dots() { fn a_group_written_as_an_array_is_not_split() { assert_same( &operation_with(r#""x-fern-sdk-group-name": ["a.b", "c"], "x-fern-sdk-method-name": "get","#), - &operation_with(r#""x-forge-group": ["a.b", "c"], "x-forge-method-name": "get","#), + &operation_with(r#""x-forge-group-name": ["a.b", "c"], "x-forge-method-name": "get","#), ); } @@ -69,7 +69,7 @@ fn a_group_that_is_neither_a_string_nor_an_array_is_an_error() { fn ignore_makes_no_method() { assert_same( &operation_with(r#""x-fern-sdk-group-name": ["a"], "x-fern-sdk-method-name": "get", "x-fern-ignore": true,"#), - &operation_with(r#""x-forge-group": ["a"], "x-forge-method-name": "get", "x-forge-ignore": true,"#), + &operation_with(r#""x-forge-group-name": ["a"], "x-forge-method-name": "get", "x-forge-skip": true,"#), ); assert_eq!( parse(&operation_with( @@ -85,7 +85,7 @@ fn ignore_makes_no_method() { fn ignore_set_to_false_keeps_the_method() { assert_same( &operation_with(r#""x-fern-sdk-group-name": ["a"], "x-fern-sdk-method-name": "get", "x-fern-ignore": false,"#), - &operation_with(r#""x-forge-group": ["a"], "x-forge-method-name": "get","#), + &operation_with(r#""x-forge-group-name": ["a"], "x-forge-method-name": "get","#), ); } @@ -93,7 +93,7 @@ fn ignore_set_to_false_keeps_the_method() { fn a_parameter_name_is_read_as_native() { let parameters = |key: &str| { operation_with(&format!( - r#""x-forge-group": ["a"], "x-forge-method-name": "list", + r#""x-forge-group-name": ["a"], "x-forge-method-name": "list", "parameters": [{{"name": "q", "in": "query", "{key}": "query", "schema": {{"type": "string"}}}}],"# )) }; @@ -152,10 +152,10 @@ fn the_keys_of_an_alias_are_read_too() { "x-fern-ignore", ), &alias( - "x-forge-group", + "x-forge-group-name", "x-forge-method-name", "x-forge-availability", - "x-forge-ignore", + "x-forge-skip", ), ); } @@ -182,7 +182,7 @@ fn a_key_that_is_read_is_not_an_extension_of_the_method() { #[test] fn a_key_that_is_not_read_stays_an_extension_of_the_method() { let forge = parse(&operation_with( - r#""x-forge-group": ["a"], "x-forge-method-name": "get", "x-fern-audiences": ["internal"], "x-fern-pagination": {"offset": "o"},"#, + r#""x-forge-group-name": ["a"], "x-forge-method-name": "get", "x-fern-audiences": ["internal"], "x-fern-pagination": {"offset": "o"},"#, )); let method = forge.groups().next().unwrap().methods().next().unwrap(); @@ -266,7 +266,7 @@ fn a_key_written_twice_is_an_error_naming_it() { fn the_vendor_keys_of_a_kind_of_object_are_read_only_there() { // A name key on an operation, or a method key on a schema, says nothing. let forge = parse(&operation_with( - r#""x-forge-group": ["a"], "x-forge-method-name": "get", "x-fern-property-name": "p", "x-fern-type-name": "T", + r#""x-forge-group-name": ["a"], "x-forge-method-name": "get", "x-fern-property-name": "p", "x-fern-type-name": "T", "x-fern-parameter-name": "q","#, )); let method = forge.groups().next().unwrap().methods().next().unwrap(); diff --git a/tests/compat/native_first.rs b/tests/compat/native_first.rs index 9e0db4d6f..6296e9601 100644 --- a/tests/compat/native_first.rs +++ b/tests/compat/native_first.rs @@ -17,13 +17,13 @@ fn native_wins(native: &str, compat: &str, expected: &str) { #[test] fn the_native_method_wins() { native_wins( - r#""x-forge-group": ["a"], "x-forge-method-name": "native","#, + r#""x-forge-group-name": ["a"], "x-forge-method-name": "native","#, r#""x-fern-sdk-method-name": "vendor","#, - r#""x-forge-group": ["a"], "x-forge-method-name": "native","#, + r#""x-forge-group-name": ["a"], "x-forge-method-name": "native","#, ); assert!( describe(&parse(&operation_with( - r#""x-forge-group": ["a"], "x-forge-method-name": "native", "x-fern-sdk-method-name": "vendor","# + r#""x-forge-group-name": ["a"], "x-forge-method-name": "native", "x-fern-sdk-method-name": "vendor","# ))) .contains("method native") ); @@ -32,36 +32,36 @@ fn the_native_method_wins() { #[test] fn the_native_group_wins() { native_wins( - r#""x-forge-method-name": "get", "x-forge-group": ["native"],"#, + r#""x-forge-method-name": "get", "x-forge-group-name": ["native"],"#, r#""x-fern-sdk-group-name": "vendor.group","#, - r#""x-forge-method-name": "get", "x-forge-group": ["native"],"#, + r#""x-forge-method-name": "get", "x-forge-group-name": ["native"],"#, ); } #[test] fn the_native_availability_wins_and_an_invalid_compat_one_is_not_checked() { native_wins( - r#""x-forge-group": ["a"], "x-forge-method-name": "get", "x-forge-availability": "beta","#, + r#""x-forge-group-name": ["a"], "x-forge-method-name": "get", "x-forge-availability": "beta","#, r#""x-fern-availability": "ga","#, - r#""x-forge-group": ["a"], "x-forge-method-name": "get", "x-forge-availability": "beta","#, + r#""x-forge-group-name": ["a"], "x-forge-method-name": "get", "x-forge-availability": "beta","#, ); } #[test] -fn a_native_ignore_of_false_keeps_the_method_a_compat_one_would_drop() { +fn a_native_skip_of_false_keeps_the_method_a_compat_one_would_drop() { native_wins( - r#""x-forge-group": ["a"], "x-forge-method-name": "get", "x-forge-ignore": false,"#, + r#""x-forge-group-name": ["a"], "x-forge-method-name": "get", "x-forge-skip": false,"#, r#""x-fern-ignore": true,"#, - r#""x-forge-group": ["a"], "x-forge-method-name": "get","#, + r#""x-forge-group-name": ["a"], "x-forge-method-name": "get","#, ); } #[test] -fn a_native_ignore_of_true_drops_the_method_a_compat_one_would_keep() { +fn a_native_skip_of_true_drops_the_method_a_compat_one_would_keep() { native_wins( - r#""x-forge-group": ["a"], "x-forge-method-name": "get", "x-forge-ignore": true,"#, + r#""x-forge-group-name": ["a"], "x-forge-method-name": "get", "x-forge-skip": true,"#, r#""x-fern-ignore": false,"#, - r#""x-forge-group": ["a"], "x-forge-method-name": "get", "x-forge-ignore": true,"#, + r#""x-forge-group-name": ["a"], "x-forge-method-name": "get", "x-forge-skip": true,"#, ); } @@ -69,7 +69,7 @@ fn a_native_ignore_of_true_drops_the_method_a_compat_one_would_keep() { fn the_native_parameter_name_wins() { let parameter = |keys: &str| { operation_with(&format!( - r#""x-forge-group": ["a"], "x-forge-method-name": "list", + r#""x-forge-group-name": ["a"], "x-forge-method-name": "list", "parameters": [{{"name": "q", "in": "query", {keys} "schema": {{"type": "string"}}}}],"# )) }; @@ -116,7 +116,7 @@ fn the_native_property_and_model_names_win() { } #[test] -fn the_native_ignore_of_a_property_wins_over_a_skip() { +fn the_native_skip_of_a_property_wins_over_a_stainless_one() { let property = |keys: &str| { responding_with( &format!(r#"{{"type": "object", "properties": {{"a": {{"type": "string", {keys}}}}}}}"#), @@ -124,14 +124,14 @@ fn the_native_ignore_of_a_property_wins_over_a_skip() { ) }; for keys in [ - r#""x-forge-ignore": false, "x-stainless-skip": true"#, - r#""x-stainless-skip": true, "x-forge-ignore": false"#, + r#""x-forge-skip": false, "x-stainless-skip": true"#, + r#""x-stainless-skip": true, "x-forge-skip": false"#, ] { assert!(describe(&parse(&property(keys))).contains("a:a="), "{keys}"); } for keys in [ - r#""x-forge-ignore": true, "x-stainless-skip": false"#, - r#""x-stainless-skip": false, "x-forge-ignore": true"#, + r#""x-forge-skip": true, "x-stainless-skip": false"#, + r#""x-stainless-skip": false, "x-forge-skip": true"#, ] { assert!(!describe(&parse(&property(keys))).contains("a:a="), "{keys}"); } @@ -141,12 +141,12 @@ fn the_native_ignore_of_a_property_wins_over_a_skip() { fn both_keys_with_the_same_value_are_the_same_as_one() { assert_same( &operation_with( - r#""x-forge-group": ["a", "b"], "x-fern-sdk-group-name": "a.b", + r#""x-forge-group-name": ["a", "b"], "x-fern-sdk-group-name": "a.b", "x-forge-method-name": "get", "x-fern-sdk-method-name": "get", "x-forge-availability": "beta", "x-fern-availability": "beta","#, ), &operation_with( - r#""x-forge-group": ["a", "b"], "x-forge-method-name": "get", "x-forge-availability": "beta","#, + r#""x-forge-group-name": ["a", "b"], "x-forge-method-name": "get", "x-forge-availability": "beta","#, ), ); } @@ -155,8 +155,12 @@ fn both_keys_with_the_same_value_are_the_same_as_one() { fn each_primitive_is_resolved_on_its_own() { // The group is native and the method and availability are not. assert_same( - &operation_with(r#""x-forge-group": ["a"], "x-fern-sdk-method-name": "get", "x-fern-availability": "beta","#), - &operation_with(r#""x-forge-group": ["a"], "x-forge-method-name": "get", "x-forge-availability": "beta","#), + &operation_with( + r#""x-forge-group-name": ["a"], "x-fern-sdk-method-name": "get", "x-fern-availability": "beta","#, + ), + &operation_with( + r#""x-forge-group-name": ["a"], "x-forge-method-name": "get", "x-forge-availability": "beta","#, + ), ); } @@ -167,7 +171,7 @@ fn a_compat_value_of_the_wrong_type_is_an_error_even_when_a_native_key_wins() { r#""x-fern-availability": 1, "x-forge-availability": "beta","#, ] { let err = parse_err(&operation_with(&format!( - r#""x-forge-group": ["a"], "x-forge-method-name": "get", {keys}"# + r#""x-forge-group-name": ["a"], "x-forge-method-name": "get", {keys}"# ))); assert!(err.contains("invalid type"), "{keys}: {err}"); @@ -176,24 +180,24 @@ fn a_compat_value_of_the_wrong_type_is_an_error_even_when_a_native_key_wins() { #[test] fn a_native_key_written_twice_is_an_error_naming_it() { - let err = parse_err(&operation_with(r#""x-forge-ignore": false, "x-forge-ignore": false,"#)); + let err = parse_err(&operation_with(r#""x-forge-skip": false, "x-forge-skip": false,"#)); - assert!(err.contains("duplicate field `x-forge-ignore`"), "{err}"); + assert!(err.contains("duplicate field `x-forge-skip`"), "{err}"); } #[test] fn the_native_group_written_as_a_string_wins() { native_wins( - r#""x-forge-method-name": "get", "x-forge-group": "native.group","#, + r#""x-forge-method-name": "get", "x-forge-group-name": "native.group","#, r#""x-fern-sdk-group-name": ["vendor"],"#, - r#""x-forge-method-name": "get", "x-forge-group": ["native", "group"],"#, + r#""x-forge-method-name": "get", "x-forge-group-name": ["native", "group"],"#, ); } #[test] fn an_error_names_the_native_key_when_the_native_key_gave_the_value() { let err = parse_err(&operation_with( - r#""x-forge-group": ["a"], "x-forge-method-name": "get", "x-forge-availability": "ga","#, + r#""x-forge-group-name": ["a"], "x-forge-method-name": "get", "x-forge-availability": "ga","#, )); assert!(err.contains(r#"GET /a: x-forge-availability "ga" is not"#), "{err}"); @@ -204,14 +208,14 @@ fn only_the_winning_group_and_method_name_are_checked() { // Each compat value is an error on its own, so the document parsing at // all shows it was not checked. native_wins( - r#""x-forge-group": ["a"], "x-forge-method-name": "get","#, + r#""x-forge-group-name": ["a"], "x-forge-method-name": "get","#, r#""x-fern-sdk-group-name": "a..b","#, - r#""x-forge-group": ["a"], "x-forge-method-name": "get","#, + r#""x-forge-group-name": ["a"], "x-forge-method-name": "get","#, ); native_wins( - r#""x-forge-group": ["a"], "x-forge-method-name": "get","#, + r#""x-forge-group-name": ["a"], "x-forge-method-name": "get","#, r#""x-fern-sdk-method-name": "-","#, - r#""x-forge-group": ["a"], "x-forge-method-name": "get","#, + r#""x-forge-group-name": ["a"], "x-forge-method-name": "get","#, ); assert!( parse_err(&operation_with( @@ -221,7 +225,7 @@ fn only_the_winning_group_and_method_name_are_checked() { ); assert!( parse_err(&operation_with( - r#""x-forge-group": ["a"], "x-fern-sdk-method-name": "-","# + r#""x-forge-group-name": ["a"], "x-fern-sdk-method-name": "-","# )) .contains("x-fern-sdk-method-name") ); @@ -243,41 +247,41 @@ fn native_wins_in_an_alias(native: &str, compat: &str, expected: &str, method: & #[test] fn the_native_keys_of_an_alias_win() { native_wins_in_an_alias( - r#""x-forge-group": ["a"], "x-forge-method-name": "native""#, + r#""x-forge-group-name": ["a"], "x-forge-method-name": "native""#, r#""x-fern-sdk-method-name": "vendor""#, - r#""x-forge-group": ["a"], "x-forge-method-name": "native""#, + r#""x-forge-group-name": ["a"], "x-forge-method-name": "native""#, "native", ); native_wins_in_an_alias( - r#""x-forge-method-name": "get", "x-forge-group": ["native"]"#, + r#""x-forge-method-name": "get", "x-forge-group-name": ["native"]"#, r#""x-fern-sdk-group-name": "vendor.group""#, - r#""x-forge-method-name": "get", "x-forge-group": ["native"]"#, + r#""x-forge-method-name": "get", "x-forge-group-name": ["native"]"#, "get", ); native_wins_in_an_alias( - r#""x-forge-group": ["a"], "x-forge-method-name": "get", "x-forge-availability": "beta""#, + r#""x-forge-group-name": ["a"], "x-forge-method-name": "get", "x-forge-availability": "beta""#, r#""x-fern-availability": "ga""#, - r#""x-forge-group": ["a"], "x-forge-method-name": "get", "x-forge-availability": "beta""#, + r#""x-forge-group-name": ["a"], "x-forge-method-name": "get", "x-forge-availability": "beta""#, "get", ); native_wins_in_an_alias( - r#""x-forge-group": ["a"], "x-forge-method-name": "get", "x-forge-ignore": false"#, + r#""x-forge-group-name": ["a"], "x-forge-method-name": "get", "x-forge-skip": false"#, r#""x-fern-ignore": true"#, - r#""x-forge-group": ["a"], "x-forge-method-name": "get""#, + r#""x-forge-group-name": ["a"], "x-forge-method-name": "get""#, "get", ); } #[test] -fn a_native_ignore_in_an_alias_drops_the_method_a_compat_one_would_keep() { +fn a_native_skip_in_an_alias_drops_the_method_a_compat_one_would_keep() { let alias = |keys: &str| { operation_with(&format!( - r#""x-forge-aliases": [{{"x-forge-group": ["a"], "x-forge-method-name": "get", {keys}}}],"# + r#""x-forge-aliases": [{{"x-forge-group-name": ["a"], "x-forge-method-name": "get", {keys}}}],"# )) }; for keys in [ - r#""x-forge-ignore": true, "x-fern-ignore": false"#, - r#""x-fern-ignore": false, "x-forge-ignore": true"#, + r#""x-forge-skip": true, "x-fern-ignore": false"#, + r#""x-fern-ignore": false, "x-forge-skip": true"#, ] { assert_eq!(parse(&alias(keys)).groups().count(), 0, "{keys}"); } diff --git a/tests/compat/stainless.rs b/tests/compat/stainless.rs index 7f4cd1a4c..d778ad997 100644 --- a/tests/compat/stainless.rs +++ b/tests/compat/stainless.rs @@ -3,7 +3,7 @@ use super::common::{assert_same, describe, operation_with, parse, parse_err, responding_with}; -fn with_skip(value: &str) -> String { +fn with_stainless_skip(value: &str) -> String { responding_with( &format!( r#"{{"type": "object", "properties": {{"id": {{"type": "string"}}, "old_id": {{"type": "string", "x-stainless-skip": {value}}}}}}}"# @@ -12,44 +12,47 @@ fn with_skip(value: &str) -> String { ) } -fn with_ignore(value: &str) -> String { +fn with_native_skip(value: &str) -> String { responding_with( &format!( - r#"{{"type": "object", "properties": {{"id": {{"type": "string"}}, "old_id": {{"type": "string", "x-forge-ignore": {value}}}}}}}"# + r#"{{"type": "object", "properties": {{"id": {{"type": "string"}}, "old_id": {{"type": "string", "x-forge-skip": {value}}}}}}}"# ), "{}", ) } #[test] -fn a_skipped_property_is_read_as_an_ignored_one() { - assert_same(&with_skip("true"), &with_ignore("true")); - assert!(!describe(&parse(&with_skip("true"))).contains("old_id")); +fn a_stainless_skip_is_read_as_a_native_one() { + assert_same(&with_stainless_skip("true"), &with_native_skip("true")); + assert!(!describe(&parse(&with_stainless_skip("true"))).contains("old_id")); } #[test] fn a_property_skipped_as_false_keeps_its_field() { - assert_same(&with_skip("false"), &with_ignore("false")); - assert!(describe(&parse(&with_skip("false"))).contains("old_id")); + assert_same(&with_stainless_skip("false"), &with_native_skip("false")); + assert!(describe(&parse(&with_stainless_skip("false"))).contains("old_id")); } #[test] fn a_list_of_targets_is_not_a_skip() { - assert_same(&with_skip(r#"["typescript", "go"]"#), &with_ignore("false")); - assert!(describe(&parse(&with_skip(r#"["typescript"]"#))).contains("old_id")); - assert!(describe(&parse(&with_skip("[]"))).contains("old_id")); + assert_same( + &with_stainless_skip(r#"["typescript", "go"]"#), + &with_native_skip("false"), + ); + assert!(describe(&parse(&with_stainless_skip(r#"["typescript"]"#))).contains("old_id")); + assert!(describe(&parse(&with_stainless_skip("[]"))).contains("old_id")); } #[test] fn a_skip_that_is_not_a_boolean_or_a_list_is_an_error() { - let err = parse_err(&with_skip(r#""yes""#)); + let err = parse_err(&with_stainless_skip(r#""yes""#)); assert!(err.contains("a boolean or an array of targets"), "{err}"); } #[test] fn a_list_of_targets_is_an_error_for_the_native_key() { - let err = parse_err(&with_ignore(r#"["typescript"]"#)); + let err = parse_err(&with_native_skip(r#"["typescript"]"#)); assert!(err.contains("invalid type"), "{err}"); } @@ -67,7 +70,7 @@ fn a_skip_written_twice_is_an_error_naming_it() { #[test] fn a_skip_on_an_operation_says_nothing_and_stays_an_extension() { let forge = parse(&operation_with( - r#""x-forge-group": ["a"], "x-forge-method-name": "get", "x-stainless-skip": true,"#, + r#""x-forge-group-name": ["a"], "x-forge-method-name": "get", "x-stainless-skip": true,"#, )); let method = forge.groups().next().unwrap().methods().next().unwrap(); diff --git a/tests/parse/aliases.rs b/tests/parse/aliases.rs index bcb6c580b..8ff74cbb1 100644 --- a/tests/parse/aliases.rs +++ b/tests/parse/aliases.rs @@ -10,7 +10,7 @@ fn aliased(aliases: &str, fields: &str) -> String { r#"{{ "openapi": "3.0.3", "info": {{"title": "t", "version": "1"}}, "paths": {{"/a/{{id}}/status": {{"patch": {{{fields} - "x-forge-group": ["a", "status"], "x-forge-method-name": "edit", + "x-forge-group-name": ["a", "status"], "x-forge-method-name": "edit", "x-forge-aliases": {aliases}, "summary": "Edit status", "description": "Changes the status.", "parameters": [{{"name": "id", "in": "path", "required": true, "schema": {{"type": "string"}}}}], @@ -39,8 +39,8 @@ fn methods(forge: &Forge) -> Vec { } const PAUSE_AND_TERMINATE: &str = r#"[ - {"x-forge-group": ["a"], "x-forge-method-name": "pause"}, - {"x-forge-group": ["a"], "x-forge-method-name": "terminate"} + {"x-forge-group-name": ["a"], "x-forge-method-name": "pause"}, + {"x-forge-group-name": ["a"], "x-forge-method-name": "terminate"} ]"#; #[test] @@ -77,10 +77,10 @@ fn aliases_share_the_rest_of_the_operation() { fn each_alias_has_its_own_availability_hidden_confirmation_and_extensions() { let forge = parse(&aliased( r#"[ - {"x-forge-group": ["a"], "x-forge-method-name": "pause", + {"x-forge-group-name": ["a"], "x-forge-method-name": "pause", "x-forge-availability": "generally-available", "x-forge-hidden": true, "x-forge-params": {"status": {"default": "pause", "hidden": true}}}, - {"x-forge-group": ["a"], "x-forge-method-name": "terminate", + {"x-forge-group-name": ["a"], "x-forge-method-name": "terminate", "x-forge-availability": "beta", "x-forge-require-confirmation": "Sure?", "x-a": 1, "x-b": 2} ]"#, "", @@ -111,7 +111,7 @@ fn each_alias_has_its_own_availability_hidden_confirmation_and_extensions() { #[test] fn alias_does_not_fall_back_to_the_operation() { let forge = parse(&aliased( - r#"[{"x-forge-group": ["a"], "x-forge-method-name": "pause"}]"#, + r#"[{"x-forge-group-name": ["a"], "x-forge-method-name": "pause"}]"#, r#""x-forge-availability": "beta", "x-forge-hidden": true, "x-forge-require-confirmation": "Sure?", "x-forge-params": {"status": {}},"#, )); @@ -128,10 +128,10 @@ fn alias_does_not_fall_back_to_the_operation() { fn alias_without_a_group_or_name_is_not_a_method() { let forge = parse(&aliased( r#"[ - {"x-forge-group": ["a"], "x-forge-method-name": "pause"}, - {"x-forge-group": ["a"]}, + {"x-forge-group-name": ["a"], "x-forge-method-name": "pause"}, + {"x-forge-group-name": ["a"]}, {"x-forge-method-name": "resume"}, - {"x-forge-group": [], "x-forge-method-name": "restart"} + {"x-forge-group-name": [], "x-forge-method-name": "restart"} ]"#, "", )); @@ -142,7 +142,7 @@ fn alias_without_a_group_or_name_is_not_a_method() { #[test] fn element_that_is_not_an_object_is_ignored() { let forge = parse(&aliased( - r#"["pause", null, 1, true, ["a"], {"x-forge-group": ["a"], "x-forge-method-name": "pause"}]"#, + r#"["pause", null, 1, true, ["a"], {"x-forge-group-name": ["a"], "x-forge-method-name": "pause"}]"#, "", )); @@ -159,7 +159,7 @@ fn empty_aliases_make_no_method() { #[test] fn aliases_that_are_not_an_array_are_an_error() { let err = parse_err(&aliased( - r#"{"x-forge-group": ["a"], "x-forge-method-name": "pause"}"#, + r#"{"x-forge-group-name": ["a"], "x-forge-method-name": "pause"}"#, "", )); @@ -172,11 +172,11 @@ fn alias_and_another_method_with_equal_names_are_an_error() { r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": { - "/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "pause", + "/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "pause", "responses": {"204": {"description": "ok"}}}}, "/b": {"post": {"x-forge-aliases": [ - {"x-forge-group": ["a"], "x-forge-method-name": "resume"}, - {"x-forge-group": ["a"], "x-forge-method-name": "Pause"} + {"x-forge-group-name": ["a"], "x-forge-method-name": "resume"}, + {"x-forge-group-name": ["a"], "x-forge-method-name": "Pause"} ], "responses": {"204": {"description": "ok"}}}} } }"#, @@ -192,8 +192,8 @@ fn alias_and_another_method_with_equal_names_are_an_error() { fn two_aliases_with_equal_names_are_an_error() { let err = parse_err(&aliased( r#"[ - {"x-forge-group": ["a"], "x-forge-method-name": "pause"}, - {"x-forge-group": ["a"], "x-forge-method-name": "pause"} + {"x-forge-group-name": ["a"], "x-forge-method-name": "pause"}, + {"x-forge-group-name": ["a"], "x-forge-method-name": "pause"} ]"#, "", )); @@ -207,12 +207,12 @@ fn two_aliases_with_equal_names_are_an_error() { #[test] fn alias_group_name_without_letters_is_an_error_naming_the_alias() { let err = parse_err(&aliased( - r#"[{"x-forge-group": ["a", "", "b"], "x-forge-method-name": "pause"}]"#, + r#"[{"x-forge-group-name": ["a", "", "b"], "x-forge-method-name": "pause"}]"#, "", )); assert!( - err.contains("PATCH /a/{id}/status: x-forge-aliases[0]: x-forge-group"), + err.contains("PATCH /a/{id}/status: x-forge-aliases[0]: x-forge-group-name"), "{err}" ); } @@ -220,7 +220,7 @@ fn alias_group_name_without_letters_is_an_error_naming_the_alias() { #[test] fn alias_availability_is_checked() { let err = parse_err(&aliased( - r#"[1, {"x-forge-group": ["a"], "x-forge-method-name": "pause", "x-forge-availability": "ga"}]"#, + r#"[1, {"x-forge-group-name": ["a"], "x-forge-method-name": "pause", "x-forge-availability": "ga"}]"#, "", )); @@ -241,18 +241,18 @@ fn repeated_key_forge_reads_in_an_alias_is_an_error() { } #[test] -fn ignored_operation_still_has_its_aliases() { - let forge = parse(&aliased(PAUSE_AND_TERMINATE, r#""x-forge-ignore": true,"#)); +fn skipped_operation_still_has_its_aliases() { + let forge = parse(&aliased(PAUSE_AND_TERMINATE, r#""x-forge-skip": true,"#)); assert_eq!(methods(&forge), ["a.pause", "a.terminate"]); } #[test] -fn ignored_alias_is_not_a_method() { +fn skipped_alias_is_not_a_method() { let forge = parse(&aliased( r#"[ - {"x-forge-group": ["a"], "x-forge-method-name": "pause", "x-forge-ignore": true}, - {"x-forge-group": ["a"], "x-forge-method-name": "terminate", "x-forge-ignore": false} + {"x-forge-group-name": ["a"], "x-forge-method-name": "pause", "x-forge-skip": true}, + {"x-forge-group-name": ["a"], "x-forge-method-name": "terminate", "x-forge-skip": false} ]"#, "", )); @@ -263,8 +263,8 @@ fn ignored_alias_is_not_a_method() { #[test] fn aliases_are_not_extensions() { let forge = parse(&aliased( - r#"[{"x-forge-group": ["a"], "x-forge-method-name": "pause", "x-a": 1, - "x-forge-aliases": [{"x-forge-group": ["b"], "x-forge-method-name": "nested"}]}]"#, + r#"[{"x-forge-group-name": ["a"], "x-forge-method-name": "pause", "x-a": 1, + "x-forge-aliases": [{"x-forge-group-name": ["b"], "x-forge-method-name": "nested"}]}]"#, "", )); @@ -277,7 +277,7 @@ fn aliases_are_not_extensions() { #[test] fn keys_of_an_alias_that_are_not_extensions_are_ignored() { let forge = parse(&aliased( - r#"[{"x-forge-group": ["a"], "x-forge-method-name": "pause", + r#"[{"x-forge-group-name": ["a"], "x-forge-method-name": "pause", "summary": "Pause", "deprecated": false, "path": "/b"}]"#, "", )); @@ -293,7 +293,7 @@ fn operation_without_aliases_keeps_its_own_method() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "get", "responses": {"204": {"description": "ok"}}}}} }"#, ); @@ -307,10 +307,10 @@ fn model_used_by_aliases_in_two_top_level_groups_is_at_the_top_level() { r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": {"/m": {"get": { - "x-forge-group": ["a", "b"], "x-forge-method-name": "get", + "x-forge-group-name": ["a", "b"], "x-forge-method-name": "get", "x-forge-aliases": [ - {"x-forge-group": ["a", "b"], "x-forge-method-name": "get"}, - {"x-forge-group": ["x"], "x-forge-method-name": "get"} + {"x-forge-group-name": ["a", "b"], "x-forge-method-name": "get"}, + {"x-forge-group-name": ["x"], "x-forge-method-name": "get"} ], "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/M"}}}}}}}}, "components": {"schemas": {"M": {"type": "object", "properties": {"id": {"type": "string"}}}}} @@ -328,10 +328,10 @@ fn model_used_by_aliases_in_one_group_is_in_that_group() { r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": {"/m": {"get": { - "x-forge-group": ["x"], "x-forge-method-name": "get", + "x-forge-group-name": ["x"], "x-forge-method-name": "get", "x-forge-aliases": [ - {"x-forge-group": ["a", "b"], "x-forge-method-name": "get"}, - {"x-forge-group": ["a", "c"], "x-forge-method-name": "get"} + {"x-forge-group-name": ["a", "b"], "x-forge-method-name": "get"}, + {"x-forge-group-name": ["a", "c"], "x-forge-method-name": "get"} ], "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/M"}}}}}}}}, "components": {"schemas": {"M": {"type": "object", "properties": {"id": {"type": "string"}}}}} diff --git a/tests/parse/bodies.rs b/tests/parse/bodies.rs index 76b28f6fc..296d30b5a 100644 --- a/tests/parse/bodies.rs +++ b/tests/parse/bodies.rs @@ -21,7 +21,7 @@ fn body_has_its_required_and_description() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"post": {"x-forge-group": ["a"], "x-forge-method-name": "create", + "paths": {"/a": {"post": {"x-forge-group-name": ["a"], "x-forge-method-name": "create", "requestBody": {"required": true, "description": "The pet.", "content": {"application/json": {"schema": {"type": "string"}}}}, "responses": {"204": {"description": "ok"}}}}} @@ -38,7 +38,7 @@ fn body_is_optional_by_default() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"post": {"x-forge-group": ["a"], "x-forge-method-name": "create", + "paths": {"/a": {"post": {"x-forge-group-name": ["a"], "x-forge-method-name": "create", "requestBody": {"content": {"application/json": {"schema": {"type": "string"}}}}, "responses": {"204": {"description": "ok"}}}}} }"#, @@ -52,7 +52,7 @@ fn contents_are_sorted_by_media_type() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"post": {"x-forge-group": ["a"], "x-forge-method-name": "create", + "paths": {"/a": {"post": {"x-forge-group-name": ["a"], "x-forge-method-name": "create", "requestBody": {"content": { "text/plain": {"schema": {"type": "string"}}, "multipart/form-data": {"schema": {"type": "object"}}, @@ -73,7 +73,7 @@ fn json_is_application_json_before_other_json_types() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"post": {"x-forge-group": ["a"], "x-forge-method-name": "create", + "paths": {"/a": {"post": {"x-forge-group-name": ["a"], "x-forge-method-name": "create", "requestBody": {"content": { "application/geo+json": {"schema": {"type": "object"}}, "application/json": {"schema": {"type": "string"}} @@ -90,7 +90,7 @@ fn json_is_otherwise_the_first_json_type() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"patch": {"x-forge-group": ["a"], "x-forge-method-name": "create", + "paths": {"/a": {"patch": {"x-forge-group-name": ["a"], "x-forge-method-name": "create", "requestBody": {"content": { "multipart/form-data": {"schema": {"type": "object"}}, "application/scim+json": {"schema": {"type": "object"}}, @@ -111,7 +111,7 @@ fn json_ignores_case_and_parameters() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"post": {"x-forge-group": ["a"], "x-forge-method-name": "create", + "paths": {"/a": {"post": {"x-forge-group-name": ["a"], "x-forge-method-name": "create", "requestBody": {"content": {"Application/JSON; charset=utf-8": {"schema": {"type": "string"}}}}, "responses": {"204": {"description": "ok"}}}}} }"#, @@ -127,7 +127,7 @@ fn multipart_content_is_typed() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"post": {"x-forge-group": ["a"], "x-forge-method-name": "create", + "paths": {"/a": {"post": {"x-forge-group-name": ["a"], "x-forge-method-name": "create", "requestBody": {"content": {"multipart/form-data": {"schema": { "type": "object", "required": ["file"], "properties": { "file": {"type": "string", "format": "binary"}, @@ -151,7 +151,7 @@ fn octet_stream_content_of_a_binary_string_is_binary() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"post": {"x-forge-group": ["a"], "x-forge-method-name": "create", + "paths": {"/a": {"post": {"x-forge-group-name": ["a"], "x-forge-method-name": "create", "requestBody": {"content": {"application/octet-stream": {"schema": { "type": "string", "format": "binary" }}}}, @@ -169,7 +169,7 @@ fn json_ignores_content_that_is_not_json() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"post": {"x-forge-group": ["a"], "x-forge-method-name": "create", + "paths": {"/a": {"post": {"x-forge-group-name": ["a"], "x-forge-method-name": "create", "requestBody": {"content": { "multipart/form-data": {"schema": {"type": "object", "properties": {"name": {"type": "string"}}}}, "text/plain": {"schema": {"type": "string"}} @@ -189,7 +189,7 @@ fn content_that_is_not_json_without_a_schema_is_unknown() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"post": {"x-forge-group": ["a"], "x-forge-method-name": "create", + "paths": {"/a": {"post": {"x-forge-group-name": ["a"], "x-forge-method-name": "create", "requestBody": {"content": {"application/octet-stream": {}}}, "responses": {"204": {"description": "ok"}}}}} }"#, @@ -204,7 +204,7 @@ fn content_without_a_schema_is_unknown() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"post": {"x-forge-group": ["a"], "x-forge-method-name": "create", + "paths": {"/a": {"post": {"x-forge-group-name": ["a"], "x-forge-method-name": "create", "requestBody": {"content": {"application/json": {}}}, "responses": {"204": {"description": "ok"}}}}} }"#, @@ -219,7 +219,7 @@ fn body_ref_is_followed() { let forge = parse( r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"post": {"x-forge-group": ["a"], "x-forge-method-name": "create", + "paths": {"/a": {"post": {"x-forge-group-name": ["a"], "x-forge-method-name": "create", "requestBody": {"$ref": "#/components/requestBodies/Pet"}, "responses": {"204": {"description": "ok"}}}}}, "components": {"requestBodies": { @@ -239,7 +239,7 @@ fn with_body_schema(version: &str, schema: &str, schemas: &str) -> String { format!( r#"{{ "openapi": "{version}", "info": {{"title": "t", "version": "1"}}, - "paths": {{"/a": {{"post": {{"x-forge-group": ["a"], "x-forge-method-name": "create", + "paths": {{"/a": {{"post": {{"x-forge-group-name": ["a"], "x-forge-method-name": "create", "requestBody": {{"content": {{"application/json": {{"schema": {schema}}}}}}}, "responses": {{"204": {{"description": "ok"}}}}}}}}}}, "components": {{"schemas": {schemas}}} @@ -301,7 +301,7 @@ fn response_content_has_its_schema_description() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "get", "responses": {"200": {"description": "ok", "content": {"application/json": { "schema": {"type": "string", "description": "The name."}}}}}}}} }"#, diff --git a/tests/parse/common.rs b/tests/parse/common.rs index ddfeccfe9..901cf07bb 100644 --- a/tests/parse/common.rs +++ b/tests/parse/common.rs @@ -19,7 +19,7 @@ pub fn responding_with(schema: &str) -> String { format!( r#"{{ "openapi": "3.0.3", "info": {{"title": "t", "version": "1"}}, - "paths": {{"/a": {{"get": {{"x-forge-group": ["a"], "x-forge-method-name": "get", + "paths": {{"/a": {{"get": {{"x-forge-group-name": ["a"], "x-forge-method-name": "get", "responses": {{"200": {{"description": "ok", "content": {{"application/json": {{"schema": {schema}}}}}}}}}}}}}}} }}"# ) @@ -31,7 +31,7 @@ pub fn responding_with_schemas(schema: &str, schemas: &str) -> String { format!( r#"{{ "openapi": "3.0.3", "info": {{"title": "t", "version": "1"}}, - "paths": {{"/a": {{"get": {{"x-forge-group": ["a"], "x-forge-method-name": "get", + "paths": {{"/a": {{"get": {{"x-forge-group-name": ["a"], "x-forge-method-name": "get", "responses": {{"200": {{"description": "ok", "content": {{"application/json": {{"schema": {schema}}}}}}}}}}}}}}}, "components": {{"schemas": {schemas}}} }}"# diff --git a/tests/parse/document.rs b/tests/parse/document.rs index 934a48ae9..cbb9fc30d 100644 --- a/tests/parse/document.rs +++ b/tests/parse/document.rs @@ -103,7 +103,7 @@ fn every_version_of_3_0_3_1_and_3_2_is_read() { let forge = parse(&format!( r#"{{ "openapi": "{version}", "info": {{"title": "t", "version": "1"}}, - "paths": {{"/a": {{"get": {{"x-forge-group": ["a"], "x-forge-method-name": "get", + "paths": {{"/a": {{"get": {{"x-forge-group-name": ["a"], "x-forge-method-name": "get", "responses": {{"204": {{"description": "ok"}}}}}}}}}} }}"# )); @@ -118,7 +118,7 @@ fn errors_are_reported_together() { r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "servers": [{"url": "https://{region}.example.com"}], - "paths": {"/a": {"get": {"x-forge-group": ["a", "", "b"], "x-forge-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group-name": ["a", "", "b"], "x-forge-method-name": "get", "responses": {"204": {"description": "ok"}}}}} }"#, ); diff --git a/tests/parse/extensions.rs b/tests/parse/extensions.rs index 58f214836..14fbd9eb0 100644 --- a/tests/parse/extensions.rs +++ b/tests/parse/extensions.rs @@ -9,7 +9,7 @@ fn operation_with(fields: &str) -> String { r#"{{ "openapi": "3.0.3", "info": {{"title": "t", "version": "1"}}, "paths": {{"/a": {{"get": {{{fields} - "x-forge-group": ["a"], "x-forge-method-name": "get", + "x-forge-group-name": ["a"], "x-forge-method-name": "get", "responses": {{"204": {{"description": "ok"}}}}}}}}}} }}"# ) @@ -85,12 +85,12 @@ fn keys_that_are_not_extensions_are_not_kept() { #[test] fn extensions_forge_reads_are_not_kept() { - let forge = parse(&operation_with(r#""x-forge-ignore": false, "x-a": 1,"#)); + let forge = parse(&operation_with(r#""x-forge-skip": false, "x-a": 1,"#)); let get = method(&forge, "a.get"); assert_eq!(names(&forge, "a.get"), ["x-a"]); - assert_eq!(get.extension("x-forge-group"), None); - assert_eq!(get.extension("x-forge-ignore"), None); + assert_eq!(get.extension("x-forge-group-name"), None); + assert_eq!(get.extension("x-forge-skip"), None); } #[test] @@ -113,9 +113,9 @@ fn extensions_forge_reads_are_still_read() { r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": {"/a": { - "get": {"x-forge-group": ["a", "b"], "x-forge-method-name": "list", "x-a": 1, + "get": {"x-forge-group-name": ["a", "b"], "x-forge-method-name": "list", "x-a": 1, "responses": {"204": {"description": "ok"}}}, - "post": {"x-forge-group": ["a"], "x-forge-method-name": "create", "x-forge-ignore": true, + "post": {"x-forge-group-name": ["a"], "x-forge-method-name": "create", "x-forge-skip": true, "x-a": 1, "responses": {"204": {"description": "ok"}}} }} }"#, @@ -136,9 +136,9 @@ fn repeated_extension_has_the_last_value_in_the_place_of_the_first() { #[test] fn repeated_extension_forge_reads_is_still_an_error() { - let err = parse_err(&operation_with(r#""x-forge-ignore": false, "x-forge-ignore": false,"#)); + let err = parse_err(&operation_with(r#""x-forge-skip": false, "x-forge-skip": false,"#)); - assert!(err.contains("duplicate field `x-forge-ignore`"), "{err}"); + assert!(err.contains("duplicate field `x-forge-skip`"), "{err}"); } #[test] @@ -147,11 +147,11 @@ fn each_method_keeps_its_own_extensions() { r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": { - "/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", "x-a": "get", + "/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "get", "x-a": "get", "responses": {"204": {"description": "ok"}}}}, - "/b": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "list", + "/b": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "list", "responses": {"204": {"description": "ok"}}}}, - "/c": {"post": {"x-forge-group": ["a"], "x-forge-method-name": "create", "x-a": "create", + "/c": {"post": {"x-forge-group-name": ["a"], "x-forge-method-name": "create", "x-a": "create", "x-b": [], "responses": {"204": {"description": "ok"}}}} } }"#, @@ -170,7 +170,7 @@ fn extensions_outside_the_operation_are_not_kept() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "x-root": 1, - "paths": {"/a": {"x-path-item": 1, "get": {"x-forge-group": ["a"], "x-forge-method-name": "get", + "paths": {"/a": {"x-path-item": 1, "get": {"x-forge-group-name": ["a"], "x-forge-method-name": "get", "parameters": [{"name": "q", "in": "query", "x-parameter": 1, "schema": {"type": "string", "x-schema": 1}}], "responses": {"204": {"description": "ok", "x-response": 1}}}}} }"#, @@ -186,11 +186,11 @@ fn operation_of_a_referenced_path_item_keeps_its_extensions() { "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, "paths": { "/a": {"$ref": "#/components/pathItems/A"}, - "/b": {"get": {"x-forge-group": ["b"], "x-forge-method-name": "get", "x-b": 2, + "/b": {"get": {"x-forge-group-name": ["b"], "x-forge-method-name": "get", "x-b": 2, "responses": {"204": {"description": "ok"}}}} }, "components": {"pathItems": { - "A": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", "x-a": 1, + "A": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "get", "x-a": 1, "responses": {"204": {"description": "ok"}}}} }} }"##, diff --git a/tests/parse/groups.rs b/tests/parse/groups.rs index 0e06d3322..36a8d4b9b 100644 --- a/tests/parse/groups.rs +++ b/tests/parse/groups.rs @@ -7,7 +7,7 @@ fn string_group_name_splits_on_dots() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": "accounts.members", "x-forge-method-name": "list", + "paths": {"/a": {"get": {"x-forge-group-name": "accounts.members", "x-forge-method-name": "list", "responses": {"204": {"description": "ok"}}}}} }"#, ); @@ -21,7 +21,7 @@ fn group_name_that_is_neither_a_string_nor_an_array_is_an_error() { let err = parse_err( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": 1, "x-forge-method-name": "list", + "paths": {"/a": {"get": {"x-forge-group-name": 1, "x-forge-method-name": "list", "responses": {"204": {"description": "ok"}}}}} }"#, ); @@ -34,7 +34,7 @@ fn array_group_name_has_a_group_per_element() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["accounts", "members"], "x-forge-method-name": "list", + "paths": {"/a": {"get": {"x-forge-group-name": ["accounts", "members"], "x-forge-method-name": "list", "responses": {"204": {"description": "ok"}}}}} }"#, ); @@ -48,7 +48,7 @@ fn array_element_with_a_dot_is_one_group() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["zones.settings"], "x-forge-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group-name": ["zones.settings"], "x-forge-method-name": "get", "responses": {"204": {"description": "ok"}}}}} }"#, ); @@ -67,9 +67,9 @@ fn groups_with_equal_names_are_one_group() { r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": { - "/a": {"get": {"x-forge-group": ["abuse-reports"], "x-forge-method-name": "list", + "/a": {"get": {"x-forge-group-name": ["abuse-reports"], "x-forge-method-name": "list", "responses": {"204": {"description": "ok"}}}}, - "/b": {"get": {"x-forge-group": ["abuse_reports"], "x-forge-method-name": "get", + "/b": {"get": {"x-forge-group-name": ["abuse_reports"], "x-forge-method-name": "get", "responses": {"204": {"description": "ok"}}}} } }"#, @@ -85,9 +85,9 @@ fn subgroup_and_method_can_share_a_name() { r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": { - "/a": {"delete": {"x-forge-group": ["members"], "x-forge-method-name": "delete", + "/a": {"delete": {"x-forge-group-name": ["members"], "x-forge-method-name": "delete", "responses": {"204": {"description": "ok"}}}}, - "/b": {"post": {"x-forge-group": ["members", "delete"], "x-forge-method-name": "bulk", + "/b": {"post": {"x-forge-group-name": ["members", "delete"], "x-forge-method-name": "bulk", "responses": {"204": {"description": "ok"}}}} } }"#, @@ -116,7 +116,7 @@ fn empty_group_name_is_no_group() { let forge = parse(&format!( r#"{{ "openapi": "3.0.3", "info": {{"title": "t", "version": "1"}}, - "paths": {{"/a": {{"get": {{"x-forge-group": {group}, "x-forge-method-name": "get", + "paths": {{"/a": {{"get": {{"x-forge-group-name": {group}, "x-forge-method-name": "get", "responses": {{"204": {{"description": "ok"}}}}}}}}}} }}"# )); @@ -130,7 +130,7 @@ fn operation_without_a_method_name_is_not_a_method() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], + "paths": {"/a": {"get": {"x-forge-group-name": ["a"], "responses": {"204": {"description": "ok"}}}}} }"#, ); @@ -143,7 +143,7 @@ fn empty_method_name_is_not_a_method() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "", + "paths": {"/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "", "responses": {"204": {"description": "ok"}}}}} }"#, ); @@ -152,14 +152,14 @@ fn empty_method_name_is_not_a_method() { } #[test] -fn ignored_operation_is_not_a_method() { +fn skipped_operation_is_not_a_method() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": {"/a": { - "get": {"x-forge-group": ["a"], "x-forge-method-name": "list", + "get": {"x-forge-group-name": ["a"], "x-forge-method-name": "list", "responses": {"204": {"description": "ok"}}}, - "post": {"x-forge-group": ["a"], "x-forge-method-name": "create", "x-forge-ignore": true, + "post": {"x-forge-group-name": ["a"], "x-forge-method-name": "create", "x-forge-skip": true, "responses": {"204": {"description": "ok"}}} }} }"#, @@ -175,12 +175,15 @@ fn group_name_without_letters_is_an_error() { let err = parse_err(&format!( r#"{{ "openapi": "3.0.3", "info": {{"title": "t", "version": "1"}}, - "paths": {{"/a": {{"get": {{"x-forge-group": {group}, "x-forge-method-name": "list", + "paths": {{"/a": {{"get": {{"x-forge-group-name": {group}, "x-forge-method-name": "list", "responses": {{"204": {{"description": "ok"}}}}}}}}}} }}"# )); - assert!(err.contains("GET /a: x-forge-group has a group name"), "{group}: {err}"); + assert!( + err.contains("GET /a: x-forge-group-name has a group name"), + "{group}: {err}" + ); } } @@ -190,9 +193,9 @@ fn duplicate_method_in_a_group_is_an_error() { r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": { - "/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "list", + "/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "list", "responses": {"204": {"description": "ok"}}}}, - "/b": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "list", + "/b": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "list", "responses": {"204": {"description": "ok"}}}} } }"#, @@ -207,9 +210,9 @@ fn method_names_are_equal_as_names() { r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": { - "/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "list_all", + "/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "list_all", "responses": {"204": {"description": "ok"}}}}, - "/b": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "listAll", + "/b": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "listAll", "responses": {"204": {"description": "ok"}}}} } }"#, @@ -224,11 +227,11 @@ fn groups_are_depth_first() { r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": { - "/a": {"get": {"x-forge-group": ["a", "b", "c"], "x-forge-method-name": "get", + "/a": {"get": {"x-forge-group-name": ["a", "b", "c"], "x-forge-method-name": "get", "responses": {"204": {"description": "ok"}}}}, - "/b": {"get": {"x-forge-group": ["x"], "x-forge-method-name": "get", + "/b": {"get": {"x-forge-group-name": ["x"], "x-forge-method-name": "get", "responses": {"204": {"description": "ok"}}}}, - "/c": {"get": {"x-forge-group": ["a", "d"], "x-forge-method-name": "get", + "/c": {"get": {"x-forge-group-name": ["a", "d"], "x-forge-method-name": "get", "responses": {"204": {"description": "ok"}}}} } }"#, @@ -258,7 +261,7 @@ fn group_description_comes_from_x_forge_commands() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["accounts"], "x-forge-method-name": "list", + "paths": {"/a": {"get": {"x-forge-group-name": ["accounts"], "x-forge-method-name": "list", "responses": {"204": {"description": "ok"}}}}}, "x-forge-commands": {"accounts": {"description": "Accounts."}} }"#, @@ -272,7 +275,7 @@ fn subgroup_description_is_in_its_parents_command() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["accounts", "members"], "x-forge-method-name": "list", + "paths": {"/a": {"get": {"x-forge-group-name": ["accounts", "members"], "x-forge-method-name": "list", "responses": {"204": {"description": "ok"}}}}}, "x-forge-commands": {"accounts": {"groups": {"members": {"description": "Members."}}}} }"#, @@ -294,7 +297,7 @@ fn with_commands(groups: &[&str], commands: &str) -> String { .collect::>() .join(", "); format!( - r#""/{i}": {{"get": {{"x-forge-group": [{group}], "x-forge-method-name": "list", + r#""/{i}": {{"get": {{"x-forge-group-name": [{group}], "x-forge-method-name": "list", "responses": {{"204": {{"description": "ok"}}}}}}}}"# ) }) @@ -363,7 +366,7 @@ fn command_matches_a_group_with_an_equal_name() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["abuse_reports"], "x-forge-method-name": "list", + "paths": {"/a": {"get": {"x-forge-group-name": ["abuse_reports"], "x-forge-method-name": "list", "responses": {"204": {"description": "ok"}}}}}, "x-forge-commands": {"abuse-reports": {"description": "Abuse reports."}} }"#, @@ -377,7 +380,7 @@ fn group_without_a_command_has_no_description() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["accounts"], "x-forge-method-name": "list", + "paths": {"/a": {"get": {"x-forge-group-name": ["accounts"], "x-forge-method-name": "list", "responses": {"204": {"description": "ok"}}}}}, "x-forge-commands": {"zones": {"description": "Zones."}} }"#, @@ -406,13 +409,13 @@ fn groups_are_sorted_by_name() { r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": { - "/a": {"get": {"x-forge-group": ["zones"], "x-forge-method-name": "get", + "/a": {"get": {"x-forge-group-name": ["zones"], "x-forge-method-name": "get", "responses": {"204": {"description": "ok"}}}}, - "/b": {"get": {"x-forge-group": ["accounts", "members"], "x-forge-method-name": "get", + "/b": {"get": {"x-forge-group-name": ["accounts", "members"], "x-forge-method-name": "get", "responses": {"204": {"description": "ok"}}}}, - "/c": {"get": {"x-forge-group": ["dns"], "x-forge-method-name": "get", + "/c": {"get": {"x-forge-group-name": ["dns"], "x-forge-method-name": "get", "responses": {"204": {"description": "ok"}}}}, - "/d": {"get": {"x-forge-group": ["accounts", "billing"], "x-forge-method-name": "get", + "/d": {"get": {"x-forge-group-name": ["accounts", "billing"], "x-forge-method-name": "get", "responses": {"204": {"description": "ok"}}}} } }"#, @@ -434,11 +437,11 @@ fn methods_are_sorted_by_name() { r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": {"/a": { - "get": {"x-forge-group": ["a"], "x-forge-method-name": "list", + "get": {"x-forge-group-name": ["a"], "x-forge-method-name": "list", "responses": {"204": {"description": "ok"}}}, - "post": {"x-forge-group": ["a"], "x-forge-method-name": "create", + "post": {"x-forge-group-name": ["a"], "x-forge-method-name": "create", "responses": {"204": {"description": "ok"}}}, - "delete": {"x-forge-group": ["a"], "x-forge-method-name": "delete", + "delete": {"x-forge-group-name": ["a"], "x-forge-method-name": "delete", "responses": {"204": {"description": "ok"}}} }} }"#, diff --git a/tests/parse/headers.rs b/tests/parse/headers.rs index cbba07620..1d45cdb8c 100644 --- a/tests/parse/headers.rs +++ b/tests/parse/headers.rs @@ -17,7 +17,7 @@ fn header_param_is_a_field() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "get", "parameters": [ {"name": "X-Request-ID", "in": "header", "required": true, "description": "Traces the request.", "schema": {"type": "integer", "format": "int64"}}, @@ -43,7 +43,7 @@ fn header_field_is_named_by_x_forge_parameter_name() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "get", "parameters": [{"name": "cf-r2-jurisdiction", "in": "header", "x-forge-parameter-name": "jurisdiction", "schema": {"type": "string"}}], "responses": {"204": {"description": "ok"}}}}} @@ -59,7 +59,7 @@ fn header_description_is_the_params_or_else_its_schemas() { let forge = parse( r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "get", "parameters": [ {"name": "X-Own", "in": "header", "description": "Param.", "schema": {"$ref": "#/components/schemas/Id"}}, {"name": "X-Inherited", "in": "header", "schema": {"$ref": "#/components/schemas/Id"}}, @@ -81,7 +81,7 @@ fn deprecated_header_is_a_deprecated_field() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "get", "parameters": [{"name": "X-Old", "in": "header", "deprecated": true, "schema": {"type": "string"}}], "responses": {"204": {"description": "ok"}}}}} }"#, @@ -95,7 +95,7 @@ fn header_is_sensitive_when_its_schema_is_and_never_read_only() { let forge = parse( r##"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "get", "parameters": [ {"name": "X-Api-Key", "in": "header", "schema": {"type": "string", "x-forge-sensitive": true, "readOnly": true}}, {"name": "X-Auth-Token", "in": "header", "schema": {"$ref": "#/components/schemas/Token"}}, @@ -119,7 +119,7 @@ fn header_has_the_default_of_its_schema() { let forge = parse( r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "get", "parameters": [ {"name": "X-Version", "in": "header", "schema": {"type": "string", "default": "2024-01-01"}}, {"name": "X-Retries", "in": "header", "schema": {"$ref": "#/components/schemas/Retries"}}, @@ -144,7 +144,7 @@ fn header_param_ref_is_followed() { let forge = parse( r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"post": {"x-forge-group": ["a"], "x-forge-method-name": "create", + "paths": {"/a": {"post": {"x-forge-group-name": ["a"], "x-forge-method-name": "create", "parameters": [{"$ref": "#/components/parameters/IdempotencyKey"}], "responses": {"204": {"description": "ok"}}}}}, "components": {"parameters": {"IdempotencyKey": {"name": "Idempotency-Key", "in": "header", @@ -163,8 +163,8 @@ fn path_item_headers_belong_to_each_operation() { "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": {"/a": { "parameters": [{"name": "X-Account", "in": "header", "schema": {"type": "string"}}], - "get": {"x-forge-group": ["a"], "x-forge-method-name": "get", "responses": {"204": {"description": "ok"}}}, - "delete": {"x-forge-group": ["a"], "x-forge-method-name": "delete", "responses": {"204": {"description": "ok"}}} + "get": {"x-forge-group-name": ["a"], "x-forge-method-name": "get", "responses": {"204": {"description": "ok"}}}, + "delete": {"x-forge-group-name": ["a"], "x-forge-method-name": "delete", "responses": {"204": {"description": "ok"}}} }} }"#, ); @@ -181,7 +181,7 @@ fn operation_header_replaces_the_path_item_header() { "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": {"/a": { "parameters": [{"name": "X-Limit", "in": "header", "schema": {"type": "string"}}], - "get": {"x-forge-group": ["a"], "x-forge-method-name": "get", + "get": {"x-forge-group-name": ["a"], "x-forge-method-name": "get", "parameters": [{"name": "X-Limit", "in": "header", "required": true, "schema": {"type": "integer"}}], "responses": {"204": {"description": "ok"}}} }} @@ -202,7 +202,7 @@ fn operation_header_replaces_the_path_item_header_ignoring_case() { "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": {"/a": { "parameters": [{"name": "X-Request-ID", "in": "header", "schema": {"type": "string"}}], - "get": {"x-forge-group": ["a"], "x-forge-method-name": "get", + "get": {"x-forge-group-name": ["a"], "x-forge-method-name": "get", "parameters": [{"name": "x-request-id", "in": "header", "schema": {"type": "integer"}}], "responses": {"204": {"description": "ok"}}} }} @@ -221,7 +221,7 @@ fn query_param_does_not_replace_a_header_with_its_name() { "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": {"/a": { "parameters": [{"name": "version", "in": "header", "schema": {"type": "string"}}], - "get": {"x-forge-group": ["a"], "x-forge-method-name": "get", + "get": {"x-forge-group-name": ["a"], "x-forge-method-name": "get", "parameters": [{"name": "version", "in": "query", "schema": {"type": "string"}}], "responses": {"204": {"description": "ok"}}} }} @@ -243,7 +243,7 @@ fn headers_are_in_declared_order_the_path_items_first() { {"name": "X-First", "in": "header", "schema": {"type": "string"}}, {"name": "X-Second", "in": "header", "schema": {"type": "string"}} ], - "get": {"x-forge-group": ["a"], "x-forge-method-name": "get", + "get": {"x-forge-group-name": ["a"], "x-forge-method-name": "get", "parameters": [ {"name": "X-Third", "in": "header", "schema": {"type": "string"}}, {"name": "x-first", "in": "header", "schema": {"type": "string"}} @@ -264,7 +264,7 @@ fn headers_with_equal_names_are_an_error() { let err = parse_err( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "get", "parameters": [ {"name": "X-Rate-Limit", "in": "header", "schema": {"type": "string"}}, {"name": "X_Rate_Limit", "in": "header", "schema": {"type": "string"}} @@ -289,7 +289,7 @@ fn path_item_headers_differing_in_case_are_an_error() { {"name": "X-A", "in": "header", "schema": {"type": "string"}}, {"name": "x-a", "in": "header", "schema": {"type": "string"}} ], - "get": {"x-forge-group": ["a"], "x-forge-method-name": "get", + "get": {"x-forge-group-name": ["a"], "x-forge-method-name": "get", "responses": {"204": {"description": "ok"}}} }} }"#, @@ -303,7 +303,7 @@ fn header_and_param_with_equal_names_are_not_an_error() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a/{account_id}": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", + "paths": {"/a/{account_id}": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "get", "parameters": [ {"name": "account_id", "in": "path", "required": true, "schema": {"type": "string"}}, {"name": "Account-Id", "in": "header", "schema": {"type": "string"}} @@ -320,7 +320,7 @@ fn cookie_params_are_not_headers() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "get", "parameters": [ {"name": "session", "in": "cookie", "schema": {"type": "string"}}, {"name": "X-Trace", "in": "header", "schema": {"type": "string"}} @@ -337,7 +337,7 @@ fn method_with_only_cookie_params_has_no_headers() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "get", "parameters": [{"name": "session", "in": "cookie", "schema": {"type": "string"}}], "responses": {"204": {"description": "ok"}}}}} }"#, @@ -351,7 +351,7 @@ fn accept_content_type_and_authorization_headers_are_kept() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"put": {"x-forge-group": ["a"], "x-forge-method-name": "upload", + "paths": {"/a": {"put": {"x-forge-group-name": ["a"], "x-forge-method-name": "upload", "parameters": [ {"name": "Content-Type", "in": "header", "required": true, "schema": {"type": "string"}}, {"name": "Content-Length", "in": "header", "schema": {"type": "integer"}}, @@ -376,7 +376,7 @@ fn method_without_header_params_has_no_headers() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "list", + "paths": {"/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "list", "parameters": [{"name": "page", "in": "query", "schema": {"type": "integer"}}], "responses": {"204": {"description": "ok"}}}}} }"#, @@ -391,7 +391,7 @@ fn headers_are_not_in_the_request() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"post": {"x-forge-group": ["a"], "x-forge-method-name": "create", + "paths": {"/a": {"post": {"x-forge-group-name": ["a"], "x-forge-method-name": "create", "parameters": [ {"name": "page", "in": "query", "schema": {"type": "integer"}}, {"name": "X-Trace", "in": "header", "schema": {"type": "string"}} @@ -411,7 +411,7 @@ fn method_with_only_headers_has_no_request() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "get", "parameters": [{"name": "X-Trace", "in": "header", "schema": {"type": "string"}}], "responses": {"204": {"description": "ok"}}}}} }"#, @@ -427,7 +427,7 @@ fn model_used_only_by_a_header_is_a_model() { let forge = parse( r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a", "b"], "x-forge-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group-name": ["a", "b"], "x-forge-method-name": "get", "parameters": [{"name": "cf-r2-storage-class", "in": "header", "schema": {"$ref": "#/components/schemas/StorageClass"}}], "responses": {"204": {"description": "ok"}}}}}, "components": {"schemas": {"StorageClass": {"type": "string", "enum": ["Standard", "InfrequentAccess"]}}} @@ -446,7 +446,7 @@ fn header_with_content_is_an_error() { let err = parse_err( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "get", "parameters": [{"name": "X-Filter", "in": "header", "content": {"application/json": {"schema": { "type": "object", "properties": {"status": {"type": "string"}} }}}}], @@ -465,7 +465,7 @@ fn header_style_other_than_simple_is_an_error() { let err = parse_err( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "get", "parameters": [{"name": "X-Ids", "in": "header", "style": "form", "schema": {"type": "array", "items": {"type": "string"}}}], "responses": {"204": {"description": "ok"}}}}} diff --git a/tests/parse/methods.rs b/tests/parse/methods.rs index c03f128e5..029470219 100644 --- a/tests/parse/methods.rs +++ b/tests/parse/methods.rs @@ -9,7 +9,7 @@ fn operation_with(fields: &str) -> String { r#"{{ "openapi": "3.0.3", "info": {{"title": "t", "version": "1"}}, "paths": {{"/a": {{"get": {{{fields} - "x-forge-group": ["a"], "x-forge-method-name": "get", + "x-forge-group-name": ["a"], "x-forge-method-name": "get", "responses": {{"204": {{"description": "ok"}}}}}}}}}} }}"# ) @@ -21,14 +21,14 @@ fn each_operation_field_is_its_http_method() { r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": {"/a": { - "get": {"x-forge-group": ["a"], "x-forge-method-name": "get", "responses": {"204": {"description": "ok"}}}, - "put": {"x-forge-group": ["a"], "x-forge-method-name": "put", "responses": {"204": {"description": "ok"}}}, - "post": {"x-forge-group": ["a"], "x-forge-method-name": "post", "responses": {"204": {"description": "ok"}}}, - "delete": {"x-forge-group": ["a"], "x-forge-method-name": "delete", "responses": {"204": {"description": "ok"}}}, - "options": {"x-forge-group": ["a"], "x-forge-method-name": "options", "responses": {"204": {"description": "ok"}}}, - "head": {"x-forge-group": ["a"], "x-forge-method-name": "head", "responses": {"204": {"description": "ok"}}}, - "patch": {"x-forge-group": ["a"], "x-forge-method-name": "patch", "responses": {"204": {"description": "ok"}}}, - "trace": {"x-forge-group": ["a"], "x-forge-method-name": "trace", "responses": {"204": {"description": "ok"}}} + "get": {"x-forge-group-name": ["a"], "x-forge-method-name": "get", "responses": {"204": {"description": "ok"}}}, + "put": {"x-forge-group-name": ["a"], "x-forge-method-name": "put", "responses": {"204": {"description": "ok"}}}, + "post": {"x-forge-group-name": ["a"], "x-forge-method-name": "post", "responses": {"204": {"description": "ok"}}}, + "delete": {"x-forge-group-name": ["a"], "x-forge-method-name": "delete", "responses": {"204": {"description": "ok"}}}, + "options": {"x-forge-group-name": ["a"], "x-forge-method-name": "options", "responses": {"204": {"description": "ok"}}}, + "head": {"x-forge-group-name": ["a"], "x-forge-method-name": "head", "responses": {"204": {"description": "ok"}}}, + "patch": {"x-forge-group-name": ["a"], "x-forge-method-name": "patch", "responses": {"204": {"description": "ok"}}}, + "trace": {"x-forge-group-name": ["a"], "x-forge-method-name": "trace", "responses": {"204": {"description": "ok"}}} }} }"#, ); @@ -54,7 +54,7 @@ fn query_operation_is_query() { let forge = parse( r#"{ "openapi": "3.2.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"query": {"x-forge-group": ["a"], "x-forge-method-name": "search", + "paths": {"/a": {"query": {"x-forge-group-name": ["a"], "x-forge-method-name": "search", "responses": {"204": {"description": "ok"}}}}} }"#, ); @@ -68,7 +68,7 @@ fn additional_operation_is_its_method_as_written() { r#"{ "openapi": "3.2.0", "info": {"title": "t", "version": "1"}, "paths": {"/a": {"additionalOperations": { - "COPY": {"x-forge-group": ["a"], "x-forge-method-name": "copy", + "COPY": {"x-forge-group-name": ["a"], "x-forge-method-name": "copy", "responses": {"204": {"description": "ok"}}} }}} }"#, @@ -86,7 +86,7 @@ fn additional_operation_with_the_method_of_a_field_is_that_variant() { r#"{ "openapi": "3.2.0", "info": {"title": "t", "version": "1"}, "paths": {"/a": {"additionalOperations": { - "GET": {"x-forge-group": ["a"], "x-forge-method-name": "get", + "GET": {"x-forge-group-name": ["a"], "x-forge-method-name": "get", "responses": {"204": {"description": "ok"}}} }}} }"#, @@ -101,7 +101,7 @@ fn additional_operation_methods_are_case_sensitive() { r#"{ "openapi": "3.2.0", "info": {"title": "t", "version": "1"}, "paths": {"/a": {"additionalOperations": { - "get": {"x-forge-group": ["a"], "x-forge-method-name": "get", + "get": {"x-forge-group-name": ["a"], "x-forge-method-name": "get", "responses": {"204": {"description": "ok"}}} }}} }"#, @@ -118,7 +118,7 @@ fn method_has_the_summary_description_and_deprecation_of_its_operation() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "get", "summary": "Get an a.", "description": "Gets an a.", "deprecated": true, "responses": {"204": {"description": "ok"}}}}} }"#, @@ -135,7 +135,7 @@ fn method_without_docs_is_empty_and_not_deprecated() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "get", "responses": {"204": {"description": "ok"}}}}} }"#, ); @@ -153,7 +153,7 @@ fn path_item_ref_is_followed() { "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, "paths": {"/a": {"$ref": "#/components/pathItems/A"}}, "components": {"pathItems": { - "A": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", + "A": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "get", "responses": {"204": {"description": "ok"}}}} }} }"##, @@ -168,11 +168,11 @@ fn extensions_in_paths_and_path_items_are_not_operations() { r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": { - "x-paths-extension": {"get": {"x-forge-group": ["x"], "x-forge-method-name": "get", + "x-paths-extension": {"get": {"x-forge-group-name": ["x"], "x-forge-method-name": "get", "responses": {"204": {"description": "ok"}}}}, "/a": { "x-forge-commands": {"a": {"description": "A."}}, - "get": {"x-forge-group": ["a"], "x-forge-method-name": "get", + "get": {"x-forge-group-name": ["a"], "x-forge-method-name": "get", "responses": {"204": {"description": "ok"}}} } } @@ -189,9 +189,9 @@ fn additional_operation_with_the_method_of_a_present_field_is_skipped() { r#"{ "openapi": "3.2.0", "info": {"title": "t", "version": "1"}, "paths": {"/a": { - "get": {"x-forge-group": ["a"], "x-forge-method-name": "get", + "get": {"x-forge-group-name": ["a"], "x-forge-method-name": "get", "responses": {"204": {"description": "ok"}}}, - "additionalOperations": {"GET": {"x-forge-group": ["a"], "x-forge-method-name": "shadowed", + "additionalOperations": {"GET": {"x-forge-group-name": ["a"], "x-forge-method-name": "shadowed", "responses": {"204": {"description": "ok"}}}} }} }"#, @@ -206,7 +206,7 @@ fn webhooks_are_not_methods() { let forge = parse( r#"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "webhooks": {"newPet": {"post": {"x-forge-group": ["pets"], "x-forge-method-name": "created", + "webhooks": {"newPet": {"post": {"x-forge-group-name": ["pets"], "x-forge-method-name": "created", "responses": {"204": {"description": "ok"}}}}} }"#, ); @@ -219,9 +219,9 @@ fn callbacks_are_not_methods() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/subscribe": {"post": {"x-forge-group": ["a"], "x-forge-method-name": "subscribe", + "paths": {"/subscribe": {"post": {"x-forge-group-name": ["a"], "x-forge-method-name": "subscribe", "callbacks": {"event": {"{$request.query.url}": {"post": { - "x-forge-group": ["a"], "x-forge-method-name": "event", + "x-forge-group-name": ["a"], "x-forge-method-name": "event", "responses": {"204": {"description": "ok"}} }}}}, "responses": {"204": {"description": "ok"}}}}} @@ -331,13 +331,13 @@ fn availability_and_deprecation_are_apart() { r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": {"/a": { - "get": {"x-forge-group": ["a"], "x-forge-method-name": "get", + "get": {"x-forge-group-name": ["a"], "x-forge-method-name": "get", "deprecated": true, "x-forge-availability": "generally-available", "responses": {"204": {"description": "ok"}}}, - "put": {"x-forge-group": ["a"], "x-forge-method-name": "put", + "put": {"x-forge-group-name": ["a"], "x-forge-method-name": "put", "x-forge-availability": "deprecated", "responses": {"204": {"description": "ok"}}}, - "post": {"x-forge-group": ["a"], "x-forge-method-name": "post", + "post": {"x-forge-group-name": ["a"], "x-forge-method-name": "post", "deprecated": true, "responses": {"204": {"description": "ok"}}} }} }"#, @@ -359,7 +359,7 @@ fn x_forge_method_without_name_is_not_a_method_name() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method": "get", + "paths": {"/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method": "get", "responses": {"204": {"description": "ok"}}}}} }"#, ); diff --git a/tests/parse/models.rs b/tests/parse/models.rs index dcb7af171..f4bfe3b36 100644 --- a/tests/parse/models.rs +++ b/tests/parse/models.rs @@ -13,7 +13,7 @@ fn response_model_is_placed_in_its_group() { "paths": { "/zones": { "get": { - "x-forge-group": ["zones"], + "x-forge-group-name": ["zones"], "x-forge-method-name": "list", "responses": { "200": { @@ -43,9 +43,9 @@ fn model_used_in_sibling_groups_is_in_their_parent() { r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": { - "/c": {"get": {"x-forge-group": ["a", "b", "c"], "x-forge-method-name": "get", + "/c": {"get": {"x-forge-group-name": ["a", "b", "c"], "x-forge-method-name": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/M"}}}}}}}, - "/d": {"get": {"x-forge-group": ["a", "b", "d"], "x-forge-method-name": "get", + "/d": {"get": {"x-forge-group-name": ["a", "b", "d"], "x-forge-method-name": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/M"}}}}}}} }, "components": {"schemas": {"M": {"type": "object", "properties": {"id": {"type": "string"}}}}} @@ -61,9 +61,9 @@ fn model_used_in_a_group_and_its_subgroup_is_in_the_group() { r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": { - "/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", + "/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/M"}}}}}}}, - "/c": {"get": {"x-forge-group": ["a", "b", "c"], "x-forge-method-name": "get", + "/c": {"get": {"x-forge-group-name": ["a", "b", "c"], "x-forge-method-name": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/M"}}}}}}} }, "components": {"schemas": {"M": {"type": "object", "properties": {"id": {"type": "string"}}}}} @@ -79,9 +79,9 @@ fn model_used_in_top_level_groups_is_at_the_top_level() { r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": { - "/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", + "/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/M"}}}}}}}, - "/x": {"get": {"x-forge-group": ["x"], "x-forge-method-name": "get", + "/x": {"get": {"x-forge-group-name": ["x"], "x-forge-method-name": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/M"}}}}}}} }, "components": {"schemas": {"M": {"type": "object", "properties": {"id": {"type": "string"}}}}} @@ -99,9 +99,9 @@ fn model_used_through_another_model_is_placed_by_every_use() { r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": { - "/b": {"get": {"x-forge-group": ["a", "b"], "x-forge-method-name": "get", + "/b": {"get": {"x-forge-group-name": ["a", "b"], "x-forge-method-name": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Outer"}}}}}}}, - "/c": {"get": {"x-forge-group": ["a", "c"], "x-forge-method-name": "get", + "/c": {"get": {"x-forge-group-name": ["a", "c"], "x-forge-method-name": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Inner"}}}}}}} }, "components": {"schemas": { @@ -120,7 +120,7 @@ fn params_bodies_and_errors_use_models() { let forge = parse( r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"post": {"x-forge-group": ["a"], "x-forge-method-name": "create", + "paths": {"/a": {"post": {"x-forge-group-name": ["a"], "x-forge-method-name": "create", "parameters": [{"name": "filter", "in": "query", "schema": {"$ref": "#/components/schemas/Filter"}}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Input"}}}}, "responses": {"400": {"description": "no", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Failure"}}}}}}}}, @@ -144,16 +144,16 @@ fn schema_no_method_uses_is_not_a_model() { r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": { - "/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", + "/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Used"}}}}}}}, - "/ignored": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "ignored", "x-forge-ignore": true, - "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Ignored"}}}}}}}, + "/skipped": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "skipped", "x-forge-skip": true, + "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Skipped"}}}}}}}, "/ungrouped": {"get": {"x-forge-method-name": "ungrouped", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Ungrouped"}}}}}}} }, "components": {"schemas": { "Used": {"type": "string"}, - "Ignored": {"type": "string"}, + "Skipped": {"type": "string"}, "Ungrouped": {"type": "string"}, "Unused": {"type": "string"} }} @@ -224,7 +224,7 @@ fn schema_in_defs_is_a_model() { let forge = parse( r##"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Tree/$defs/Node"}}}}}}}}, "components": {"schemas": { "Tree": {"type": "object", "$defs": {"Node": {"type": "string"}}} @@ -256,7 +256,7 @@ fn schema_used_only_by_content_that_is_not_json_is_a_model() { let forge = parse( r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"post": {"x-forge-group": ["a", "files"], "x-forge-method-name": "upload", + "paths": {"/a": {"post": {"x-forge-group-name": ["a", "files"], "x-forge-method-name": "upload", "requestBody": {"content": {"multipart/form-data": {"schema": {"$ref": "#/components/schemas/Upload"}}}}, "responses": {"200": {"description": "ok", "content": {"text/csv": {"schema": {"$ref": "#/components/schemas/Report"}}}}}}}}, "components": {"schemas": { @@ -287,9 +287,9 @@ fn use_by_content_that_is_not_json_places_a_model() { r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": { - "/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", + "/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/M"}}}}}}}, - "/b": {"post": {"x-forge-group": ["b"], "x-forge-method-name": "upload", + "/b": {"post": {"x-forge-group-name": ["b"], "x-forge-method-name": "upload", "requestBody": {"content": {"multipart/form-data": {"schema": { "type": "object", "properties": {"meta": {"$ref": "#/components/schemas/M"}} }}}}, @@ -307,7 +307,7 @@ fn schema_used_only_in_a_response_header_is_not_a_model() { let forge = parse( r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "get", "parameters": [{"name": "X-Trace", "in": "header", "schema": {"$ref": "#/components/schemas/Trace"}}], "responses": {"204": {"description": "ok", "headers": {"X-Rate-Limit": {"schema": {"$ref": "#/components/schemas/RateLimit"}}}}}}}}, diff --git a/tests/parse/requests.rs b/tests/parse/requests.rs index e87904898..b0e9eaaae 100644 --- a/tests/parse/requests.rs +++ b/tests/parse/requests.rs @@ -13,7 +13,7 @@ fn path_param_is_a_required_request_field() { "paths": { "/zones/{zone_id}": { "get": { - "x-forge-group": ["zones"], + "x-forge-group-name": ["zones"], "x-forge-method-name": "get", "parameters": [ { "name": "zone_id", "in": "path", "required": true, "schema": { "type": "string" } } @@ -44,7 +44,7 @@ fn query_param_is_optional_and_exploded_by_default() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "list", + "paths": {"/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "list", "parameters": [{"name": "page", "in": "query", "schema": {"type": "integer"}}], "responses": {"204": {"description": "ok"}}}}} }"#, @@ -61,7 +61,7 @@ fn query_param_can_be_required() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "list", + "paths": {"/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "list", "parameters": [{"name": "page", "in": "query", "required": true, "schema": {"type": "integer"}}], "responses": {"204": {"description": "ok"}}}}} }"#, @@ -76,7 +76,7 @@ fn query_param_with_explode_false_is_not_exploded() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "list", + "paths": {"/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "list", "parameters": [{"name": "ids", "in": "query", "style": "form", "explode": false, "schema": {"type": "array", "items": {"type": "string"}}}], "responses": {"204": {"description": "ok"}}}}} @@ -93,7 +93,7 @@ fn header_and_cookie_params_are_not_fields() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "get", "parameters": [ {"name": "X-Trace", "in": "header", "schema": {"type": "string"}}, {"name": "session", "in": "cookie", "schema": {"type": "string"}} @@ -111,7 +111,7 @@ fn param_field_is_named_by_x_forge_parameter_name() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "list", + "paths": {"/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "list", "parameters": [{"name": "q", "in": "query", "x-forge-parameter-name": "query", "schema": {"type": "string"}}], "responses": {"204": {"description": "ok"}}}}} }"#, @@ -126,7 +126,7 @@ fn param_description_falls_back_to_an_inline_schema() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "list", + "paths": {"/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "list", "parameters": [ {"name": "own", "in": "query", "description": "Param.", "schema": {"type": "string", "description": "Schema."}}, {"name": "inherited", "in": "query", "schema": {"type": "string", "description": "Schema."}} @@ -145,7 +145,7 @@ fn param_description_comes_from_a_ref_schema() { let forge = parse( r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "list", + "paths": {"/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "list", "parameters": [ {"name": "own", "in": "query", "description": "Param.", "schema": {"$ref": "#/components/schemas/Id"}}, {"name": "id", "in": "query", "schema": {"$ref": "#/components/schemas/Id"}} @@ -165,7 +165,7 @@ fn param_description_follows_a_chain_of_refs() { let forge = parse( r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a/{id}": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", + "paths": {"/a/{id}": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "get", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"$ref": "#/components/schemas/Id"}}], "responses": {"204": {"description": "ok"}}}}}, "components": {"schemas": { @@ -184,7 +184,7 @@ fn param_description_is_never_a_schema_title() { let forge = parse( r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "list", + "paths": {"/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "list", "parameters": [ {"name": "id", "in": "query", "schema": {"$ref": "#/components/schemas/Id"}}, {"name": "inline", "in": "query", "schema": {"type": "string", "title": "Inline"}} @@ -204,7 +204,7 @@ fn description_next_to_a_param_schema_ref_is_its_own_in_3_1() { let forge = parse( r##"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "list", + "paths": {"/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "list", "parameters": [{"name": "id", "in": "query", "schema": {"$ref": "#/components/schemas/Id", "description": "The ID to find."}}], "responses": {"204": {"description": "ok"}}}}}, @@ -221,7 +221,7 @@ fn param_description_comes_from_an_all_of_member_in_3_1() { let forge = parse( r##"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "list", + "paths": {"/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "list", "parameters": [{"name": "region", "in": "query", "schema": {"allOf": [{"$ref": "#/components/schemas/Region"}, {"default": "us"}]}}], "responses": {"204": {"description": "ok"}}}}}, @@ -238,7 +238,7 @@ fn param_description_from_an_all_of_member_is_ignored_in_3_0() { let forge = parse( r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "list", + "paths": {"/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "list", "parameters": [{"name": "region", "in": "query", "schema": {"allOf": [{"$ref": "#/components/schemas/Region"}, {"default": "us"}]}}], "responses": {"204": {"description": "ok"}}}}}, @@ -255,7 +255,7 @@ fn param_ref_description_overrides_the_params_in_3_1() { let forge = parse( r##"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "list", + "paths": {"/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "list", "parameters": [{"$ref": "#/components/parameters/Page", "description": "The page of pets."}], "responses": {"204": {"description": "ok"}}}}}, "components": {"parameters": {"Page": {"name": "page", "in": "query", "description": "A page.", @@ -272,7 +272,7 @@ fn param_ref_description_is_ignored_in_3_0() { let forge = parse( r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "list", + "paths": {"/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "list", "parameters": [{"$ref": "#/components/parameters/Page", "description": "The page of pets."}], "responses": {"204": {"description": "ok"}}}}}, "components": {"parameters": {"Page": {"name": "page", "in": "query", "description": "A page.", @@ -289,7 +289,7 @@ fn param_ref_without_a_description_has_the_params() { let forge = parse( r##"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "list", + "paths": {"/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "list", "parameters": [{"$ref": "#/components/parameters/Page"}], "responses": {"204": {"description": "ok"}}}}}, "components": {"parameters": {"Page": {"name": "page", "in": "query", "description": "A page.", @@ -306,7 +306,7 @@ fn param_field_is_never_read_only() { let forge = parse( r##"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a/{id}": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", + "paths": {"/a/{id}": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "get", "parameters": [ {"name": "id", "in": "path", "required": true, "schema": {"type": "string", "readOnly": true}}, {"name": "since", "in": "query", "schema": {"$ref": "#/components/schemas/Since", "readOnly": true}}, @@ -331,7 +331,7 @@ fn query_string_property_is_never_read_only() { let forge = parse( r#"{ "openapi": "3.2.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "list", + "paths": {"/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "list", "parameters": [{"name": "filter", "in": "querystring", "content": {"application/x-www-form-urlencoded": {"schema": { "type": "object", "properties": {"status": {"type": "string", "readOnly": true, "x-forge-sensitive": true}} }}}}], @@ -349,7 +349,7 @@ fn param_field_is_sensitive_when_its_schema_is() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "list", + "paths": {"/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "list", "parameters": [ {"name": "token", "in": "query", "schema": {"type": "string", "x-forge-sensitive": true}}, {"name": "page", "in": "query", "schema": {"type": "integer"}} @@ -368,7 +368,7 @@ fn param_field_is_sensitive_when_its_ref_schema_is() { let forge = parse( r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "list", + "paths": {"/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "list", "parameters": [{"name": "token", "in": "query", "schema": {"$ref": "#/components/schemas/Token"}}], "responses": {"204": {"description": "ok"}}}}}, "components": {"schemas": { @@ -386,7 +386,7 @@ fn sensitive_next_to_a_param_schema_ref_counts_in_3_0() { let forge = parse( r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "list", + "paths": {"/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "list", "parameters": [{"name": "token", "in": "query", "schema": {"$ref": "#/components/schemas/Token", "x-forge-sensitive": true}}], "responses": {"204": {"description": "ok"}}}}}, @@ -402,7 +402,7 @@ fn sensitive_on_the_param_itself_is_ignored() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "list", + "paths": {"/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "list", "parameters": [{"name": "token", "in": "query", "x-forge-sensitive": true, "schema": {"type": "string"}}], "responses": {"204": {"description": "ok"}}}}} }"#, @@ -416,7 +416,7 @@ fn param_ref_is_followed() { let forge = parse( r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "list", + "paths": {"/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "list", "parameters": [{"$ref": "#/components/parameters/Page"}], "responses": {"204": {"description": "ok"}}}}}, "components": {"parameters": {"Page": {"name": "page", "in": "query", "schema": {"type": "integer"}}}} @@ -435,8 +435,8 @@ fn path_item_params_belong_to_each_operation() { "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": {"/a/{id}": { "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}], - "get": {"x-forge-group": ["a"], "x-forge-method-name": "get", "responses": {"204": {"description": "ok"}}}, - "delete": {"x-forge-group": ["a"], "x-forge-method-name": "delete", "responses": {"204": {"description": "ok"}}} + "get": {"x-forge-group-name": ["a"], "x-forge-method-name": "get", "responses": {"204": {"description": "ok"}}}, + "delete": {"x-forge-group-name": ["a"], "x-forge-method-name": "delete", "responses": {"204": {"description": "ok"}}} }} }"#, ); @@ -455,7 +455,7 @@ fn operation_param_replaces_the_path_item_param() { "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": {"/a": { "parameters": [{"name": "page", "in": "query", "schema": {"type": "string"}}], - "get": {"x-forge-group": ["a"], "x-forge-method-name": "list", + "get": {"x-forge-group-name": ["a"], "x-forge-method-name": "list", "parameters": [{"name": "page", "in": "query", "schema": {"type": "integer"}}], "responses": {"204": {"description": "ok"}}} }} @@ -472,7 +472,7 @@ fn undeclared_path_placeholder_is_a_required_string() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a/{id}": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", + "paths": {"/a/{id}": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "get", "responses": {"204": {"description": "ok"}}}}} }"#, ); @@ -491,7 +491,7 @@ fn params_are_in_path_template_then_declared_order() { "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": {"/a/{second}/{first}": { "parameters": [{"name": "item_query", "in": "query", "schema": {"type": "string"}}], - "get": {"x-forge-group": ["a"], "x-forge-method-name": "get", + "get": {"x-forge-group-name": ["a"], "x-forge-method-name": "get", "parameters": [ {"name": "op_query", "in": "query", "schema": {"type": "string"}}, {"name": "first", "in": "path", "required": true, "schema": {"type": "string"}}, @@ -511,7 +511,7 @@ fn query_string_object_is_query_fields() { let forge = parse( r#"{ "openapi": "3.2.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "list", + "paths": {"/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "list", "parameters": [{"name": "filter", "in": "querystring", "content": {"application/x-www-form-urlencoded": {"schema": { "type": "object", "properties": {"status": {"type": "string"}, "limit": {"type": "integer"}} }}}}], @@ -532,7 +532,7 @@ fn no_params_and_no_body_is_no_request() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "get", "responses": {"204": {"description": "ok"}}}}} }"#, ); @@ -545,7 +545,7 @@ fn body_without_params_is_the_request() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"post": {"x-forge-group": ["a"], "x-forge-method-name": "create", + "paths": {"/a": {"post": {"x-forge-group-name": ["a"], "x-forge-method-name": "create", "requestBody": {"content": {"application/json": {"schema": {"type": "array", "items": {"type": "string"}}}}}, "responses": {"204": {"description": "ok"}}}}} }"#, @@ -559,7 +559,7 @@ fn object_body_is_intersected_with_the_params() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a/{id}": {"put": {"x-forge-group": ["a"], "x-forge-method-name": "update", + "paths": {"/a/{id}": {"put": {"x-forge-group-name": ["a"], "x-forge-method-name": "update", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}], "requestBody": {"content": {"application/json": {"schema": { "type": "object", "properties": {"name": {"type": "string"}} @@ -582,7 +582,7 @@ fn model_body_is_intersected_with_the_params() { let forge = parse( r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a/{id}": {"put": {"x-forge-group": ["a"], "x-forge-method-name": "update", + "paths": {"/a/{id}": {"put": {"x-forge-group-name": ["a"], "x-forge-method-name": "update", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Pet"}}}}, "responses": {"204": {"description": "ok"}}}}}, @@ -604,7 +604,7 @@ fn union_body_is_intersected_with_the_params() { let forge = parse( r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a/{id}": {"put": {"x-forge-group": ["a"], "x-forge-method-name": "update", + "paths": {"/a/{id}": {"put": {"x-forge-group-name": ["a"], "x-forge-method-name": "update", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}], "requestBody": {"content": {"application/json": {"schema": { "oneOf": [{"$ref": "#/components/schemas/Cat"}, {"$ref": "#/components/schemas/Dog"}] @@ -631,7 +631,7 @@ fn array_body_is_the_last_field_of_the_params() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a/{id}": {"put": {"x-forge-group": ["a"], "x-forge-method-name": "update", + "paths": {"/a/{id}": {"put": {"x-forge-group-name": ["a"], "x-forge-method-name": "update", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}], "requestBody": {"content": {"application/json": {"schema": {"type": "array", "items": {"type": "string"}}}}}, "responses": {"204": {"description": "ok"}}}}} @@ -651,7 +651,7 @@ fn body_field_is_neither_read_only_nor_sensitive() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a/{id}": {"put": {"x-forge-group": ["a"], "x-forge-method-name": "update", + "paths": {"/a/{id}": {"put": {"x-forge-group-name": ["a"], "x-forge-method-name": "update", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}], "requestBody": {"content": {"application/json": {"schema": { "type": "array", "items": {"type": "string"}, "readOnly": true, "x-forge-sensitive": true @@ -670,7 +670,7 @@ fn read_only_field_of_a_body_model_is_kept() { let forge = parse( r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"post": {"x-forge-group": ["a"], "x-forge-method-name": "create", + "paths": {"/a": {"post": {"x-forge-group-name": ["a"], "x-forge-method-name": "create", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Pet"}}}}, "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Pet"}}}}}}}}, "components": {"schemas": {"Pet": {"type": "object", "properties": { @@ -690,7 +690,7 @@ fn map_body_is_a_body_field() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a/{id}": {"put": {"x-forge-group": ["a"], "x-forge-method-name": "update", + "paths": {"/a/{id}": {"put": {"x-forge-group-name": ["a"], "x-forge-method-name": "update", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}], "requestBody": {"content": {"application/json": {"schema": { "type": "object", "additionalProperties": {"type": "string"} @@ -708,7 +708,7 @@ fn unknown_body_is_a_body_field() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a/{id}": {"put": {"x-forge-group": ["a"], "x-forge-method-name": "update", + "paths": {"/a/{id}": {"put": {"x-forge-group-name": ["a"], "x-forge-method-name": "update", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}], "requestBody": {"content": {"application/json": {"schema": {}}}}, "responses": {"204": {"description": "ok"}}}}} @@ -724,7 +724,7 @@ fn nullable_body_is_a_body_field() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a/{id}": {"put": {"x-forge-group": ["a"], "x-forge-method-name": "update", + "paths": {"/a/{id}": {"put": {"x-forge-group-name": ["a"], "x-forge-method-name": "update", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}], "requestBody": {"content": {"application/json": {"schema": { "type": "object", "nullable": true, "properties": {"name": {"type": "string"}} @@ -742,7 +742,7 @@ fn body_with_the_name_of_a_param_is_a_body_field() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a/{zone_id}": {"put": {"x-forge-group": ["a"], "x-forge-method-name": "update", + "paths": {"/a/{zone_id}": {"put": {"x-forge-group-name": ["a"], "x-forge-method-name": "update", "parameters": [{"name": "zone_id", "in": "path", "required": true, "schema": {"type": "string"}}], "requestBody": {"content": {"application/json": {"schema": { "type": "object", "properties": {"zoneId": {"type": "string"}, "name": {"type": "string"}} @@ -762,8 +762,8 @@ fn field_of_another_method_has_no_location() { "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": {"/a/{id}": { "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}], - "get": {"x-forge-group": ["a"], "x-forge-method-name": "get", "responses": {"204": {"description": "ok"}}}, - "delete": {"x-forge-group": ["a"], "x-forge-method-name": "delete", "responses": {"204": {"description": "ok"}}} + "get": {"x-forge-group-name": ["a"], "x-forge-method-name": "get", "responses": {"204": {"description": "ok"}}}, + "delete": {"x-forge-group-name": ["a"], "x-forge-method-name": "delete", "responses": {"204": {"description": "ok"}}} }} }"#, ); @@ -779,7 +779,7 @@ fn body_that_is_not_json_is_an_unknown_body_field() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a/{id}": {"post": {"x-forge-group": ["a"], "x-forge-method-name": "upload", + "paths": {"/a/{id}": {"post": {"x-forge-group-name": ["a"], "x-forge-method-name": "upload", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}], "requestBody": {"content": {"multipart/form-data": {"schema": { "type": "object", "properties": {"file": {"type": "string", "format": "binary"}} @@ -798,7 +798,7 @@ fn body_that_is_only_multipart_is_an_unknown_request() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"post": {"x-forge-group": ["a"], "x-forge-method-name": "upload", + "paths": {"/a": {"post": {"x-forge-group-name": ["a"], "x-forge-method-name": "upload", "requestBody": {"content": {"multipart/form-data": {"schema": { "type": "object", "properties": {"file": {"type": "string", "format": "binary"}} }}}}, @@ -816,7 +816,7 @@ fn path_param_missing_from_the_template_is_dropped() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a/{id}": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", + "paths": {"/a/{id}": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "get", "parameters": [ {"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}, {"name": "account_id", "in": "path", "required": true, "schema": {"type": "string"}} @@ -833,7 +833,7 @@ fn deprecated_query_param_is_a_deprecated_field() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "list", + "paths": {"/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "list", "parameters": [{"name": "page", "in": "query", "deprecated": true, "schema": {"type": "integer"}}], "responses": {"204": {"description": "ok"}}}}} }"#, @@ -847,7 +847,7 @@ fn deprecated_path_param_is_not_a_deprecated_field() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a/{id}": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", + "paths": {"/a/{id}": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "get", "parameters": [{"name": "id", "in": "path", "required": true, "deprecated": true, "schema": {"type": "string"}}], "responses": {"204": {"description": "ok"}}}}} }"#, @@ -861,7 +861,7 @@ fn all_of_body_is_one_member_of_the_intersection() { let forge = parse( r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a/{id}": {"put": {"x-forge-group": ["a"], "x-forge-method-name": "update", + "paths": {"/a/{id}": {"put": {"x-forge-group-name": ["a"], "x-forge-method-name": "update", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}], "requestBody": {"content": {"application/json": {"schema": { "allOf": [{"$ref": "#/components/schemas/A"}, {"$ref": "#/components/schemas/B"}] @@ -888,7 +888,7 @@ fn param_with_content_is_an_error() { let err = parse_err( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "list", + "paths": {"/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "list", "parameters": [{"name": "filter", "in": "query", "content": {"application/json": {"schema": { "type": "object", "properties": {"status": {"type": "string"}} }}}}], @@ -912,7 +912,7 @@ fn query_style_other_than_form_is_an_error() { let err = parse_err(&format!( r#"{{ "openapi": "3.0.3", "info": {{"title": "t", "version": "1"}}, - "paths": {{"/a": {{"get": {{"x-forge-group": ["a"], "x-forge-method-name": "list", + "paths": {{"/a": {{"get": {{"x-forge-group-name": ["a"], "x-forge-method-name": "list", "parameters": [{{"name": "filter", "in": "query", "style": "{style}", "schema": {schema}}}], "responses": {{"204": {{"description": "ok"}}}}}}}}}} }}"# @@ -927,7 +927,7 @@ fn two_query_string_params_are_an_error() { let err = parse_err( r#"{ "openapi": "3.2.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "list", + "paths": {"/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "list", "parameters": [ {"name": "one", "in": "querystring", "content": {"application/x-www-form-urlencoded": {"schema": {"type": "object"}}}}, {"name": "two", "in": "querystring", "content": {"application/x-www-form-urlencoded": {"schema": {"type": "object"}}}} @@ -946,7 +946,7 @@ fn query_and_query_string_params_are_an_error() { "openapi": "3.2.0", "info": {"title": "t", "version": "1"}, "paths": {"/a": { "parameters": [{"name": "page", "in": "query", "schema": {"type": "integer"}}], - "get": {"x-forge-group": ["a"], "x-forge-method-name": "list", + "get": {"x-forge-group-name": ["a"], "x-forge-method-name": "list", "parameters": [{"name": "filter", "in": "querystring", "content": {"application/x-www-form-urlencoded": {"schema": {"type": "object"}}}}], "responses": {"204": {"description": "ok"}}} }} @@ -963,10 +963,10 @@ fn body_field_is_required_when_the_body_is() { "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": {"/a/{id}": { "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}], - "put": {"x-forge-group": ["a"], "x-forge-method-name": "replace", + "put": {"x-forge-group-name": ["a"], "x-forge-method-name": "replace", "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "array", "items": {"type": "string"}}}}}, "responses": {"204": {"description": "ok"}}}, - "patch": {"x-forge-group": ["a"], "x-forge-method-name": "append", + "patch": {"x-forge-group-name": ["a"], "x-forge-method-name": "append", "requestBody": {"content": {"application/json": {"schema": {"type": "array", "items": {"type": "string"}}}}}, "responses": {"204": {"description": "ok"}}} }} @@ -983,7 +983,7 @@ fn params_with_equal_names_are_an_error() { let err = parse_err( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a/{zone_id}": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", + "paths": {"/a/{zone_id}": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "get", "parameters": [ {"name": "zone_id", "in": "path", "required": true, "schema": {"type": "string"}}, {"name": "zoneId", "in": "query", "schema": {"type": "string"}} @@ -1001,7 +1001,7 @@ fn path_style_other_than_simple_is_an_error() { let err = parse_err(&format!( r#"{{ "openapi": "3.0.3", "info": {{"title": "t", "version": "1"}}, - "paths": {{"/a/{{id}}": {{"get": {{"x-forge-group": ["a"], "x-forge-method-name": "get", + "paths": {{"/a/{{id}}": {{"get": {{"x-forge-group-name": ["a"], "x-forge-method-name": "get", "parameters": [{{"name": "id", "in": "path", "required": true, "style": "{style}", "schema": {{"type": "string"}}}}], "responses": {{"204": {{"description": "ok"}}}}}}}}}} }}"# @@ -1016,7 +1016,7 @@ fn param_field_has_the_default_of_its_schema() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "list", + "paths": {"/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "list", "parameters": [ {"name": "per_page", "in": "query", "schema": {"type": "integer", "default": 20}}, {"name": "page", "in": "query", "schema": {"type": "integer"}} @@ -1035,7 +1035,7 @@ fn param_field_has_the_default_of_its_ref_schema() { let forge = parse( r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a/{order}": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "list", + "paths": {"/a/{order}": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "list", "parameters": [ {"name": "order", "in": "path", "required": true, "schema": {"$ref": "#/components/schemas/Order"}}, {"$ref": "#/components/parameters/Direction"} @@ -1062,7 +1062,7 @@ fn default_next_to_a_param_schema_ref_is_its_own_in_3_1() { let forge = parse( r##"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "list", + "paths": {"/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "list", "parameters": [{"name": "per_page", "in": "query", "schema": {"$ref": "#/components/schemas/PerPage", "default": 50}}], "responses": {"204": {"description": "ok"}}}}}, @@ -1079,7 +1079,7 @@ fn default_on_the_param_itself_is_ignored() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "list", + "paths": {"/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "list", "parameters": [{"name": "per_page", "in": "query", "default": 20, "schema": {"type": "integer"}}], "responses": {"204": {"description": "ok"}}}}} }"#, @@ -1096,7 +1096,7 @@ fn body_field_and_undeclared_placeholder_have_no_default() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a/{id}": {"put": {"x-forge-group": ["a"], "x-forge-method-name": "update", + "paths": {"/a/{id}": {"put": {"x-forge-group-name": ["a"], "x-forge-method-name": "update", "requestBody": {"content": {"application/json": {"schema": { "type": "array", "items": {"type": "string"}, "default": ["x"] }}}}, @@ -1114,7 +1114,7 @@ fn array_body_keeps_its_max_items() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"post": {"x-forge-group": ["a"], "x-forge-method-name": "create", + "paths": {"/a": {"post": {"x-forge-group-name": ["a"], "x-forge-method-name": "create", "requestBody": {"content": {"application/json": {"schema": { "type": "array", "items": {"type": "string"}, "maxItems": 100 }}}}, diff --git a/tests/parse/responses.rs b/tests/parse/responses.rs index 7ac12e940..6555d6935 100644 --- a/tests/parse/responses.rs +++ b/tests/parse/responses.rs @@ -7,7 +7,7 @@ fn responses_are_the_2xx_sorted_by_status() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"post": {"x-forge-group": ["a"], "x-forge-method-name": "create", + "paths": {"/a": {"post": {"x-forge-group-name": ["a"], "x-forge-method-name": "create", "responses": { "2XX": {"description": "ok"}, "201": {"description": "ok"}, @@ -29,7 +29,7 @@ fn errors_are_the_rest_sorted_with_default_last() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "get", "responses": { "default": {"description": "no"}, "500": {"description": "no"}, @@ -54,7 +54,7 @@ fn response_has_its_description() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "get", "responses": {"200": {"description": "The pet."}}}}} }"#, ); @@ -68,7 +68,7 @@ fn response_without_content_has_none() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"delete": {"x-forge-group": ["a"], "x-forge-method-name": "delete", + "paths": {"/a": {"delete": {"x-forge-group-name": ["a"], "x-forge-method-name": "delete", "responses": {"204": {"description": "ok"}}}}} }"#, ); @@ -83,7 +83,7 @@ fn response_ref_is_followed() { let forge = parse( r##"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "get", "responses": {"4XX": {"$ref": "#/components/responses/Failure"}}}}}, "components": {"responses": { "Failure": {"description": "Failed.", "content": {"application/json": {"schema": {"type": "string"}}}} @@ -102,7 +102,7 @@ fn response_content_that_is_not_json_is_typed() { let forge = parse( r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "download", + "paths": {"/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "download", "responses": {"200": {"description": "ok", "content": { "application/octet-stream": {"schema": {"type": "string", "format": "binary"}}, "text/csv": {"schema": {"type": "string"}} diff --git a/tests/parse/types/arrays.rs b/tests/parse/types/arrays.rs index 3af14fdf8..88caa3184 100644 --- a/tests/parse/types/arrays.rs +++ b/tests/parse/types/arrays.rs @@ -92,7 +92,7 @@ fn array_without_items_has_unknown_elements() { let forge = parse( r#"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { "type": "array" }}}}}}}} @@ -107,7 +107,7 @@ fn tuple_is_unknown() { let forge = parse( r#"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { "type": "array", "prefixItems": [{"type": "string"}, {"type": "integer"}], "items": false }}}}}}}} diff --git a/tests/parse/types/enums.rs b/tests/parse/types/enums.rs index a1511c847..1e331ab35 100644 --- a/tests/parse/types/enums.rs +++ b/tests/parse/types/enums.rs @@ -59,7 +59,7 @@ fn const_is_an_enum_of_one_value() { let forge = parse( r#"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { "const": "a" }}}}}}}} diff --git a/tests/parse/types/intersections.rs b/tests/parse/types/intersections.rs index 26c7b8ae3..0794b2739 100644 --- a/tests/parse/types/intersections.rs +++ b/tests/parse/types/intersections.rs @@ -49,7 +49,7 @@ fn true_member_is_dropped() { let forge = parse( r##"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { "allOf": [{"$ref": "#/components/schemas/A"}, true, {"$ref": "#/components/schemas/B"}] }}}}}}}}, @@ -141,7 +141,7 @@ fn unevaluated_properties_is_an_object_member() { let forge = parse( r##"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { "allOf": [{"$ref": "#/components/schemas/A"}], "unevaluatedProperties": {"type": "string"} diff --git a/tests/parse/types/nulls.rs b/tests/parse/types/nulls.rs index ecf06e087..79ee061b7 100644 --- a/tests/parse/types/nulls.rs +++ b/tests/parse/types/nulls.rs @@ -95,7 +95,7 @@ fn nullable_is_read_in_3_1() { let forge = parse( r#"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { "type": "string", "nullable": true }}}}}}}} @@ -110,7 +110,7 @@ fn null_in_a_type_array_is_a_union_with_null() { let forge = parse( r#"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { "type": ["string", "null"] }}}}}}}} @@ -125,7 +125,7 @@ fn null_type_is_null() { let forge = parse( r#"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { "type": "null" }}}}}}}} @@ -140,7 +140,7 @@ fn const_null_is_null() { let forge = parse( r#"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { "const": null }}}}}}}} diff --git a/tests/parse/types/objects.rs b/tests/parse/types/objects.rs index 7135a1f19..7ade16797 100644 --- a/tests/parse/types/objects.rs +++ b/tests/parse/types/objects.rs @@ -195,7 +195,7 @@ fn pattern_properties_are_ignored() { let forge = parse( r#"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { "type": "object", "properties": {"id": {"type": "string"}}, "patternProperties": {"^x-": {"type": "string"}} }}}}}}}} @@ -212,7 +212,7 @@ fn false_property_is_no_field() { let forge = parse( r#"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { "type": "object", "properties": {"id": {"type": "string"}, "gone": false} }}}}}}}} @@ -223,9 +223,9 @@ fn false_property_is_no_field() { } #[test] -fn property_ignored_by_x_forge_ignore_is_no_field() { +fn property_skipped_by_x_forge_skip_is_no_field() { let forge = parse(&responding_with( - r#"{"type": "object", "properties": {"id": {"type": "string"}, "old_id": {"type": "string", "x-forge-ignore": true}}}"#, + r#"{"type": "object", "properties": {"id": {"type": "string"}, "old_id": {"type": "string", "x-forge-skip": true}}}"#, )); assert_eq!(field_names(object(response_type(&forge, "a.get"))), ["id"]); diff --git a/tests/parse/types/refs.rs b/tests/parse/types/refs.rs index c736bf622..6c6429a6d 100644 --- a/tests/parse/types/refs.rs +++ b/tests/parse/types/refs.rs @@ -65,7 +65,7 @@ fn annotations_next_to_a_ref_keep_the_model_in_3_1() { let forge = parse( r##"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { "$ref": "#/components/schemas/Zone", "description": "The zone.", "deprecated": true }}}}}}}}, @@ -81,7 +81,7 @@ fn shape_next_to_a_ref_is_intersected_in_3_1() { let forge = parse( r##"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { "$ref": "#/components/schemas/Zone", "properties": {"extra": {"type": "string"}} }}}}}}}}, @@ -97,7 +97,7 @@ fn nullable_next_to_a_ref_is_a_union_with_null_in_3_1() { let forge = parse( r##"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { "$ref": "#/components/schemas/Zone", "nullable": true }}}}}}}}, @@ -153,7 +153,7 @@ fn description_next_to_a_property_ref_is_its_own_in_3_1() { let forge = parse( r##"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { "type": "object", "properties": {"home": {"$ref": "#/components/schemas/Zone", "description": "The home zone."}} }}}}}}}}, @@ -324,7 +324,7 @@ fn read_only_next_to_a_property_ref_counts_in_3_1() { let forge = parse( r##"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { "type": "object", "properties": {"id": {"$ref": "#/components/schemas/Id", "readOnly": true}} }}}}}}}}, @@ -350,7 +350,7 @@ fn read_only_false_next_to_a_ref_does_not_undo_its_schemas_in_3_1() { let forge = parse( r##"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { "type": "object", "properties": {"id": {"$ref": "#/components/schemas/Id", "readOnly": false}} }}}}}}}}, @@ -386,7 +386,7 @@ fn sensitive_next_to_a_property_ref_counts_in_3_1() { let forge = parse( r##"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { "type": "object", "properties": {"token": {"$ref": "#/components/schemas/Token", "x-forge-sensitive": true}} }}}}}}}}, @@ -424,7 +424,7 @@ fn property_default_is_the_first_along_a_chain_of_refs_in_3_1() { let forge = parse( r##"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { "type": "object", "properties": {"size": {"$ref": "#/components/schemas/Size"}} }}}}}}}}, @@ -454,7 +454,7 @@ fn default_next_to_a_property_ref_is_its_own_in_3_1() { let forge = parse( r##"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { "type": "object", "properties": { "size": {"$ref": "#/components/schemas/Size", "default": 5}, diff --git a/tests/parse/types/scalars.rs b/tests/parse/types/scalars.rs index 65b9e455c..3ec41e305 100644 --- a/tests/parse/types/scalars.rs +++ b/tests/parse/types/scalars.rs @@ -140,7 +140,7 @@ fn base64_content_encoding_is_bytes() { let forge = parse( r#"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { "type": "string", "contentEncoding": "base64" }}}}}}}} @@ -155,7 +155,7 @@ fn binary_format_is_binary_even_with_base64_content_encoding() { let forge = parse( r#"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { "type": "string", "format": "binary", "contentEncoding": "base64" }}}}}}}} @@ -198,7 +198,7 @@ fn true_schema_is_unknown() { let forge = parse( r#"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": true}}}}}}} }"#, ); diff --git a/tests/parse/types/unions.rs b/tests/parse/types/unions.rs index f8349fe71..8f26b6e38 100644 --- a/tests/parse/types/unions.rs +++ b/tests/parse/types/unions.rs @@ -76,7 +76,7 @@ fn type_array_is_exclusive() { let forge = parse( r#"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { "type": ["string", "integer"] }}}}}}}} @@ -138,7 +138,7 @@ fn types_in_a_type_array_are_a_union() { let forge = parse( r#"{ "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { "type": ["string", "integer"] }}}}}}}} @@ -329,7 +329,7 @@ fn discriminator_default_mapping_is_the_default() { let forge = parse( r##"{ "openapi": "3.2.0", "info": {"title": "t", "version": "1"}, - "paths": {"/a": {"get": {"x-forge-group": ["a"], "x-forge-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { "oneOf": [{"$ref": "#/components/schemas/A"}, {"$ref": "#/components/schemas/B"}], "discriminator": {"propertyName": "kind", "mapping": {"a": "#/components/schemas/A"}, "defaultMapping": "#/components/schemas/B"} diff --git a/tests/wasm.rs b/tests/wasm.rs index 95b4730cd..18bedd8b9 100644 --- a/tests/wasm.rs +++ b/tests/wasm.rs @@ -41,7 +41,7 @@ const SPEC: &str = r##"{ "paths": { "/pets": { "get": { - "x-forge-group": ["pets"], "x-forge-method-name": "list", "summary": "Lists pets.", + "x-forge-group-name": ["pets"], "x-forge-method-name": "list", "summary": "Lists pets.", "parameters": [ {"name": "limit", "in": "query", "schema": {"type": "integer", "format": "int32"}}, {"name": "tags", "in": "query", "explode": false, "schema": {"type": "array", "items": {"type": "string"}}} @@ -52,14 +52,14 @@ const SPEC: &str = r##"{ } }, "post": { - "x-forge-group": ["pets"], "x-forge-method-name": "create", "deprecated": true, + "x-forge-group-name": ["pets"], "x-forge-method-name": "create", "deprecated": true, "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/NewPet"}}}}, "responses": {"201": {"description": "created", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Pet"}}}}} } }, "/pets/{pet_id}": { "get": { - "x-forge-group": ["pets"], "x-forge-method-name": "get", + "x-forge-group-name": ["pets"], "x-forge-method-name": "get", "parameters": [{"name": "pet_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}}], "responses": { "200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Pet"}}}}, @@ -69,7 +69,7 @@ const SPEC: &str = r##"{ }, "/pets/{pet_id}/photos": { "put": { - "x-forge-group": ["pets", "photos"], "x-forge-method-name": "upload", + "x-forge-group-name": ["pets", "photos"], "x-forge-method-name": "upload", "parameters": [{"name": "pet_id", "in": "path", "required": true, "schema": {"type": "string"}}], "requestBody": {"content": {"application/octet-stream": {"schema": {"type": "string", "format": "binary"}}}}, "responses": {"204": {"description": "done"}} @@ -77,7 +77,7 @@ const SPEC: &str = r##"{ }, "/owners": { "get": { - "x-forge-group": ["owners"], "x-forge-method-name": "list", + "x-forge-group-name": ["owners"], "x-forge-method-name": "list", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { "type": "object", "properties": { @@ -138,7 +138,7 @@ const FEATURES: &str = r##"{ "/pets/{id}": { "parameters": [{"name": "X-Trace", "in": "header", "schema": {"type": "string"}}], "get": { - "x-forge-group": ["pets"], "x-forge-method-name": "get", + "x-forge-group-name": ["pets"], "x-forge-method-name": "get", "x-forge-availability": "beta", "x-forge-hidden": true, "x-custom": {"b": 1, "a": [1.5, "two \"quoted\"\n", null, true, {}], "b": 2}, "x-flag": false, "x-count": 9007199254740993, "x-text": "é 😀", @@ -152,9 +152,9 @@ const FEATURES: &str = r##"{ }, "delete": { "x-forge-aliases": [ - {"x-forge-group": ["pets"], "x-forge-method-name": "remove", "x-forge-availability": "deprecated", + {"x-forge-group-name": ["pets"], "x-forge-method-name": "remove", "x-forge-availability": "deprecated", "x-forge-require-confirmation": "This deletes the pet.", "x-alias": [1]}, - {"x-forge-group": ["admin", "pets"], "x-forge-method-name": "delete", "x-forge-require-confirmation": ""} + {"x-forge-group-name": ["admin", "pets"], "x-forge-method-name": "delete", "x-forge-require-confirmation": ""} ], "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}], "responses": {"204": {"description": "gone"}} @@ -162,7 +162,7 @@ const FEATURES: &str = r##"{ }, "/pets/{id}/photo": { "put": { - "x-forge-group": ["pets", "photos"], "x-forge-method-name": "upload", + "x-forge-group-name": ["pets", "photos"], "x-forge-method-name": "upload", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}], "requestBody": {"required": true, "content": { "application/octet-stream": {"schema": {"type": "string", "format": "binary", "description": "The photo."}}, From 19b3577a81cce3554b2231e256cd9a04a7716d32 Mon Sep 17 00:00:00 2001 From: Niels Swimberghe Date: Mon, 26 Oct 2026 23:22:47 -0400 Subject: [PATCH 19/25] docs: say where vendor compatibility lives, and how to add or remove it AGENTS.md gets a short section: only src/compat/ names vendor extensions, it sits behind the compat feature, native keys win, and tests/boundary.rs enforces it. src/compat/README.md holds the longer how-to, next to the code it describes and deleted along with it. --- AGENTS.md | 10 ++++++++++ src/compat/README.md | 47 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+) create mode 100644 src/compat/README.md diff --git a/AGENTS.md b/AGENTS.md index c43a3f71d..39e758679 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,6 +8,16 @@ API documentation site for Forge OpenAPI specs, built on Astro. - **`packages/fern-forge`**: `astro-fern` extension that validates and adapts Forge OpenAPI metadata. - **`packages/docs-site`**: API documentation website consuming `astro-fern` and `fern-forge`. The OpenAPI spec is fetched from the `openapi.forge.json` GitHub release asset (or a local file via `FORGE_OPENAPI_SPEC`). - **`packages/cloudflare-terraform-docs`**: Generator for the site's Terraform documentation data, derived from the released Cloudflare Terraform provider. Temporary: removed once Forge supports Terraform natively (see the site's README). +- **`src/`**: The Rust engine: OpenAPI to linked model to plugin host. It reads only native `x-forge-*` extensions (`src/metadata/`); other tools' extensions are translated in `src/compat/`. + +## Vendor compatibility + +Only `src/compat/` knows vendor extensions (`x-fern-*`, `x-stainless-*`). It is sectioned off so it can be deleted as a whole. See [`src/compat/README.md`](src/compat/README.md). + +- Never name a vendor extension outside `src/compat/` and `tests/compat/`, comments and docs included. `tests/boundary.rs` enforces this (`just boundary`). +- Compat is behind the default-on `compat` feature and is only a fallback: native `x-forge-*` keys always win. +- Support for a new vendor key means mapping it onto an existing native primitive in an adapter. Add the native key first if there is none. +- The docs packages (`astro-fern`, `fern-forge`, `cloudflare-api-site`) read vendor keys themselves and are not covered by the boundary. ## Conventions diff --git a/src/compat/README.md b/src/compat/README.md new file mode 100644 index 000000000..6ebaee086 --- /dev/null +++ b/src/compat/README.md @@ -0,0 +1,47 @@ +# The compatibility layer + +The engine understands one vocabulary: the native `x-forge-*` extensions in +[`src/metadata/`](../metadata/mod.rs). This folder is the only place that +knows any other tool's extensions (Fern's `x-fern-*`, Stainless's +`x-stainless-*`). Each adapter maps its tool's keys onto native primitives. + +The layer is meant to be deleted as a whole once nobody writes those keys. +Everything below follows from that. + +## Rules + +- **Only this folder names a vendor extension.** That includes the readers, + the linker, the plugin host, the WIT, the `packages/forge` npm package, and + their comments and docs. `tests/boundary.rs` fails, with file and line, if + one appears elsewhere (`just boundary`; it also runs with `cargo test`). + Vendor tests live in `tests/compat/`. +- **Native wins.** A vendor key is read only for a primitive the document + has no native key for. When both are written, the native value is used and + the vendor value is not even validated, whatever order they appear in. +- **Behind the `compat` feature** (on by default). Native keys must mean the + same with it off. CI compiles that configuration (clippy with + `--no-default-features`) but does not run its tests. +- **Mapping, not semantics.** An adapter says which native primitive a key + stands for and how its value is written, as data. Anything structural, such + as how aliases become methods, belongs to the builder, not here. +- **No vendor knowledge leaks out.** Keys are private to their adapter. + `mod.rs` is the only way in: one resolver per kind of object, and one name + lookup per kind so errors can name the key the author wrote. + +## Adding a vendor key + +1. Find the native primitive it stands for in `src/metadata/`. If there is + none, add a native `x-forge-*` key first: a vendor key never introduces a + primitive. +2. Map it in the adapter for its tool, or add an adapter next to `fern.rs` + and `stainless.rs` and register it in `mod.rs`. +3. Add a test in `tests/compat/` showing that the vendor-keyed document + parses to the same `Forge` as the native-keyed one. + +## Removing the layer + +Delete this folder and `tests/compat/`, `mod compat` in `src/lib.rs`, the +`compat` arms in `src/link/resolve.rs`, and the `compat` feature and its +`[[test]]` entry in `Cargo.toml`. No native extension changes. A few +native-side items exist only for the layer and are left unused; removing them +is a mechanical follow-up. From a852e335439422e63c4d3b50ed06436998c142b0 Mon Sep 17 00:00:00 2001 From: Niels Swimberghe Date: Mon, 26 Oct 2026 23:22:47 -0400 Subject: [PATCH 20/25] refactor: group extension keys by the level they apply to Each level (operation or alias, parameter, schema, document) now has its own module holding its key constants, its table of names and its reader, in the native vocabulary and in each adapter. A key constant is no longer in scope at a level it does not belong to. Tests check that every key is read at exactly the levels that list it. Also drop the key tables from the adapters' module docs: the code is the source of truth for the keys. --- src/compat/fern.rs | 167 +++++++++++++++------------- src/compat/mod.rs | 66 ++++++++---- src/compat/stainless.rs | 43 ++++---- src/link/resolve.rs | 16 +-- src/metadata/mod.rs | 234 ++++++++++++++++++++++++---------------- 5 files changed, 315 insertions(+), 211 deletions(-) diff --git a/src/compat/fern.rs b/src/compat/fern.rs index 1fbf2fcab..771c76f45 100644 --- a/src/compat/fern.rs +++ b/src/compat/fern.rs @@ -1,15 +1,5 @@ //! The adapter for Fern's `x-fern-*` extensions. //! -//! | Key | Says | Format | -//! |---|---|---| -//! | `x-fern-sdk-method-name` | the name of the method | a string | -//! | `x-fern-sdk-group-name` | the group path of the method | as `x-forge-group-name`: an array of group names, or one string of them separated by dots | -//! | `x-fern-ignore` | the definition makes no method | a boolean | -//! | `x-fern-availability` | the stage of the API | a string, one of the native spellings | -//! | `x-fern-parameter-name` | the name of a parameter's field | a string | -//! | `x-fern-property-name` | the name of a property's field | a string | -//! | `x-fern-type-name` | the name of a model | a string | -//! //! A group path written as one string is split on dots, as for //! `x-forge-group-name`, which Fern itself does not do: Fern reads a string as a //! single group. A document written for Fern uses the array, which means the @@ -18,103 +8,136 @@ //! Its other extensions, such as `x-fern-enum` and `x-fern-audiences`, are //! not read: they say things forge has no primitive for. -use crate::metadata::{BoolFormat, DefinitionField, Hit, ParameterField, SchemaField}; - /// What every key of the adapter begins with. A key without it is turned /// away before it is matched against the table. const PREFIX: &str = "x-fern-"; -const METHOD_NAME: &str = "x-fern-sdk-method-name"; -const GROUP_NAME: &str = "x-fern-sdk-group-name"; -const IGNORE: &str = "x-fern-ignore"; -const AVAILABILITY: &str = "x-fern-availability"; -const PARAMETER_NAME: &str = "x-fern-parameter-name"; -const PROPERTY_NAME: &str = "x-fern-property-name"; -const TYPE_NAME: &str = "x-fern-type-name"; - -/// The names of the adapter's keys of an operation or an alias, by slot. -pub(super) const DEFINITION_NAMES: [&str; 4] = [METHOD_NAME, GROUP_NAME, IGNORE, AVAILABILITY]; -/// The names of the adapter's keys of a Parameter Object, by slot. -pub(super) const PARAMETER_NAMES: [&str; 1] = [PARAMETER_NAME]; -/// The names of the adapter's keys of a Schema Object, by slot. -pub(super) const SCHEMA_NAMES: [&str; 2] = [TYPE_NAME, PROPERTY_NAME]; - -pub(super) fn definition(key: &str) -> Option> { - if !key.starts_with(PREFIX) { - return None; + +/// The keys of an operation or an alias. +pub(super) mod definition { + use super::PREFIX; + use crate::metadata::{BoolFormat, DefinitionField, Hit}; + + const METHOD_NAME: &str = "x-fern-sdk-method-name"; + const GROUP_NAME: &str = "x-fern-sdk-group-name"; + const IGNORE: &str = "x-fern-ignore"; + const AVAILABILITY: &str = "x-fern-availability"; + + /// The names of the adapter's keys of an operation or an alias, by slot. + pub(crate) const NAMES: [&str; 4] = [METHOD_NAME, GROUP_NAME, IGNORE, AVAILABILITY]; + + pub(crate) fn read(key: &str) -> Option> { + if !key.starts_with(PREFIX) { + return None; + } + Some(match key { + METHOD_NAME => Hit::compat(DefinitionField::Method, 0), + GROUP_NAME => Hit::compat(DefinitionField::Group, 1), + IGNORE => Hit::compat(DefinitionField::Skip(BoolFormat::Bool), 2), + AVAILABILITY => Hit::compat(DefinitionField::Availability, 3), + _ => return None, + }) } - Some(match key { - METHOD_NAME => Hit::compat(DefinitionField::Method, 0), - GROUP_NAME => Hit::compat(DefinitionField::Group, 1), - IGNORE => Hit::compat(DefinitionField::Skip(BoolFormat::Bool), 2), - AVAILABILITY => Hit::compat(DefinitionField::Availability, 3), - _ => return None, - }) } -pub(super) fn parameter(key: &str) -> Option> { - if !key.starts_with(PREFIX) { - return None; +/// The keys of a Parameter Object. +pub(super) mod parameter { + use super::PREFIX; + use crate::metadata::{Hit, ParameterField}; + + const NAME: &str = "x-fern-parameter-name"; + + /// The names of the adapter's keys of a Parameter Object, by slot. + pub(crate) const NAMES: [&str; 1] = [NAME]; + + pub(crate) fn read(key: &str) -> Option> { + if !key.starts_with(PREFIX) { + return None; + } + Some(match key { + NAME => Hit::compat(ParameterField::Name, 0), + _ => return None, + }) } - Some(match key { - PARAMETER_NAME => Hit::compat(ParameterField::Name, 0), - _ => return None, - }) } -pub(super) fn schema(key: &str) -> Option> { - if !key.starts_with(PREFIX) { - return None; +/// The keys of a Schema Object. +pub(super) mod schema { + use super::PREFIX; + use crate::metadata::{Hit, SchemaField}; + + const TYPE_NAME: &str = "x-fern-type-name"; + const PROPERTY_NAME: &str = "x-fern-property-name"; + + /// The names of the adapter's keys of a Schema Object, by slot. + pub(crate) const NAMES: [&str; 2] = [TYPE_NAME, PROPERTY_NAME]; + + pub(crate) fn read(key: &str) -> Option> { + if !key.starts_with(PREFIX) { + return None; + } + Some(match key { + TYPE_NAME => Hit::compat(SchemaField::ModelName, 0), + PROPERTY_NAME => Hit::compat(SchemaField::PropertyName, 1), + _ => return None, + }) } - Some(match key { - TYPE_NAME => Hit::compat(SchemaField::ModelName, 0), - PROPERTY_NAME => Hit::compat(SchemaField::PropertyName, 1), - _ => return None, - }) } #[cfg(test)] mod tests { use super::*; - use crate::metadata::Tier; + + /// The names of the keys of a level, and whether the level reads a key. + type Level<'a> = (&'a [&'a str], fn(&str) -> bool); + use crate::metadata::{DefinitionField, Tier}; #[test] fn each_key_is_read_as_itself_and_as_not_native() { - for key in [METHOD_NAME, GROUP_NAME, IGNORE, AVAILABILITY] { - let hit = definition(key).unwrap_or_else(|| panic!("{key} is not a definition key")); + for key in definition::NAMES { + let hit = definition::read(key).unwrap_or_else(|| panic!("{key} is not a definition key")); + assert_eq!(hit.tier, Tier::Compat, "{key}"); + } + for key in parameter::NAMES { + let hit = parameter::read(key).unwrap_or_else(|| panic!("{key} is not a parameter key")); assert_eq!(hit.tier, Tier::Compat, "{key}"); } - let hit = parameter(PARAMETER_NAME).unwrap(); - assert_eq!(hit.tier, Tier::Compat, "{PARAMETER_NAME}"); - for key in [TYPE_NAME, PROPERTY_NAME] { - let hit = schema(key).unwrap_or_else(|| panic!("{key} is not a schema key")); + for key in schema::NAMES { + let hit = schema::read(key).unwrap_or_else(|| panic!("{key} is not a schema key")); assert_eq!(hit.tier, Tier::Compat, "{key}"); } } #[test] - fn a_key_is_read_only_where_it_says_something() { - assert!(definition(PARAMETER_NAME).is_none()); - assert!(definition(PROPERTY_NAME).is_none()); - assert!(parameter(METHOD_NAME).is_none()); - assert!(schema(IGNORE).is_none()); - assert!(schema(METHOD_NAME).is_none()); + fn a_key_is_read_only_at_its_level() { + let levels: [Level; 3] = [ + (&definition::NAMES, |key| definition::read(key).is_some()), + (¶meter::NAMES, |key| parameter::read(key).is_some()), + (&schema::NAMES, |key| schema::read(key).is_some()), + ]; + for (own, _) in levels { + for &key in own { + for (names, reads) in levels { + assert_eq!(reads(key), names.contains(&key), "{key}"); + } + } + } } #[test] fn slots_are_unique_among_the_keys_of_an_object() { - let mut slots: Vec = [METHOD_NAME, GROUP_NAME, IGNORE, AVAILABILITY] + let mut slots: Vec = definition::NAMES .iter() - .map(|key| definition(key).unwrap().slot) + .map(|key| definition::read(key).unwrap().slot) .collect(); slots.sort_unstable(); slots.dedup(); - assert_eq!(slots.len(), 4); + assert_eq!(slots.len(), definition::NAMES.len()); assert!(slots.iter().all(|&slot| slot >= 32), "slots of other keys start at 32"); } #[test] fn the_group_is_read_as_the_native_group() { - let hit = definition(GROUP_NAME).unwrap(); + let hit = definition::read("x-fern-sdk-group-name").unwrap(); assert!(matches!(hit.field, DefinitionField::Group)); } @@ -126,9 +149,9 @@ mod tests { "x-fern-pagination", "x-fern-sdk-variables", ] { - assert!(definition(key).is_none(), "{key}"); - assert!(parameter(key).is_none(), "{key}"); - assert!(schema(key).is_none(), "{key}"); + assert!(definition::read(key).is_none(), "{key}"); + assert!(parameter::read(key).is_none(), "{key}"); + assert!(schema::read(key).is_none(), "{key}"); } } } diff --git a/src/compat/mod.rs b/src/compat/mod.rs index f4b590ee4..46c87e2f8 100644 --- a/src/compat/mod.rs +++ b/src/compat/mod.rs @@ -21,49 +21,52 @@ use crate::metadata::{DefinitionField, Hit, ParameterField, SchemaField}; /// Returns what `key` says on an operation or an alias, if an adapter reads /// it there. pub(crate) fn definition(key: &str) -> Option> { - fern::definition(key) + fern::definition::read(key) } /// Returns what `key` says on a Parameter Object, if an adapter reads it /// there. pub(crate) fn parameter(key: &str) -> Option> { - fern::parameter(key) + fern::parameter::read(key) } /// Returns what `key` says on a Schema Object, if an adapter reads it there. /// Each adapter counts its keys from zero, so Stainless's come after Fern's. pub(crate) fn schema(key: &str) -> Option> { - fern::schema(key).or_else(|| stainless::schema(key).map(|hit| hit.after(FERN_SCHEMA_KEYS))) + fern::schema::read(key).or_else(|| stainless::schema::read(key).map(|hit| hit.after(FERN_SCHEMA_KEYS))) } /// How many keys of a Schema Object Fern has, which is where Stainless's /// start. -const FERN_SCHEMA_KEYS: u8 = fern::SCHEMA_NAMES.len() as u8; +const FERN_SCHEMA_KEYS: u8 = fern::schema::NAMES.len() as u8; /// Returns the name of the key of an operation or an alias that is `n`th /// among the keys of the adapters. pub(crate) fn definition_name(n: u8) -> &'static str { - fern::DEFINITION_NAMES[usize::from(n)] + fern::definition::NAMES[usize::from(n)] } /// Returns the name of the key of a Parameter Object that is `n`th among the /// keys of the adapters. pub(crate) fn parameter_name(n: u8) -> &'static str { - fern::PARAMETER_NAMES[usize::from(n)] + fern::parameter::NAMES[usize::from(n)] } /// Returns the name of the key of a Schema Object that is `n`th among the /// keys of the adapters. pub(crate) fn schema_name(n: u8) -> &'static str { match n.checked_sub(FERN_SCHEMA_KEYS) { - None => fern::SCHEMA_NAMES[usize::from(n)], - Some(n) => stainless::SCHEMA_NAMES[usize::from(n)], + None => fern::schema::NAMES[usize::from(n)], + Some(n) => stainless::schema::NAMES[usize::from(n)], } } #[cfg(test)] mod tests { use super::*; + + /// The names of the keys of a level, and whether the level reads a key. + type Level<'a> = (&'a [&'a str], fn(&str) -> bool); use crate::metadata::{self, COMPAT_SLOTS, Tier}; /// Asserts that each key in `names` is read by `read` as a key that is @@ -79,10 +82,29 @@ mod tests { #[test] fn each_key_of_each_adapter_is_named_by_its_slot() { - read_as_itself(&fern::DEFINITION_NAMES, definition, definition_name); - read_as_itself(&fern::PARAMETER_NAMES, parameter, parameter_name); - read_as_itself(&fern::SCHEMA_NAMES, schema, schema_name); - read_as_itself(&stainless::SCHEMA_NAMES, schema, schema_name); + read_as_itself(&fern::definition::NAMES, definition, definition_name); + read_as_itself(&fern::parameter::NAMES, parameter, parameter_name); + read_as_itself(&fern::schema::NAMES, schema, schema_name); + read_as_itself(&stainless::schema::NAMES, schema, schema_name); + } + + #[test] + fn a_key_of_an_adapter_is_read_only_at_its_level() { + let definitions = fern::definition::NAMES.to_vec(); + let parameters = fern::parameter::NAMES.to_vec(); + let schemas = [&fern::schema::NAMES[..], &stainless::schema::NAMES[..]].concat(); + let levels: [Level; 3] = [ + (&definitions, |key| definition(key).is_some()), + (¶meters, |key| parameter(key).is_some()), + (&schemas, |key| schema(key).is_some()), + ]; + for (own, _) in levels { + for &key in own { + for (names, reads) in levels { + assert_eq!(reads(key), names.contains(&key), "{key}"); + } + } + } } #[test] @@ -95,25 +117,27 @@ mod tests { .copied() .collect() }; - let definitions = keys(&metadata::DEFINITION_NAMES, &[&fern::DEFINITION_NAMES]); - let parameters = keys(&metadata::PARAMETER_NAMES, &[&fern::PARAMETER_NAMES]); + let definitions = keys(&metadata::definition::NAMES, &[&fern::definition::NAMES]); + let parameters = keys(&metadata::parameter::NAMES, &[&fern::parameter::NAMES]); let schemas = keys( - &metadata::SCHEMA_NAMES, - &[&fern::SCHEMA_NAMES, &stainless::SCHEMA_NAMES], + &metadata::schema::NAMES, + &[&fern::schema::NAMES, &stainless::schema::NAMES], ); metadata::assert_one_key_per_tier(&definitions, |key| { - metadata::definition(key).or_else(|| definition(key)) + metadata::definition::read(key).or_else(|| definition(key)) + }); + metadata::assert_one_key_per_tier(¶meters, |key| { + metadata::parameter::read(key).or_else(|| parameter(key)) }); - metadata::assert_one_key_per_tier(¶meters, |key| metadata::parameter(key).or_else(|| parameter(key))); - metadata::assert_one_key_per_tier(&schemas, |key| metadata::schema(key).or_else(|| schema(key))); + metadata::assert_one_key_per_tier(&schemas, |key| metadata::schema::read(key).or_else(|| schema(key))); } #[test] fn the_slots_of_the_adapters_do_not_collide_and_fit_in_a_set() { - let mut slots: Vec = fern::SCHEMA_NAMES + let mut slots: Vec = fern::schema::NAMES .iter() - .chain(&stainless::SCHEMA_NAMES) + .chain(&stainless::schema::NAMES) .map(|key| schema(key).unwrap().slot) .collect(); let len = slots.len(); diff --git a/src/compat/stainless.rs b/src/compat/stainless.rs index caa76ba0c..275ed5b8b 100644 --- a/src/compat/stainless.rs +++ b/src/compat/stainless.rs @@ -1,42 +1,47 @@ //! The adapter for Stainless's `x-stainless-*` extensions. //! -//! | Key | Says | Format | -//! |---|---|---| -//! | `x-stainless-skip` | the property makes no field | a boolean, or an array of the targets to skip it for, which counts as `false` | +//! `x-stainless-skip` may be a list of the targets to skip a property for, +//! which counts as `false`. //! //! Its other extensions, such as `x-stainless-terraform-configurability` and //! `x-stainless-deprecation-message`, are not read: they say things forge has //! no primitive for. -use crate::metadata::{BoolFormat, Hit, SchemaField}; - /// What every key of the adapter begins with. A key without it is turned /// away before it is matched against the table. const PREFIX: &str = "x-stainless-"; -const SKIP: &str = "x-stainless-skip"; -/// The names of the adapter's keys of a Schema Object, by slot. -pub(super) const SCHEMA_NAMES: [&str; 1] = [SKIP]; +/// The keys of a Schema Object. +pub(super) mod schema { + use super::PREFIX; + use crate::metadata::{BoolFormat, Hit, SchemaField}; + + const SKIP: &str = "x-stainless-skip"; -pub(super) fn schema(key: &str) -> Option> { - if !key.starts_with(PREFIX) { - return None; + /// The names of the adapter's keys of a Schema Object, by slot. + pub(crate) const NAMES: [&str; 1] = [SKIP]; + + pub(crate) fn read(key: &str) -> Option> { + if !key.starts_with(PREFIX) { + return None; + } + Some(match key { + SKIP => Hit::compat(SchemaField::Skip(BoolFormat::BoolOrTargets), 0), + _ => return None, + }) } - Some(match key { - SKIP => Hit::compat(SchemaField::Skip(BoolFormat::BoolOrTargets), 0), - _ => return None, - }) } #[cfg(test)] mod tests { use super::*; - use crate::metadata::Tier; + use crate::metadata::{BoolFormat, SchemaField, Tier}; #[test] fn the_skip_is_read_on_a_schema_as_a_boolean_or_targets_and_not_native() { - let hit = schema(SKIP).unwrap(); - assert_eq!(hit.tier, Tier::Compat, "{SKIP}"); + let key = "x-stainless-skip"; + let hit = schema::read(key).unwrap(); + assert_eq!(hit.tier, Tier::Compat, "{key}"); assert!(matches!(hit.field, SchemaField::Skip(BoolFormat::BoolOrTargets))); } @@ -47,7 +52,7 @@ mod tests { "x-stainless-deprecation-message", "x-fern-ignore", ] { - assert!(schema(key).is_none(), "{key}"); + assert!(schema::read(key).is_none(), "{key}"); } } } diff --git a/src/link/resolve.rs b/src/link/resolve.rs index 956351c13..2a2fae2bd 100644 --- a/src/link/resolve.rs +++ b/src/link/resolve.rs @@ -16,23 +16,23 @@ use crate::metadata::{self, DefinitionField, DocumentField, Hit, ParameterField, /// Returns what `key` says on an operation or an alias, if it is a key of /// one. pub(super) fn definition(key: &str) -> Option> { - metadata::definition(key).or_else(|| compat_definition(key)) + metadata::definition::read(key).or_else(|| compat_definition(key)) } /// Returns what `key` says on a Parameter Object, if it is a key of one. pub(super) fn parameter(key: &str) -> Option> { - metadata::parameter(key).or_else(|| compat_parameter(key)) + metadata::parameter::read(key).or_else(|| compat_parameter(key)) } /// Returns what `key` says on a Schema Object, if it is a key of one. pub(super) fn schema(key: &str) -> Option> { - metadata::schema(key).or_else(|| compat_schema(key)) + metadata::schema::read(key).or_else(|| compat_schema(key)) } /// Returns what `key` says at the root of the document, if it is a key of /// it. No compatibility key says anything there. pub(super) fn document(key: &str) -> Option> { - metadata::document(key) + metadata::document::read(key) } /// A kind of object with extensions, which can name its keys by slot. @@ -43,25 +43,25 @@ pub(super) trait Kind { impl Kind for DefinitionField { fn name(slot: u8) -> &'static str { - metadata::native_name(&metadata::DEFINITION_NAMES, slot).unwrap_or_else(|| compat_definition_name(slot)) + metadata::native_name(&metadata::definition::NAMES, slot).unwrap_or_else(|| compat_definition_name(slot)) } } impl Kind for ParameterField { fn name(slot: u8) -> &'static str { - metadata::native_name(&metadata::PARAMETER_NAMES, slot).unwrap_or_else(|| compat_parameter_name(slot)) + metadata::native_name(&metadata::parameter::NAMES, slot).unwrap_or_else(|| compat_parameter_name(slot)) } } impl Kind for SchemaField { fn name(slot: u8) -> &'static str { - metadata::native_name(&metadata::SCHEMA_NAMES, slot).unwrap_or_else(|| compat_schema_name(slot)) + metadata::native_name(&metadata::schema::NAMES, slot).unwrap_or_else(|| compat_schema_name(slot)) } } impl Kind for DocumentField { fn name(slot: u8) -> &'static str { - metadata::native_name(&metadata::DOCUMENT_NAMES, slot).unwrap_or_default() + metadata::native_name(&metadata::document::NAMES, slot).unwrap_or_default() } } diff --git a/src/metadata/mod.rs b/src/metadata/mod.rs index 84b91cf2c..89b680973 100644 --- a/src/metadata/mod.rs +++ b/src/metadata/mod.rs @@ -175,91 +175,118 @@ pub(crate) enum BoolFormat { BoolOrTargets, } -/// Returns what `key` says on an operation or an alias, if it is a native -/// key of one. -pub(crate) fn definition(key: &str) -> Option> { - if !key.starts_with(PREFIX) { - return None; - } - Some(match key { - METHOD_NAME => Hit::native(DefinitionField::Method, 0), - GROUP_NAME => Hit::native(DefinitionField::Group, 1), - SKIP => Hit::native(DefinitionField::Skip(BoolFormat::Bool), 2), - AVAILABILITY => Hit::native(DefinitionField::Availability, 3), - HIDDEN => Hit::native(DefinitionField::Hidden, 4), - CONFIRMATION => Hit::native(DefinitionField::Confirmation, 5), - _ => return None, - }) -} - -const METHOD_NAME: &str = "x-forge-method-name"; -const GROUP_NAME: &str = "x-forge-group-name"; -const SKIP: &str = "x-forge-skip"; -const AVAILABILITY: &str = "x-forge-availability"; -const HIDDEN: &str = "x-forge-hidden"; -const CONFIRMATION: &str = "x-forge-require-confirmation"; -const PARAMETER_NAME: &str = "x-forge-parameter-name"; -const MODEL_NAME: &str = "x-forge-model-name"; -const PROPERTY_NAME: &str = "x-forge-property-name"; -const SENSITIVE: &str = "x-forge-sensitive"; -/// The old name of `x-forge-sensitive`, from before every native key began -/// with `x-forge-`. Deprecated: it is read only where `x-forge-sensitive` is -/// not written. -const DEPRECATED_SENSITIVE: &str = "x-sensitive"; -const COMMANDS: &str = "x-forge-commands"; - -/// The names of the native keys of an operation or an alias, by slot. -pub(crate) const DEFINITION_NAMES: [&str; 6] = [METHOD_NAME, GROUP_NAME, SKIP, AVAILABILITY, HIDDEN, CONFIRMATION]; -/// The names of the native keys of a Parameter Object, by slot. -pub(crate) const PARAMETER_NAMES: [&str; 1] = [PARAMETER_NAME]; -/// The names of the native keys of a Schema Object, by slot. -pub(crate) const SCHEMA_NAMES: [&str; 5] = [MODEL_NAME, PROPERTY_NAME, SKIP, SENSITIVE, DEPRECATED_SENSITIVE]; -/// The names of the native keys of the document, by slot. -pub(crate) const DOCUMENT_NAMES: [&str; 1] = [COMMANDS]; - /// Returns whether `key` is the `x-forge-aliases` of an operation. pub(crate) fn is_aliases(key: &str) -> bool { key == ALIASES } -/// Returns what `key` says on a Parameter Object, if it is a native key of -/// one. -pub(crate) fn parameter(key: &str) -> Option> { - if !key.starts_with(PREFIX) { - return None; +/// The native keys of an operation, or of an alias in its `x-forge-aliases`. +pub(crate) mod definition { + use super::{BoolFormat, DefinitionField, Hit, PREFIX}; + + const METHOD_NAME: &str = "x-forge-method-name"; + const GROUP_NAME: &str = "x-forge-group-name"; + const SKIP: &str = "x-forge-skip"; + const AVAILABILITY: &str = "x-forge-availability"; + const HIDDEN: &str = "x-forge-hidden"; + const CONFIRMATION: &str = "x-forge-require-confirmation"; + + /// The names of the native keys of an operation or an alias, by slot. + pub(crate) const NAMES: [&str; 6] = [METHOD_NAME, GROUP_NAME, SKIP, AVAILABILITY, HIDDEN, CONFIRMATION]; + + /// Returns what `key` says on an operation or an alias, if it is a native + /// key of one. + pub(crate) fn read(key: &str) -> Option> { + if !key.starts_with(PREFIX) { + return None; + } + Some(match key { + METHOD_NAME => Hit::native(DefinitionField::Method, 0), + GROUP_NAME => Hit::native(DefinitionField::Group, 1), + SKIP => Hit::native(DefinitionField::Skip(BoolFormat::Bool), 2), + AVAILABILITY => Hit::native(DefinitionField::Availability, 3), + HIDDEN => Hit::native(DefinitionField::Hidden, 4), + CONFIRMATION => Hit::native(DefinitionField::Confirmation, 5), + _ => return None, + }) + } +} + +/// The native keys of a Parameter Object. +pub(crate) mod parameter { + use super::{Hit, PREFIX, ParameterField}; + + const NAME: &str = "x-forge-parameter-name"; + + /// The names of the native keys of a Parameter Object, by slot. + pub(crate) const NAMES: [&str; 1] = [NAME]; + + /// Returns what `key` says on a Parameter Object, if it is a native key of + /// one. + pub(crate) fn read(key: &str) -> Option> { + if !key.starts_with(PREFIX) { + return None; + } + Some(match key { + NAME => Hit::native(ParameterField::Name, 0), + _ => return None, + }) } - Some(match key { - PARAMETER_NAME => Hit::native(ParameterField::Name, 0), - _ => return None, - }) } -/// Returns what `key` says on a Schema Object, if it is a native key of one. -pub(crate) fn schema(key: &str) -> Option> { - // `x-sensitive` is the one native key outside the prefix. - if !key.starts_with(PREFIX) && key != DEPRECATED_SENSITIVE { - return None; +/// The native keys of a Schema Object. +pub(crate) mod schema { + use super::{BoolFormat, Hit, PREFIX, SchemaField}; + + const MODEL_NAME: &str = "x-forge-model-name"; + const PROPERTY_NAME: &str = "x-forge-property-name"; + const SKIP: &str = "x-forge-skip"; + const SENSITIVE: &str = "x-forge-sensitive"; + /// The old name of `x-forge-sensitive`, from before every native key began + /// with `x-forge-`. Deprecated: it is read only where `x-forge-sensitive` is + /// not written. + const DEPRECATED_SENSITIVE: &str = "x-sensitive"; + + /// The names of the native keys of a Schema Object, by slot. + pub(crate) const NAMES: [&str; 5] = [MODEL_NAME, PROPERTY_NAME, SKIP, SENSITIVE, DEPRECATED_SENSITIVE]; + + /// Returns what `key` says on a Schema Object, if it is a native key of one. + pub(crate) fn read(key: &str) -> Option> { + // `x-sensitive` is the one native key outside the prefix. + if !key.starts_with(PREFIX) && key != DEPRECATED_SENSITIVE { + return None; + } + Some(match key { + MODEL_NAME => Hit::native(SchemaField::ModelName, 0), + PROPERTY_NAME => Hit::native(SchemaField::PropertyName, 1), + SKIP => Hit::native(SchemaField::Skip(BoolFormat::Bool), 2), + SENSITIVE => Hit::native(SchemaField::Sensitive, 3), + DEPRECATED_SENSITIVE => Hit::deprecated(SchemaField::Sensitive, 4), + _ => return None, + }) } - Some(match key { - MODEL_NAME => Hit::native(SchemaField::ModelName, 0), - PROPERTY_NAME => Hit::native(SchemaField::PropertyName, 1), - SKIP => Hit::native(SchemaField::Skip(BoolFormat::Bool), 2), - SENSITIVE => Hit::native(SchemaField::Sensitive, 3), - DEPRECATED_SENSITIVE => Hit::deprecated(SchemaField::Sensitive, 4), - _ => return None, - }) } -/// Returns what `key` says at the root of the document, if it is a native -/// key of it. -pub(crate) fn document(key: &str) -> Option> { - if !key.starts_with(PREFIX) { - return None; +/// The native keys at the root of the document. +pub(crate) mod document { + use super::{DocumentField, Hit, PREFIX}; + + const COMMANDS: &str = "x-forge-commands"; + + /// The names of the native keys of the document, by slot. + pub(crate) const NAMES: [&str; 1] = [COMMANDS]; + + /// Returns what `key` says at the root of the document, if it is a native + /// key of it. + pub(crate) fn read(key: &str) -> Option> { + if !key.starts_with(PREFIX) { + return None; + } + Some(match key { + COMMANDS => Hit::native(DocumentField::Commands, 0), + _ => return None, + }) } - Some(match key { - COMMANDS => Hit::native(DocumentField::Commands, 0), - _ => return None, - }) } /// Returns the availability `value` is, such as `beta`, or `None` if it is @@ -297,6 +324,9 @@ pub(crate) fn assert_one_key_per_tier(keys: &[&str], read: impl Fn(&str) -> O mod tests { use super::*; + /// The names of the keys of a level, and whether the level reads a key. + type Level<'a> = (&'a [&'a str], fn(&str) -> bool); + /// Every native key of each kind of object, in slot order. fn native_keys() -> [&'static [&'static str]; 4] { [ @@ -335,30 +365,50 @@ mod tests { #[test] fn each_native_key_is_read_as_itself_and_as_native() { let [definitions, parameters, schemas, documents] = native_keys(); - read_as_itself(definitions, &DEFINITION_NAMES, definition); - read_as_itself(parameters, &PARAMETER_NAMES, parameter); - read_as_itself(schemas, &SCHEMA_NAMES, schema); - read_as_itself(documents, &DOCUMENT_NAMES, document); + read_as_itself(definitions, &definition::NAMES, definition::read); + read_as_itself(parameters, ¶meter::NAMES, parameter::read); + read_as_itself(schemas, &schema::NAMES, schema::read); + read_as_itself(documents, &document::NAMES, document::read); } #[test] fn no_two_native_keys_say_the_same_thing_with_the_same_tier() { let [definitions, parameters, schemas, documents] = native_keys(); - assert_one_key_per_tier(definitions, definition); - assert_one_key_per_tier(parameters, parameter); - assert_one_key_per_tier(schemas, schema); - assert_one_key_per_tier(documents, document); + assert_one_key_per_tier(definitions, definition::read); + assert_one_key_per_tier(parameters, parameter::read); + assert_one_key_per_tier(schemas, schema::read); + assert_one_key_per_tier(documents, document::read); + } + + #[test] + fn a_native_key_is_read_only_at_its_level() { + let levels: [Level; 4] = [ + (&definition::NAMES, |key| definition::read(key).is_some()), + (¶meter::NAMES, |key| parameter::read(key).is_some()), + (&schema::NAMES, |key| schema::read(key).is_some()), + (&document::NAMES, |key| document::read(key).is_some()), + ]; + for (own, _) in levels { + for &key in own { + for (names, reads) in levels { + assert_eq!(reads(key), names.contains(&key), "{key}"); + } + } + } } #[test] #[should_panic(expected = "x-forge-sensitive and x-forge-sensitive say the same thing with the same tier")] fn one_key_per_tier_finds_two() { - assert_one_key_per_tier(&["x-forge-sensitive", "x-forge-sensitive"], schema); + assert_one_key_per_tier(&["x-forge-sensitive", "x-forge-sensitive"], schema::read); } #[test] fn x_sensitive_is_a_deprecated_name_of_x_forge_sensitive() { - let (current, old) = (schema("x-forge-sensitive").unwrap(), schema("x-sensitive").unwrap()); + let (current, old) = ( + schema::read("x-forge-sensitive").unwrap(), + schema::read("x-sensitive").unwrap(), + ); assert!(matches!( (current.field, old.field), @@ -370,7 +420,7 @@ mod tests { #[test] fn a_slot_past_the_native_ones_has_no_native_name() { - assert_eq!(native_name(&DEFINITION_NAMES, COMPAT_SLOTS), None); + assert_eq!(native_name(&definition::NAMES, COMPAT_SLOTS), None); } #[test] @@ -384,8 +434,10 @@ mod tests { assert_eq!(slots.len(), len, "duplicate slot among {keys:?}"); assert!(slots.iter().all(|&slot| slot < 32), "native slots are under 32"); }; - slots(definitions, &|key| definition(key).map_or(u8::MAX, |hit| hit.slot)); - slots(schemas, &|key| schema(key).map_or(u8::MAX, |hit| hit.slot)); + slots(definitions, &|key| { + definition::read(key).map_or(u8::MAX, |hit| hit.slot) + }); + slots(schemas, &|key| schema::read(key).map_or(u8::MAX, |hit| hit.slot)); } #[test] @@ -398,17 +450,17 @@ mod tests { "x-forge-nope", "x-forge-method", ] { - assert!(definition(key).is_none(), "{key}"); - assert!(parameter(key).is_none(), "{key}"); - assert!(schema(key).is_none(), "{key}"); - assert!(document(key).is_none(), "{key}"); + assert!(definition::read(key).is_none(), "{key}"); + assert!(parameter::read(key).is_none(), "{key}"); + assert!(schema::read(key).is_none(), "{key}"); + assert!(document::read(key).is_none(), "{key}"); } } #[test] fn aliases_are_not_a_definition_key() { assert!(is_aliases("x-forge-aliases")); - assert!(definition("x-forge-aliases").is_none()); + assert!(definition::read("x-forge-aliases").is_none()); } #[test] From 34a123cfaba12f13b009d3147a6f067d83e6be47 Mon Sep 17 00:00:00 2001 From: Niels Swimberghe Date: Tue, 27 Oct 2026 11:51:38 -0400 Subject: [PATCH 21/25] docs: trim RFC 0001 to the essentials Drop the status header, the known gaps and follow-ups sections, the test plan and the release shape, and shorten the rest. The vocabulary, the resolution rules, the architecture and the alternatives considered stay. --- rfcs/0001-metadata-resolution-layer.md | 217 +++++++------------------ 1 file changed, 58 insertions(+), 159 deletions(-) diff --git a/rfcs/0001-metadata-resolution-layer.md b/rfcs/0001-metadata-resolution-layer.md index 2620cc6a9..d68ac7806 100644 --- a/rfcs/0001-metadata-resolution-layer.md +++ b/rfcs/0001-metadata-resolution-layer.md @@ -1,204 +1,103 @@ # RFC 0001: The metadata resolution layer -- **Status:** Draft -- **Date:** 2026-10-23 -- **Ticket:** FORGE-1 — *section off the fern stuff into a compatibility layer* -- **Related:** FORGE-3 (vocabulary renames) · FORGE-4 (versioning RFC) · FORGE-5 (unread vendor key lint) - ## Summary -Forge's engine reads vendor metadata extensions (`x-fern-*`, `x-stainless-skip`) directly, so everything downstream of the linked model inherits the vendors' vocabulary, formats, and limitations. This RFC adds a **metadata resolution layer** with three parts: - -1. a native `x-forge-*` vocabulary, designed on Forge's own terms rather than mirroring any vendor's; -2. a pure library that resolves keys to primitives — native first, vendor adapters as fallback; -3. a quarantined, feature-gated **compatibility layer** that is the only code holding vendor knowledge, and whose designed end state is wholesale deletion. +The engine reads vendor metadata extensions (`x-fern-*`, `x-stainless-skip`) directly, so everything downstream of the linked model inherits the vendors' vocabulary and formats. This RFC gives the engine a native `x-forge-*` vocabulary and moves all vendor knowledge into one quarantined, feature-gated **compatibility layer** (`src/compat/`) that maps vendor keys onto native primitives. The layer is designed to be deleted as a whole. -After the boundary, the engine — reader, builder, linked model, plugin host, npm runtime — acts only on Forge primitives. Output for existing specs is unchanged, and the release is non-breaking. +Output for existing specs is unchanged, and the release is non-breaking. ## Motivation -Today the engine couples to vendors in five places: +Vendor names are hardcoded in the serde key enums in `src/link/openapi.rs`, in value formats (Fern's availability words, Stainless's skip list), in validation messages, in the list of keys kept from plugins, and in the public docs, the WIT and the JS runtime. Anything downstream, including the cf codebase, depends on a vendor's extension format, and adding another vendor means touching all of it again. -1. **Wire keys** — vendor names are hardcoded in the serde key enums (`DefinitionKey`, `ParameterKey::FieldName`, `SchemaKey::{ModelName, FieldName, Skip}` in `src/link/openapi.rs`). -2. **Vendor value formats** — Fern's five-value availability vocabulary, Stainless's `x-stainless-skip` (where a list of target names means *false*). -3. **Error strings** — validation messages name vendor keys ("`x-fern-availability "ga" is not …`"). -4. **The consumed-key set** — the seven names excluded from the plugin passthrough are hand-listed in the deserializers and re-documented in the model docs. -5. **Documentation** — the public model, the WIT, and the JS runtime attribute primitives to vendor keys; the WIT's doc comments ship in the npm package. +The released aggregate uses the keys forge reads about 11,400 times (`x-fern-sdk-*` 3,638 each, `x-fern-availability` 3,444, `x-fern-ignore` 449, `x-stainless-skip` 114, and about 150 name keys). It also carries keys forge does not read, such as `x-fern-enum` (3,652) and `x-stainless-terraform-configurability` (407). -Anything downstream — including the cf codebase — therefore depends on a vendor's extension format. Adding another vendor (Stainless, Scalar) means touching the parser, the linker, the docs, and the filter again. +## Scope -### What the production specs actually use +Goals: -Audit of the released aggregate (`openapi.forge.json`, 27.6 MB, sha `99544617…`): +- Nothing in the engine (`src/`, `tests/`, `packages/forge`) outside `src/compat/` and `tests/compat/` names a vendor extension, enforced by a test. +- Output for existing specs is byte-identical; no meaningful parse-time regression. +- Native keys are applied first; the compatibility layer is only a fallback, and can be removed without changing what any `x-forge-*` key means. -| Read by forge today | Uses | Present, **not** read | Uses | -|---|---|---|---| -| `x-fern-sdk-method-name` | 3,638 | `x-fern-enum` | 3,652 | -| `x-fern-sdk-group-name` | 3,638 | `x-stainless-terraform-configurability` | 407 | -| `x-fern-availability` | 3,444 | `x-stainless-deprecation-message` | 281 | -| `x-fern-ignore` | 449 | `x-stability` | 240 | -| `x-fern-parameter-name` | 108 | `x-stainless-collection-type` | 110 | -| `x-fern-property-name` | 38 | `x-fern-audiences` | 80 | -| `x-fern-type-name` | 2 | ~10 further Stainless / Fern keys | ~30 | -| `x-stainless-skip` | 114 | `x-fern-sdk-variables` | 0 | - -## Goals - -- Nothing in the engine outside the resolution layer references `x-fern-*` or `x-stainless-*` — enforced mechanically. The engine is `src/`, `tests/` and the `packages/forge` npm package; the docs packages in this repository (`astro-fern`, `fern-forge`, `cloudflare-api-site`) read vendor keys themselves and are outside this RFC (see Non-goals). -- Output for existing specs is unchanged, proven by byte-equality tests on the real aggregate. -- Tests cover each supported primitive, from each accepted key. -- No meaningful performance regression; parse throughput is benchmarked before and after. -- All Fern (and Stainless) compat code and tests live in one folder, imported only through an aggregating library. -- Native metadata is applied first; the compatibility layer is a fallback. -- The compatibility layer can be completely deleted or turned off without affecting any `x-forge-*` extension. -- The native vocabulary is not constrained by vendor naming, structure, or limitations — native keys are designed for Forge's model, and adapters do the translating. - -## Non-goals (this ticket) - -- Fern's structural behavior (allOf/anyOf handling, inline request types) — unchanged. -- Operation ID prefixing. -- Renaming Fern→Forge across the cf codebase (existing follow-up). -- Dotted `operationId` derivation (`a.b.c.d` → group `[a,b,c]` + method `d`) — a follow-up that will land *in this layer* behind an opt-in flag. -- Audiences. The ticket lists audiences as a lower-priority primitive; this RFC deliberately leaves `x-fern-audiences` unread (it needs a visibility concept Forge has not designed), flagged by FORGE-5. This is a scope deviation from the ticket text and needs the reviewers' agreement. -- New primitives for `x-fern-enum` (enum-value names — needs a model primitive that does not exist yet), `x-stability`, `x-forge-sunset`, `x-stainless-deprecation-message`. These are flagged (FORGE-5) and stay unread; each would be a one-row additive addition later. -- The docs packages' own `x-fern-*` reading (`fern-forge`, `astro-fern`, `cloudflare-api-site`: downstream consumers, like cf) — follow-up. +Non-goals: Fern's structural behavior (allOf/anyOf, inline request types), operation ID prefixing, the cf codebase's own Fern-to-Forge rename, dotted `operationId` derivation, and the docs packages in this repository, which read vendor keys themselves. Vendor keys with no Forge primitive (`x-fern-enum`, `x-fern-audiences`, `x-stability` and others) stay unread. ## The native vocabulary -| Native key | Form | Meaning | Compat source (the adapter translates) | +| Native key | Value | Meaning | Compat source | |---|---|---|---| | `x-forge-method-name` | string | the method's name | `x-fern-sdk-method-name` | -| `x-forge-group-name` | **string of names separated by dots**, or **array of strings**, top level first (array elements are not split) | the group path; `""` and `[]` are meant to be the root, but are not yet (see [Known gaps](#known-gaps)) | `x-fern-sdk-group-name` (read the same way as the native key) | -| `x-forge-skip` | bool, positional | operation/alias → no method; property schema → no field | `x-fern-ignore` (operation) · `x-stainless-skip` (property; a list of targets counts as `false` — inside the adapter) | -| `x-forge-availability` | `alpha` \| `beta` \| `preview` \| `generally-available` \| `deprecated` | lifecycle stage. The values are Forge's shipped vocabulary (public enum across Rust, WIT, JS); Fern merely coincides | `x-fern-availability` | -| `x-forge-model-name` | string | the model's name (components / `$defs` schema) | `x-fern-type-name` | +| `x-forge-group-name` | dotted string, or array of strings, top level first | the group path; an empty path makes no method, as today | `x-fern-sdk-group-name`, read the same way | +| `x-forge-skip` | bool | operation or alias: no method; property schema: no field | `x-fern-ignore` (operation), `x-stainless-skip` (property; a list of targets counts as `false`) | +| `x-forge-availability` | `alpha`, `beta`, `preview`, `generally-available`, `deprecated` | lifecycle stage | `x-fern-availability` | +| `x-forge-model-name` | string | the model's name | `x-fern-type-name` | | `x-forge-property-name` | string | the property's field name | `x-fern-property-name` | | `x-forge-parameter-name` | string | the parameter's field name | `x-fern-parameter-name` | -| `x-forge-sensitive` | bool | the schema's value is a secret, to mask | — (replaces `x-sensitive`, below) | -| `x-sensitive` | bool | **deprecated** name of `x-forge-sensitive`, read only where `x-forge-sensitive` is not written | — (a native key, not a vendor one) | -| `x-forge-hidden`, `x-forge-require-confirmation`, `x-forge-aliases`, `x-forge-commands` | *(existing, unchanged)* | | — no vendor source; now owned by the native table | +| `x-forge-sensitive` | bool | the value is a secret, to mask | none; replaces `x-sensitive` | +| `x-sensitive` | bool | deprecated name of `x-forge-sensitive`, read only where that is not written | none | + +`x-forge-hidden`, `x-forge-require-confirmation`, `x-forge-aliases` and `x-forge-commands` are unchanged. Design notes: -- **Explicit position keys over one positional name key.** Fern used three name keys because its reader architecture had one visitor per OpenAPI object kind. We use explicit keys for a different, real reason: tools in the wild mangle `$ref` structure (inlining targets, dropping siblings), and when placement lies, the key name still carries the role — `x-forge-model-name` on a property schema can be ignored or flagged, where a positional `x-forge-name` would silently become a field name. Explicitness here is corruption-detection, not verbosity. -- **`model-name`, not `type-name` or `schema-name`.** The key sets `Model::name`, and the Rust API, the WIT (`model-info`, `get-model`) and the JS runtime all call the thing a model, so the key uses the word a plugin author sees. `type-name`, Fern's word, would promise more than the key does: a `Type` is any type, scalars, arrays and unions included, of which `Type::Model` is one kind, and only a schema that becomes a model is named by the key. `schema-name` is taken: `Model::schema_name` is the key under `components.schemas`, which is the very name this key replaces. `class-name` and `struct-name` are words of particular target languages, and `title` is OpenAPI's own display string. `method-name` and `model-name` are named after what they set in the model; `property-name` and `parameter-name` both set a field's name, so they are named after their position instead, for the reason above. -- **One exclusion word, two positions.** "Excluded from generated surfaces" is one concept at two scopes; two words would be a memory tax for no disambiguation. The word is `skip` rather than `ignore`: it says what happens (the thing is not generated), where `ignore` reads as though the reader ignores the key; it stands further apart from `x-forge-hidden` (generated, but not listed); and it reads naturally should the key one day take a list of targets. That Stainless uses the same word, and Fern the other, played no part. -- **Groups as a dotted string or an array.** The dotted string is how Cloudflare's specs write a group path today, and Forge CI's lint requires it, so moving a team to `x-forge-group-name` is a rename of the key, with the value unchanged. The array is JSON's own way to write a path, and the way to write a group name that has a dot in it. Both are read exactly as forge has always read groups. -- **The dotted string is Cloudflare's, not Fern's.** Fern reads a string `x-fern-sdk-group-name` as *one* group (`"a.b"` is a group named `a.b`) and an array as a path; it never splits on dots. The release pipeline converts Cloudflare's dotted strings to arrays before Fern sees them (the released aggregate has 3,591 arrays and no strings). Forge has always split them, and the adapter reads the Fern key the same way as the native one, so for a string with a dot the adapter and Fern disagree. That is deliberate: the specs forge reads write Fern's key in Cloudflare's convention, and reading the two keys alike keeps a key rename from changing a value's meaning. -- **Every native key begins with `x-forge-`.** The `-name` keys (`method-name`, `group-name`, `model-name`, `property-name`, `parameter-name`) say the same kind of thing and are spelled alike. `x-forge-group-name` holds a path rather than one name, as `x-fern-sdk-group-name` does; keeping the suffix makes moving a team a rename of the key's prefix, and keeps it apart from the legacy `x-forge-group-info`, which Forge CI bans. FORGE-3 expects to rename it to `x-forge-namespace`. `x-sensitive` predates the rule; it becomes `x-forge-sensitive`, and the old name stays readable as a deprecated alias (155 production uses), so no spec breaks. A deprecated alias is part of the native vocabulary, not of the compatibility layer: it keeps working with compat off, and deleting compat does not remove it. -- **No misplacement validation in v1.** A known native key in a wrong position is silently ignored, exactly as Fern's keys are today. Adding validation later is a pure tightening that breaks no valid spec. +- **Explicit keys, not one positional name key.** Tools that inline `$ref` targets would make a positional `x-forge-name` silently rename fields. A misplaced explicit key can be ignored or flagged instead. +- **`model-name`** sets `Model::name`, the word the Rust API, the WIT and the JS runtime use. `type-name` would promise more than it does, `schema-name` clashes with `Model::schema_name`, and `class-name` and `title` are a language's word and OpenAPI's display string. +- **`skip`**, not `ignore`: it says what happens, and stands apart from `x-forge-hidden` (generated, but not listed). One word covers operations and properties. +- **Groups as a dotted string or an array.** Cloudflare's specs write dotted strings, so moving to the native key is a rename with the value unchanged. Fern reads a string as one group and never splits on dots, so for a string with a dot the adapter and Fern disagree. That is deliberate. FORGE-3 expects to rename the key to `x-forge-namespace`. +- **Every native key begins with `x-forge-`.** `x-sensitive` predates that rule: it stays readable (155 production uses) as a deprecated alias, which is part of the native vocabulary and keeps working with compat off. +- **No misplacement validation.** A native key at the wrong level is silently ignored, as Fern's keys are today. Validating later only tightens. ## Resolution rules -1. **Native applied, compat fallback** — per primitive, independently (a spec mid-migration may take the group from native and availability from Fern). Keys rank in three tiers: a native key under its current name, then a deprecated native alias (`x-sensitive`), then a compat key. The strongest tier present wins. -2. **Silent native-wins** — when keys of two tiers are present (a native key and a deprecated alias or compat key), the weaker value is ignored without comparison machinery. Only the winning value is validated: an invalid compat `availability` does not error when a native one wins. A value of the wrong JSON type (a number where a string is expected) is still a document-shape error at read time, whichever key it is on and in whatever order the keys are written, so errors never depend on key order. Consequence, to be documented in the metadata docs: a stale compat key silently diverges, and editing the compat key when a native key is present appears to do nothing. -3. **Same key twice** → error (today's `duplicate field` behavior, unchanged). -4. **Empty or punctuation-only values** behave as today, which depends on the key: - - `x-forge-property-name`, `x-forge-parameter-name` and `x-forge-model-name`: a value with no letters or digits, empty included, is ignored, and the field or model is named after its wire name (the property, parameter or schema name). - - `x-forge-method-name` and `x-forge-group-name`: an empty method name, or an empty group path (`""` or `[]`), means the operation or alias is not a method. For the empty group path this is the current behavior, not the intended one: see [Known gaps](#known-gaps). A method name, or any group name in the path, that is not empty but has no letters or digits is an error. - - Validation errors name the key the author actually wrote (provenance), so existing Fern-keyed error expectations hold. -5. **Dual-writing** (native and compat keys carrying the same value) is the supported pattern for keeping specs portable across engine versions during the transition: old engines read the compat key, new engines apply native. - -## Known gaps - -### An empty group path +1. Keys rank in three tiers: native, then deprecated native alias, then compat. The strongest tier present wins, per primitive and whatever order the keys are written in. Only the winning value is validated. +2. The same key written twice is an error. A value of the wrong JSON type is an error whichever key it is on. +3. Names with no letters or digits behave as they always have: a property, parameter or model name is ignored, a method name or group path that is empty makes no method, and a non-empty one that has none is an error. +4. Errors name the key the author actually wrote. +5. Writing native and compat keys with the same value is the supported way to stay portable across engine versions during the transition. -- **Intended:** `x-forge-group-name: []` (or `""`) attaches the method to the root, in no group. An operation without `x-forge-group-name` at all is still not a method. -- **Today:** an empty group path makes no method, exactly like a missing key. `add_method` in `src/link.rs` returns before building the method when the path is empty; `Method::group`'s docs in `src/lib.rs` say so; and `tests/parse/groups.rs::empty_group_name_is_no_group` and the empty-group alias case in `tests/parse/aliases.rs` assert it. -- **Why it is not fixed here:** the model has no place for a root method. Methods exist only inside groups (`Forge::groups` → `Group::methods`); `Forge` has top-level models but no top-level methods, and neither does the WIT. The fix needs a root method list in the model, a way for plugins to read it (a new WIT function is additive; a new field in an existing record is not), and generators that render root methods. That is beyond FORGE-1, which leaves the model unchanged. -- **Impact:** none today. The released aggregate has no empty group path and no method name without a group, so the fix will not change its output. -- **To decide with the fix:** whether an empty `x-fern-sdk-group-name` (`""` or `[]`) should also mean the root, or keep meaning "not a method" in the compatibility layer. Fern itself puts a method with no group, or an empty array, at the root of the client, so meaning the root would bring the adapter closer to Fern. +A consequence of rule 1: a stale compat key silently diverges from a native key written beside it. ## Architecture ``` src/ -├─ metadata/ THE NATIVE LIBRARY — pure -│ │ The x-forge-* table (per kind of object: definition, -│ │ parameter, schema, document), deprecated native aliases, -│ │ the primitives and format descriptors, the availability -│ │ vocabulary, the consumed-key set. Imports neither compat nor link. No serde, no arena -│ │ coupling: its inputs are &str keys, its outputs small -│ │ pure values. -├─ compat/ THE COMPATIBILITY LAYER — feature "compat" (default on) -│ ├─ mod.rs the aggregator: one resolver per kind of object, trying -│ │ each vendor in turn, and one name lookup per kind, for -│ │ errors. The only things link imports from here. -│ │ The designed end state: delete this folder + the feature. -│ ├─ fern.rs every x-fern-* literal, mapped onto metadata's primitives; -│ │ the value formats of those keys, as data; Fern unit -│ │ tests. -│ └─ stainless.rs the x-stainless-skip mapping and its format-as-data -│ (a list of targets counts as false); unit tests. +├─ metadata/ the native table, per kind of object (definition, parameter, +│ schema, document): keys, values, formats. Pure: `&str` in, +│ small values out. Imports neither compat nor link. +├─ compat/ the adapters (fern, stainless) and their aggregator. The only +│ code holding vendor keys. Behind the default-on `compat` feature. └─ link/ - ├─ resolve.rs the orchestrator: native first, then compat behind - │ #[cfg(feature = "compat")] — the only place link calls - │ compat. (The feature's other cfg points: `mod compat` in - │ lib.rs, and the compat-only items in metadata.) - ├─ openapi.rs OpenAPI's own keys only; every x- key asks resolve.rs. - └─ read.rs serde helpers, as today. + ├─ resolve.rs native first, then compat; the only place link calls compat + └─ openapi.rs OpenAPI's own keys; every `x-` key asks resolve.rs ``` -Properties, all deliberate: - -- **Dependencies**: `link → metadata` and `link → compat` (behind the feature), and `compat → metadata` (an adapter produces metadata's primitives). `metadata` imports neither of the others, so compat sits *beside* the native table rather than under it. `link/resolve.rs` asks native first, compat second — that fallback arm *is* resolution rule 2. -- **Purity**: the library's contract is `&str` keys in and small values out: primitives, key names, and *format descriptors as data* (for example "a string splits on `.`" or "a list of targets counts as `false`"). The serde reading and the arena writes stay in `link` and are generic, driven by those descriptors. This is what makes the library pluggable, standalone-consumable (a future `forge-ci validate` needs the key table, not the engine), and mechanically liftable into a real separate crate when the first standalone consumer appears (FORGE-5's work). -- **The availability vocabulary is native.** The five values live in the native table (they are shipped public API); `fern.rs` only maps `x-fern-availability` onto that primitive. -- **Deletion is the designed end state.** `cargo build --no-default-features --features cli` proves the native vocabulary stands alone. When vendor keys reach end-of-life, deleting the layer means removing `src/compat/` and `tests/compat/`, `mod compat` in `src/lib.rs`, the `compat` cfg arms in `link/resolve.rs` (a resolver and a name lookup for each kind of object the layer reads: operations and aliases, parameters, schemas), and the `compat` feature and its `[[test]]` entry in `Cargo.toml`. No `x-forge-*` extension changes, in behavior, docs, or WIT. - - *What a deletion leaves behind.* Some native-side code exists only for the layer and compiles, unused, without it: `Hit::compat` and `Hit::after`, `Tier::Compat`, `COMPAT_SLOTS`, the value format only a vendor uses (`BoolFormat::BoolOrTargets`) and its reader in `link/openapi.rs`, and the sentence in `Method::extensions`'s docs about the keys the compatibility layer reads. Removing them is a mechanical follow-on step, not a prerequisite. -- **With compat off, vendor keys are ordinary extensions.** Built without the feature, the engine does not drop `x-fern-*` and `x-stainless-*` keys: like any other `x-` key it does not read, an operation's vendor keys are kept and given to plugins by `Method::extensions`. Native behavior is the same either way; what plugins see in `extensions()` is not. -- **Dispatch, not semantics**: the layer maps keys to primitives and normalizes simple values. Structural behavior — `x-forge-aliases` becoming methods, `x-forge-commands` describing groups — stays in the builder. +- Dependencies: `link` uses `metadata` and, behind the feature, `compat`; `compat` uses `metadata`. +- The layer maps keys to primitives and normalizes simple values. Structural behavior, such as aliases becoming methods, stays in the builder. +- Without the `compat` feature, vendor keys are ordinary extensions and are passed to plugins. +- Deleting the layer means removing `src/compat/`, `tests/compat/`, `mod compat` in `src/lib.rs`, the compat arms in `link/resolve.rs`, and the feature in `Cargo.toml`. No native key changes. A little native-side code that only the layer used is left over and can be removed afterwards. ## Enforcement -- **Structural boundary — compiler-enforced (built-in Rust):** every vendor key literal is private to its adapter; the only things outside `compat/` that can touch vendor knowledge are the aggregator's per-object resolvers and name lookups, which `link/resolve.rs` calls. The name lookups are how a vendor key's name reaches an error message (rule 4's provenance) without `link` holding the literal: it reaches `link` as data, never as code that matches on it. The feature gate makes compat removable, and builds without the `compat` feature keep proving it. -- **Textual boundary — `tests/boundary.rs`:** scans `src/`, `tests/`, and `packages/forge/` (including the shipped `plugin/forge.wit`) for `x-fern` / `x-stainless` outside `src/compat/` and `tests/compat/`. Doc comments count — moving the WIT's "from `x-fern-availability`" attribution to `x-forge-availability` is part of the work. The test runs wherever `cargo test` runs; a `just boundary` alias targets it. -- **Compat off is compiled in CI, not tested — on purpose.** The `compat` feature is a tool for organizing the code: it keeps the vendor knowledge in one place that can be deleted, rather than being a build that anyone ships. CI's clippy step already builds the crate without it (`--no-default-features`), so the code can't quietly come to depend on the layer. But CI runs the tests only with `--all-features`. A second test run with compat off would compile the crate and every test binary again, for 30–60 s on each push, to cover a configuration nothing ships. The compat-off run in *Verification* was done locally. -- **Criterion mapping:** (1) nothing outside the layer references `x-fern-*` → the two mechanisms above; (2) output unchanged → zero model-shape changes, Fern-keyed specs resolve identically (equivalence tests + the full-aggregate native-vs-wasm byte-equality bench); (3) tests per primitive → below. - -## Test plan - -- `tests/parse/` — every fixture rewrites to native keys. Not find-and-replace: group values `"a.b"` become `["a","b"]`; the three Fern name keys become their explicit native keys; error assertions naming Fern keys move out. -- `tests/compat/` — the vendor suite, mirroring `tests/parse`'s harness: for each primitive, parse(Fern-keyed fixture) == parse(native-keyed fixture); format specifics (dotted vs array groups, duplicate-key errors, provenance error strings, Stainless's skip-list semantics). -- `tests/boundary.rs` — the guard. -- Existing tripwires keep working: `wit_is_current` (WIT regeneration is mechanical via `wit.mjs`), the wasm dump equality, native-vs-wasm generated-output byte equality. -- Performance: divan parse benches before/after on the real aggregate (`FORGE_SPEC`); `tests/wasm.rs::bench` re-run; forge-bench re-pinned upstream after merge. +- Every vendor key literal is private to its adapter, and the compiler enforces that. +- `tests/boundary.rs` searches `src/`, `tests/` and `packages/forge/` (docs and WIT included) for `x-fern` and `x-stainless` outside the layer. +- CI compiles the crate without `compat` through clippy, but runs tests only with all features, to avoid a second full compile for a configuration nothing ships. ## Verification -The implementation was checked against the unmodified engine on the released aggregate (`openapi.forge.json`, 27.6 MB, sha `99544617…`): - -- **Fern-keyed behavior.** The original, untouched suite — 379 parse tests written with Fern keys — passed against the new engine before any fixture was migrated. -- **Generated output.** `forge --target ts` on the aggregate writes the same 3,381 files, byte for byte (`diff -r`). The aggregate's 155 `x-sensitive` keys are read through the deprecated alias; the dump's 210 sensitive fields are unchanged. -- **The model.** The full model dump (the repo's own `tests/wasm/dump.rs`: every group, method, availability, hidden flag, confirmation, extension and field name) is identical between the old and new engines: 197,702 lines, covering 3,021 availability values, 1,259 hidden flags, 217 confirmations and 4,695 extension entries. The generated TypeScript consumes none of those four, which is why the dump, not the output diff, is what proves them. -- **Across the plugin boundary.** `tests/wasm.rs`, including the ignored `bench` on the aggregate, reports identical output and dump, native versus WASM, for 1,317 groups and 3,381 files; `wit_is_current` holds with the regenerated WIT. -- **Independence.** `cargo test --no-default-features --features cli` (compat off) passes all 445 tests of the native suite, including the deprecated `x-sensitive` alias; with compat on, 498 pass. Clippy is clean in the library-alone, compat-off and all-features configurations. -- **The checks can fail.** Breaking the precedence rule (keeping the last value written, whatever its tier) fails 11 compat tests, and 1 `tests/parse` test, the deprecated `x-sensitive` one, with compat on and off; planting a vendor name in a doc comment fails the boundary test, naming the file and line. -- **Performance.** Measured with the divan `parse` bench (parse and link of the aggregate), paired against the unmodified engine: each pair ran base–new–new–base so that drift in the machine's speed cancels, took the fastest sample of each run, and left out pairs whose four runs differed by more than 10%. On the final code, over 12 pairs, it is 0.8% slower: +0.34 ms of 42.0 ms, standard error 0.18 ms (t = 1.9), slower in 9 of 12 pairs. Earlier runs during the work put it between −0.1% and +1.1%, depending on the machine's speed state. Peak memory is 68.80 MB against 68.75 MB (+0.07%), with the same peak allocation count. Parsing is under a tenth of a whole `forge` run on the aggregate (about 0.5 s), so the end-to-end change is under 0.1%. - - *Where it went.* Profiling (samply, with line tables, attributing inlined frames) found no hot spot of the layer's own: most of the difference is LLVM inlining parts of serde_json's key loop into `SchemaObject::read_object` and laying the code out differently, which moves with any change. Three things were measured and kept: each table turns away keys outside its prefix (`x-forge-`, `x-fern-`, `x-stainless-`) before matching, and only `x-` keys are asked about, so a skipped key costs one compare; and a `Hit` carries only its field and slot, the key's name looked up by slot when an error needs it, so the reader's per-key result fits in registers (16 bytes, as before, not 24). - - *Ruling things out.* Unpaired, sequential runs had suggested +1.5–1.9%; that was the machine warming up through the session, which the ABBA order removes. A single run of either binary differs from the next by about as much as the change measured here, so a smaller difference needs more pairs, not a better single run. - -## Release shape +Checked against the unmodified engine on the released aggregate (27.6 MB): -Non-breaking. `x-sensitive` keeps working, deprecated in favor of `x-forge-sensitive`; when it stops being read is FORGE-4's policy question. The WIT changes are doc comments only — `forge:plugin@0.3.0` stays, bindings regenerate identically, no plugin rebuilds anywhere; npm gets an additive release: the engine binary it ships accepts the new native keys and, built with the default `compat` feature, still reads the vendor keys, and the JS runtime changes only in doc comments; the spec surface grows append-only; generated output is byte-identical. Vocabulary renames that *are* breaking (group→namespace, the availability word) are deliberately deferred to FORGE-3 so one breaking release absorbs all of them; the versioning policy that governs this is FORGE-4's RFC. +- The original 379 Fern-keyed parse tests passed before any fixture was migrated. +- `forge --target ts` writes the same 3,381 files byte for byte, and the full model dump (197,702 lines) is identical, natively and through the WASM plugin boundary. +- `cargo test` passes with compat on, and with it off when run locally; clippy is clean in the library-only, compat-off and all-features configurations. +- Parse time (divan `parse` bench, paired base-new-new-base runs) is 0.8% slower (+0.34 ms of 42.0 ms, t = 1.9). Peak memory is +0.07%, with the same allocation count. ## Alternatives considered -- **Resolve-after-parse** (keep all `x-` keys as raw JSON, normalize in a second pass): most literal reading of "a resolution step", trivially provenance-aware — but it must retain every schema-level extension on the 27 MB aggregate (5,366 `x-auditable` alone) unless the reader consults a key table anyway, at which point it collapses into the chosen design with extra memory and a reimplementation of duplicate-key errors. Rejected on performance and diff size. -- **Vendor-trait machinery** (a `Vendor` trait with Fern/Stainless/Scalar impls): no primitive overlaps across vendors today; the key table grows rows when one does. Rejected as over-engineering for now. -- **One positional `x-forge-name`**: mirrors the model's single `Name` concept and is cleaner in the abstract — but when toolchains inline `$ref` targets, the model's name key rides into property positions and silently renames fields. Explicit keys turn that corruption into something ignorable or flaggable. Rejected on robustness. -- **Separate words for an operation and a property** (`x-forge-ignore` and `x-forge-skip`, as Fern and Stainless split them): two words for one concept. Rejected. -- **Arrays only for `x-forge-group-name`**: one form, which every tool reads the same way, and loosening later would break nothing. Rejected: Cloudflare's specs write dotted strings, so every migration would reshape values as well as rename keys, and Forge CI's lint would need an array rule. -- **Reading a string `x-fern-sdk-group-name` as one group, as Fern does**: faithful to Fern, but the native and Fern keys would then read the same value differently, and the specs forge reads use dots. Rejected. -- **Error on native/compat disagreement**: the strictest conflict rule, and consistent with the engine's general strictness — deferred as an open question in FORGE-4 in favor of silent native-wins (zero comparison machinery, no cost for correct specs; the drift risk is documented). -- **Full crate split now** (a separate `metadata` member crate): delivers "separate library" literally today. The root `Cargo.toml` is already a `[workspace]`, so adding a member does not mean moving the engine; the real cost is that a separate crate cannot depend on the engine, so `Availability` and the other primitive value types would move into it and be re-exported by `forge`. Deferred: the pure contract keeps that move mechanical for when the first standalone consumer exists. -- **Renaming `Group`/`Availability` now**: both are Fern-inherited words, but both renames break the plugin contract. Deferred to FORGE-3's single breaking wave. - -## Follow-ups - -| Ticket | What | -|---|---| -| FORGE-3 | Vocabulary renames in one breaking release: `group` → `namespace`, plus the availability word review; `x-forge-group-name`/`x-forge-availability` become deprecated aliases (one table row each, the same tier `x-sensitive` uses). | -| FORGE-4 | The versioning RFC: the four contracts, the rules this RFC assumes, and open questions (1.0 timing, warning channel, vendor-key EoL policy). | -| FORGE-5 | Informational Forge CI lint flagging vendor keys the engine doesn't read, sourced from this layer's key table. | -| (FORGE-1 follow-ups) | cf codebase Fern→Forge rename; dotted `operationId` derivation behind an opt-in flag. | +- **Resolve after parse** (keep every `x-` key as raw JSON, normalize in a second pass): it must retain every extension on the 27 MB aggregate unless the reader consults a key table anyway, which collapses into this design. Rejected on memory and diff size. +- **A `Vendor` trait** with an implementation per tool: no primitive overlaps across vendors today, so the table just grows rows. Over-engineering for now. +- **Separate words for operations and properties** (`ignore` and `skip`): two words for one concept. +- **Arrays only for `x-forge-group-name`**: every migration would reshape values as well as rename keys. +- **Reading a string group as one group, as Fern does**: the native and Fern keys would read the same value differently. +- **Erroring when native and compat keys disagree**: stricter, but costs comparison machinery for no gain on correct specs. Silent native-wins was chosen. +- **A separate `metadata` crate now**: it would pull `Availability` and the other primitive types out of the engine. Deferred until a standalone consumer exists; the pure contract keeps that move mechanical. +- **Renaming `Group` and `Availability` now**: both break the plugin contract, so both wait for FORGE-3's single breaking release. From 855118e70b0b574d0a5f2cc2bc49bf67b0ac3d64 Mon Sep 17 00:00:00 2001 From: Niels Swimberghe Date: Tue, 27 Oct 2026 19:25:19 -0400 Subject: [PATCH 22/25] test: a group path is split on dots only when it is a string A group path written as a string is names separated by dots, and one written as an array is the names themselves, which are not split, so ["a.b", "c"] is two names and "a.b.c" is three. `forge normalize` writes an array as a dotted string only when it says the same thing, so it depends on the two readings staying as they are. --- tests/compat/fern.rs | 21 +++++++++++++ tests/parse/aliases.rs | 22 +++++++++++++- tests/parse/groups.rs | 69 ++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 109 insertions(+), 3 deletions(-) diff --git a/tests/compat/fern.rs b/tests/compat/fern.rs index 7dc3d6998..7ca3cdd09 100644 --- a/tests/compat/fern.rs +++ b/tests/compat/fern.rs @@ -47,6 +47,27 @@ fn a_group_written_as_an_array_is_not_split() { ); } +/// A group path is names separated by dots when it is a string, as for the +/// native key, and the names of an array, which are not split, when it is not. +#[test] +fn a_group_is_split_on_dots_only_when_it_is_a_string() { + for (written, names) in [ + (r#""a.b""#, vec!["a", "b"]), + (r#"["a", "b"]"#, vec!["a", "b"]), + (r#"["a.b"]"#, vec!["a.b"]), + (r#"["a.b", "c"]"#, vec!["a.b", "c"]), + (r#"["a", "b.c"]"#, vec!["a", "b.c"]), + ] { + let forge = parse(&operation_with(&format!( + r#""x-fern-sdk-group-name": {written}, "x-fern-sdk-method-name": "get","# + ))); + + let leaf = forge.groups().find(|group| group.methods().count() > 0).unwrap(); + let want: Vec = names.iter().map(|name| forge::Name::new(name)).collect(); + assert!(leaf.path().eq(want.iter()), "{written} is not the path {names:?}"); + } +} + #[test] fn an_empty_group_string_is_no_group_and_so_no_method() { let forge = parse(&operation_with( diff --git a/tests/parse/aliases.rs b/tests/parse/aliases.rs index 8ff74cbb1..9e13022ef 100644 --- a/tests/parse/aliases.rs +++ b/tests/parse/aliases.rs @@ -1,4 +1,4 @@ -use forge::{Availability, Forge, HttpMethod, Json, Scalar}; +use forge::{Availability, Forge, HttpMethod, Json, Name, Scalar}; use crate::common::{field_names, group, intersection, method, model, object, parse, parse_err, scalar}; @@ -50,6 +50,26 @@ fn each_alias_is_a_method_and_the_operation_is_not() { assert_eq!(methods(&forge), ["a.pause", "a.terminate"]); } +#[test] +fn an_alias_group_is_split_on_dots_only_when_it_is_a_string() { + let forge = parse(&aliased( + r#"[ + {"x-forge-group-name": "a.b", "x-forge-method-name": "pause"}, + {"x-forge-group-name": ["a.b"], "x-forge-method-name": "stop"} + ]"#, + "", + )); + + let in_group = |names: &[&str], method: &str| { + let want: Vec = names.iter().map(|name| Name::new(name)).collect(); + let group = forge.groups().find(|group| group.path().eq(want.iter())).unwrap(); + group.methods().any(|m| *m.name() == Name::new(method)) + }; + assert!(in_group(&["a", "b"], "pause"), "a string is two groups"); + assert!(in_group(&["a.b"], "stop"), "an array's name is one group"); + assert!(!in_group(&["a", "b"], "stop")); +} + #[test] fn aliases_share_the_rest_of_the_operation() { let forge = parse(&aliased(PAUSE_AND_TERMINATE, r#""deprecated": true,"#)); diff --git a/tests/parse/groups.rs b/tests/parse/groups.rs index 36a8d4b9b..48e350ead 100644 --- a/tests/parse/groups.rs +++ b/tests/parse/groups.rs @@ -1,4 +1,4 @@ -use forge::{Group, Name}; +use forge::{Forge, Group, Name}; use crate::common::{group, method, parse, parse_err}; @@ -61,6 +61,65 @@ fn array_element_with_a_dot_is_one_group() { assert_eq!(settings.parent(), None); } +/// Returns a document with one method, `GET /a`, whose group path is written +/// as `group`. +fn method_in(group: &str) -> String { + format!( + r#"{{ + "openapi": "3.0.3", "info": {{"title": "t", "version": "1"}}, + "paths": {{"/a": {{"get": {{"x-forge-group-name": {group}, "x-forge-method-name": "get", + "responses": {{"204": {{"description": "ok"}}}}}}}}}} + }}"# + ) +} + +/// Returns the one group that has a method, where the group path put it. +#[track_caller] +fn group_with_the_method(forge: &Forge) -> Group<'_> { + let mut groups = forge.groups().filter(|group| group.methods().count() > 0); + let (Some(group), None) = (groups.next(), groups.next()) else { + panic!( + "want one group with methods, got {:?}", + forge.groups().collect::>() + ); + }; + group +} + +/// A string is names separated by dots, and an array is the names, which are +/// not split, so a dot in an array's name stays in it. +#[test] +fn a_dotted_string_is_split_and_a_name_in_an_array_is_not() { + for (written, names) in [ + (r#""a.b""#, vec!["a", "b"]), + (r#""a.b.c""#, vec!["a", "b", "c"]), + (r#"["a", "b"]"#, vec!["a", "b"]), + (r#"["a.b"]"#, vec!["a.b"]), + (r#"["a.b", "c"]"#, vec!["a.b", "c"]), + (r#"["a", "b.c"]"#, vec!["a", "b.c"]), + (r#"["a.b", "c.d"]"#, vec!["a.b", "c.d"]), + ] { + let forge = parse(&method_in(written)); + + let want: Vec = names.iter().map(|name| Name::new(name)).collect(); + assert!( + group_with_the_method(&forge).path().eq(want.iter()), + "{written} is not the path {names:?}" + ); + } +} + +#[test] +fn a_dotted_string_and_an_array_with_that_name_are_different_groups() { + let string = parse(&method_in(r#""zones.settings""#)); + let array = parse(&method_in(r#"["zones.settings"]"#)); + + assert_eq!(group_with_the_method(&string).path().count(), 2); + assert_eq!(group_with_the_method(&array).path().count(), 1); + assert_eq!(string.groups().count(), 2, "zones and zones.settings"); + assert_eq!(array.groups().count(), 1); +} + #[test] fn groups_with_equal_names_are_one_group() { let forge = parse( @@ -171,7 +230,13 @@ fn skipped_operation_is_not_a_method() { #[test] fn group_name_without_letters_is_an_error() { - for group in [r#"["accounts", "", "members"]"#, r#""accounts..members""#] { + for group in [ + r#"["accounts", "", "members"]"#, + r#""accounts..members""#, + r#"".accounts""#, + r#""accounts.""#, + r#"".""#, + ] { let err = parse_err(&format!( r#"{{ "openapi": "3.0.3", "info": {{"title": "t", "version": "1"}}, From 30828f715578d3f8c4f78f2e50e32449a7fbc938 Mon Sep 17 00:00:00 2001 From: Niels Swimberghe Date: Tue, 27 Oct 2026 19:25:19 -0400 Subject: [PATCH 23/25] feat: forge::normalize rewrites other tools' extensions to the native ones Rewrites the extensions other tools' metadata is written with into the native x-forge-* ones, in a JSON document, using the keys the compatibility layer reads: a key is only rewritten where `forge` would have read it, so it never gets a meaning it did not have. FORGE-1. The document is edited as bytes and not written again, so everything but the keys that change, such as the text of every number, the whitespace and the order of the keys, stays as it was. It is walked once, without a tree: 28 MB in about 20 ms. - Native keys are never replaced. When a document says the same thing twice, the native key wins, and the other is reported if it says something else. - A group path that is an array becomes the names joined by dots, unless a name has a dot in it, which a dotted string cannot say. - What no native key says, such as a skip that is a list of targets, is left as it is written. - A key written twice next to a key that is rewritten is an error, as `forge` reads no key that is written twice. - The document is checked to be JSON beside the walk, on another thread when it is long, so that what is written does not have to be. - `leave_old_extensions` keeps each key that is rewritten, beside the native key that replaces it. The native key of each field is named by the per-level modules of `metadata`. The feature is `normalize`, on by default, and needs `compat`. --- Cargo.lock | 8 + Cargo.toml | 10 +- src/compat/mod.rs | 8 +- src/compat/normalize_tests.rs | 101 ++++ src/lib.rs | 2 + src/metadata/mod.rs | 62 ++ src/normalize/mod.rs | 187 ++++++ src/normalize/scan.rs | 663 +++++++++++++++++++++ src/normalize/walk.rs | 490 +++++++++++++++ tests/compat/main.rs | 5 + tests/compat/normalize.rs | 1047 +++++++++++++++++++++++++++++++++ 11 files changed, 2579 insertions(+), 4 deletions(-) create mode 100644 src/compat/normalize_tests.rs create mode 100644 src/normalize/mod.rs create mode 100644 src/normalize/scan.rs create mode 100644 src/normalize/walk.rs create mode 100644 tests/compat/normalize.rs diff --git a/Cargo.lock b/Cargo.lock index db4f44398..0dd4ee06d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -574,6 +574,8 @@ version = "0.1.0" dependencies = [ "clap", "divan", + "glob", + "memchr", "rayon", "serde", "serde_json", @@ -699,6 +701,12 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + [[package]] name = "hashbrown" version = "0.16.1" diff --git a/Cargo.toml b/Cargo.toml index 46373a6f5..8d03ddaf9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,8 @@ edition = "2024" [dependencies] clap = { version = "4.6.7", features = ["derive"], optional = true } +glob = { version = "0.3.3", optional = true } +memchr = { version = "2.8.3", optional = true } rayon = "1.12.0" serde = { version = "1.0.229", features = ["derive"] } serde_json = { version = "1.0.151", optional = true } @@ -23,14 +25,18 @@ name = "parse" harness = false [features] -default = ["cli", "compat"] +default = ["cli", "compat", "normalize"] # The compatibility layer: it reads the extensions other tools' metadata is # written with in place of the native `x-forge-*` ones, when a document has # no native one. Without it, only the native extensions mean anything. compat = [] +# `forge::normalize`, and `forge normalize` when the `cli` feature is on: it +# rewrites the extensions other tools' metadata is written with into the +# native ones, using the keys the compatibility layer reads. +normalize = ["compat", "dep:serde_json", "dep:memchr"] # The `forge` command. Library users can turn it off with # `default-features = false`. -cli = ["dep:clap", "dep:serde_json"] +cli = ["dep:clap", "dep:serde_json", "dep:glob"] # `forge::wasm`, which runs plugins compiled to WebAssembly components. wasm = ["dep:wasmtime", "dep:wasmtime-wasi"] diff --git a/src/compat/mod.rs b/src/compat/mod.rs index 46c87e2f8..3cbeaeda2 100644 --- a/src/compat/mod.rs +++ b/src/compat/mod.rs @@ -10,10 +10,14 @@ //! //! The layer is built only with the `compat` feature, and is meant to go: //! once nobody writes these keys, deleting this folder and the calls to it in -//! `link/resolve.rs` changes nothing about what any native extension means. The keys are read in place of a native key only when there is none, -//! as `link` decides. +//! `link/resolve.rs` changes nothing about what any native extension means. +//! The keys are read in place of a native key only when there is none, as +//! `link` decides. `forge normalize` is built on these tables, and goes with +//! them. mod fern; +#[cfg(all(test, feature = "normalize"))] +mod normalize_tests; mod stainless; use crate::metadata::{DefinitionField, Hit, ParameterField, SchemaField}; diff --git a/src/compat/normalize_tests.rs b/src/compat/normalize_tests.rs new file mode 100644 index 000000000..e28e72896 --- /dev/null +++ b/src/compat/normalize_tests.rs @@ -0,0 +1,101 @@ +//! `forge normalize` on documents that are damaged, and on its two ways of +//! checking a document. They are here, and not in `tests/compat/`, as they are +//! of what is private to `normalize`, and name the keys of other tools, which +//! only this layer does. + +use crate::normalize::{Options, check, check_and_walk, normalize, walk_unchecked}; + +/// A document that has what is walked in it, with keys to rewrite at +/// each place, one written with an escape. +const DOCUMENT: &str = r#"{"openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a/{id}": {"parameters": [{"name": "id", "in": "path", "x-fern-parameter-name": "i", "schema": {"type": "string"}}], + "get": {"x-fern-sdk-group-name": ["a", "b"], "x-fern-sdk-method-name": "get", "x-fern-ignore": true, + "x-forge-aliases": [{"x-fern-sdk-method-name": "old"}], + "requestBody": {"content": {"application/json": {"schema": {"type": "object", "properties": {"p": {"type": "string", "x-stainless-skip": true}}}}}}, + "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"type": "array", "items": {"type": "object", "x\u002dsensitive": true, "properties": {"q": {"x-sensitive": true}}}}}}}}}}}, + "components": {"schemas": {"s": {"allOf": [{"type": "string", "x-stainless-skip": ["go"]}], "$defs": {"d": {"x-sensitive": true}}}}, + "parameters": {"p": {"name": "n", "in": "query", "x-fern-parameter-name": "m", "schema": {"x-sensitive": true}}}}}"#; + +const KEEP: Options = Options { + leave_old_extensions: true, +}; + +/// Damaged copies of `document`: cut short, with a byte left out and with +/// a byte changed to something that is not what JSON is made of. +fn damaged(document: &str) -> Vec> { + let junk = [b'"', b'{', b'}', b'[', b']', b',', b':', b'\\', 0xFF, b'x', b' ', 0]; + let bytes = document.as_bytes(); + let mut all: Vec> = (0..=bytes.len()).map(|end| bytes[..end].to_vec()).collect(); + for at in 0..bytes.len() { + let mut without = bytes.to_vec(); + without.remove(at); + all.push(without); + for byte in junk { + let mut changed = bytes.to_vec(); + changed[at] = byte; + all.push(changed); + } + } + all +} + +/// The walk is only given a document that is JSON, as `normalize` stands, +/// when it is short, so it is given these as they are. +#[test] +fn the_walk_does_not_panic_or_loop_on_a_document_that_is_not_json() { + let documents = damaged(DOCUMENT); + assert!(documents.len() > 1000, "the damaged documents were made"); + for document in &documents { + for options in [Options::default(), KEEP] { + // What it makes of a document that is not JSON is not said, only + // that it does, and that what it makes can be written. + if let Ok((Some(edited), _)) = walk_unchecked(document, &options) { + let _ = edited.to_vec(); + } + } + } +} + +#[test] +fn a_document_is_rewritten_the_same_with_the_check_beside_the_walk_and_before_it() { + for options in [Options::default(), KEEP] { + let one = check_and_walk(DOCUMENT.as_bytes(), &options, false).unwrap(); + let both = check_and_walk(DOCUMENT.as_bytes(), &options, true).unwrap(); + assert_eq!(one.0.unwrap().to_vec(), both.0.unwrap().to_vec()); + assert_eq!(one.1.migrated, both.1.migrated); + } +} + +#[test] +fn a_document_that_is_not_json_is_an_error_beside_the_walk_as_it_is_before_it() { + for document in damaged(DOCUMENT).into_iter().step_by(7) { + let before = check_and_walk(&document, &Options::default(), false); + let beside = check_and_walk(&document, &Options::default(), true); + assert_eq!( + before.is_err(), + beside.is_err(), + "{}", + String::from_utf8_lossy(&document) + ); + if let (Err(before), Err(beside)) = (before, beside) { + assert_eq!(before.to_string(), beside.to_string()); + } + } +} + +#[test] +fn what_is_written_from_a_damaged_document_that_is_still_json_is_json() { + let mut rewritten = 0; + for document in damaged(DOCUMENT) { + if check(&document).is_err() { + continue; + } + for options in [Options::default(), KEEP] { + if let Some(edited) = normalize(&document, &options).unwrap().json { + check(&edited.to_vec()).unwrap(); + rewritten += 1; + } + } + } + assert!(rewritten > 0, "some of them were still JSON, with keys to rewrite"); +} diff --git a/src/lib.rs b/src/lib.rs index 1274ce41e..cfda20a0f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,6 +10,8 @@ mod link; mod metadata; mod method; mod name; +#[cfg(feature = "normalize")] +pub mod normalize; mod plugin; mod types; pub use json::Json; diff --git a/src/metadata/mod.rs b/src/metadata/mod.rs index 89b680973..04fc94bf2 100644 --- a/src/metadata/mod.rs +++ b/src/metadata/mod.rs @@ -194,6 +194,19 @@ pub(crate) mod definition { /// The names of the native keys of an operation or an alias, by slot. pub(crate) const NAMES: [&str; 6] = [METHOD_NAME, GROUP_NAME, SKIP, AVAILABILITY, HIDDEN, CONFIRMATION]; + /// Returns the native key that says what `field` says. + #[cfg(feature = "normalize")] + pub(crate) fn key(field: DefinitionField) -> &'static str { + match field { + DefinitionField::Method => METHOD_NAME, + DefinitionField::Group => GROUP_NAME, + DefinitionField::Skip(_) => SKIP, + DefinitionField::Availability => AVAILABILITY, + DefinitionField::Hidden => HIDDEN, + DefinitionField::Confirmation => CONFIRMATION, + } + } + /// Returns what `key` says on an operation or an alias, if it is a native /// key of one. pub(crate) fn read(key: &str) -> Option> { @@ -221,6 +234,14 @@ pub(crate) mod parameter { /// The names of the native keys of a Parameter Object, by slot. pub(crate) const NAMES: [&str; 1] = [NAME]; + /// Returns the native key that says what `field` says. + #[cfg(feature = "normalize")] + pub(crate) fn key(field: ParameterField) -> &'static str { + match field { + ParameterField::Name => NAME, + } + } + /// Returns what `key` says on a Parameter Object, if it is a native key of /// one. pub(crate) fn read(key: &str) -> Option> { @@ -250,6 +271,17 @@ pub(crate) mod schema { /// The names of the native keys of a Schema Object, by slot. pub(crate) const NAMES: [&str; 5] = [MODEL_NAME, PROPERTY_NAME, SKIP, SENSITIVE, DEPRECATED_SENSITIVE]; + /// Returns the native key that says what `field` says. + #[cfg(feature = "normalize")] + pub(crate) fn key(field: SchemaField) -> &'static str { + match field { + SchemaField::ModelName => MODEL_NAME, + SchemaField::PropertyName => PROPERTY_NAME, + SchemaField::Skip(_) => SKIP, + SchemaField::Sensitive => SENSITIVE, + } + } + /// Returns what `key` says on a Schema Object, if it is a native key of one. pub(crate) fn read(key: &str) -> Option> { // `x-sensitive` is the one native key outside the prefix. @@ -380,6 +412,36 @@ mod tests { assert_one_key_per_tier(documents, document::read); } + #[cfg(feature = "normalize")] + #[test] + fn each_field_is_said_by_the_native_key_that_reads_as_it() { + use std::mem::discriminant as same; + + for field in [ + DefinitionField::Method, + DefinitionField::Group, + DefinitionField::Skip(BoolFormat::Bool), + DefinitionField::Availability, + DefinitionField::Hidden, + DefinitionField::Confirmation, + ] { + let hit = definition::read(definition::key(field)).unwrap(); + assert!(same(&hit.field) == same(&field) && hit.tier == Tier::Native); + } + let hit = parameter::read(parameter::key(ParameterField::Name)).unwrap(); + assert!(matches!(hit.field, ParameterField::Name) && hit.tier == Tier::Native); + for field in [ + SchemaField::ModelName, + SchemaField::PropertyName, + SchemaField::Skip(BoolFormat::Bool), + SchemaField::Sensitive, + ] { + let hit = schema::read(schema::key(field)).unwrap(); + assert!(same(&hit.field) == same(&field) && hit.tier == Tier::Native); + } + assert_eq!(schema::read("x-sensitive").unwrap().tier, Tier::Deprecated); + } + #[test] fn a_native_key_is_read_only_at_its_level() { let levels: [Level; 4] = [ diff --git a/src/normalize/mod.rs b/src/normalize/mod.rs new file mode 100644 index 000000000..6aa74ab9e --- /dev/null +++ b/src/normalize/mod.rs @@ -0,0 +1,187 @@ +//! Rewrites the extensions other tools' metadata is written with into the +//! native `x-forge-*` ones, in a JSON document. +//! +//! This is the compatibility layer run as a rewrite instead of a read. The +//! key a tool writes is found with the same tables `forge` reads a document +//! with, at the same kinds of object, so a key is only rewritten where `forge` +//! would have read it: a key of an object that `forge` does not read there is +//! left alone, since rewriting it would give it a meaning it did not have. +//! +//! A native key is never replaced. When a document says the same thing with a +//! native key and another, the native one is kept, as `forge` would read it, +//! and the other is reported if it says something else. +//! +//! The document is edited, not written again: what is decided about a key is +//! applied to the bytes it was read from, so everything but the keys that are +//! rewritten, such as the text of every number, the whitespace and the order +//! of the keys, stays as it was. It is walked once, as bytes, and not parsed: +//! only the values of the keys that are rewritten are read. It is checked to +//! be JSON beside the walk, on another thread when it is long, so that what +//! is written is JSON without being checked again: an edit that replaces or +//! removes whole keys of a document that is JSON leaves it JSON. The edits +//! are made as the document is written out, with [`Edited::write_to`]. + +mod scan; +mod walk; + +pub use scan::Edited; + +use std::collections::BTreeMap; +use std::fmt; +use std::thread; + +use serde::de::IgnoredAny; + +/// How long a document has to be for it to be checked on another thread, as +/// it costs more than it saves for one that is shorter. +const PARALLEL: usize = 1 << 18; + +/// What [`normalize`] does. +#[derive(Clone, Copy, Debug, Default)] +pub struct Options { + /// Keeps each key that is rewritten, beside the native key that replaces + /// it, for what still reads the old one. + pub leave_old_extensions: bool, +} + +/// What [`normalize`] did. +#[derive(Debug, Default)] +pub struct Report { + /// How many keys were rewritten, or removed for a native key that + /// already said it, by the name of the key. + pub migrated: BTreeMap, + /// The keys that say something other than the native key beside them. + pub conflicts: Vec, + /// How many group paths were written again, as the names joined by dots, + /// which is the one value that is not copied as it was. + pub joined: usize, +} + +impl Report { + /// Returns whether the document is different from what it was. + pub fn changed(&self) -> bool { + !self.migrated.is_empty() + } +} + +/// A key that says something other than the native key beside it, which +/// wins. +#[derive(Debug)] +pub struct Conflict { + /// Where the object is, as a JSON Pointer. + pub location: String, + /// The key that lost. + pub key: String, + /// The native key that won. + pub native: &'static str, +} + +/// A document after [`normalize`]. +#[derive(Debug)] +pub struct Normalized<'j> { + /// The document, if it is different from what was read. + pub json: Option>, + /// What was done to it. + pub report: Report, +} + +/// Why a document could not be normalized. +#[derive(Debug)] +pub struct Error(String); + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl std::error::Error for Error {} + +impl From for Error { + fn from(err: serde_json::Error) -> Error { + Error(err.to_string()) + } +} + +/// Rewrites the keys of the document `json` that a native key replaces. +pub fn normalize<'j>(json: &'j [u8], options: &Options) -> Result, Error> { + if json.starts_with(b"\xEF\xBB\xBF") { + return Err(Error( + "the document starts with a byte order mark, which JSON does not have: save it as UTF-8 without one" + .to_owned(), + )); + } + if json.starts_with(b"\xFF\xFE") || json.starts_with(b"\xFE\xFF") { + return Err(Error("the document is UTF-16, and has to be UTF-8".to_owned())); + } + // Without this, a document that is not JSON, YAML for one, would be found + // to have nothing to rewrite, as the keys are not written as JSON writes + // them. + match json.iter().find(|byte| !matches!(byte, b' ' | b'\t' | b'\n' | b'\r')) { + None => return Err(Error("the document is empty".to_owned())), + Some(b'{') => {} + Some(_) => { + return Err(Error( + "the document is not a JSON object: it does not start with `{`".to_owned(), + )); + } + } + + let (edited, report) = check_and_walk(json, options, json.len() >= PARALLEL)?; + let Some(edited) = edited else { + return Ok(Normalized { json: None, report }); + }; + debug_assert!(check(&edited.to_vec()).is_ok(), "the edits made the document not JSON"); + Ok(Normalized { + json: Some(edited), + report, + }) +} + +/// Checks that `json` is a JSON document, and walks it, beside each other when +/// `threaded`. The walk is the slower of the two, so that what is written does +/// not have to be checked: a document that is JSON is still JSON when whole +/// keys of it are replaced or removed. A document that is not is an error +/// whatever the walk made of it, so it is the check that has the last word. +pub(crate) fn check_and_walk<'j>( + json: &'j [u8], + options: &Options, + threaded: bool, +) -> Result<(Option>, Report), Error> { + if threaded { + let both = thread::scope(|scope| { + // Not a thread to be had, which is possible, is not a failure. + let checking = thread::Builder::new().spawn_scoped(scope, || check(json)).ok()?; + let walked = walk::walk(json, options); + let checked = checking.join().unwrap_or_else(|panic| std::panic::resume_unwind(panic)); + Some((checked, walked)) + }); + if let Some((checked, walked)) = both { + checked?; + return walked; + } + } + check(json)?; + walk::walk(json, options) +} + +/// Checks that `json` is a JSON document. +pub(crate) fn check(json: &[u8]) -> Result<(), Error> { + // Reading a document without keeping it does not check that its strings + // are UTF-8. + if let Err(err) = std::str::from_utf8(json) { + return Err(Error(format!( + "the document is not UTF-8: it is not from byte {}", + err.valid_up_to() + ))); + } + serde_json::from_slice::(json)?; + Ok(()) +} + +/// Walks `json` without checking it, which `normalize` only does to a document +/// that is JSON, for tests of what the walk does to one that is not. +#[cfg(test)] +pub(crate) fn walk_unchecked<'j>(json: &'j [u8], options: &Options) -> Result<(Option>, Report), Error> { + walk::walk(json, options) +} diff --git a/src/normalize/scan.rs b/src/normalize/scan.rs new file mode 100644 index 000000000..771e8c132 --- /dev/null +++ b/src/normalize/scan.rs @@ -0,0 +1,663 @@ +//! Reading a JSON document as bytes, and editing it where it is read. +//! +//! Nothing here knows what a document means: it passes over values, hands out +//! the members of an object with where they are, and writes the edits it is +//! given. That is all the walk of [`walk`](super::walk) needs of the format. +//! It does not check the document either, which is done beside it, and nothing +//! here panics or loops on one that is not JSON. + +use std::borrow::Cow; +use std::fmt; +use std::io::{self, IoSlice, Write}; + +use serde::de::DeserializeOwned; + +use super::Error; + +/// The bytes that a value that is an object or an array has inside it that are +/// not just passed over: the quotes, which are strings, and the brackets. +static STRUCTURAL: [bool; 256] = { + let mut table = [false; 256]; + table[b'"' as usize] = true; + table[b'{' as usize] = true; + table[b'[' as usize] = true; + table[b'}' as usize] = true; + table[b']' as usize] = true; + table +}; + +/// Returns where the first `"` or `\` is in `rest`. Most strings are short, a +/// key or a name, and are looked through eight bytes at a time, which costs +/// less for them than a search for a byte does. +fn end_of_run(rest: &[u8]) -> Option { + const LOW: u64 = 0x0101_0101_0101_0101; + const HIGH: u64 = 0x8080_8080_8080_8080; + let mut at = 0; + while at < 32 { + let Some(chunk) = rest.get(at..).and_then(|rest| rest.first_chunk::<8>()) else { + break; + }; + let word = u64::from_le_bytes(*chunk); + let quotes = word ^ (LOW * u64::from(b'"')); + let slashes = word ^ (LOW * u64::from(b'\\')); + // A byte of a word that is zero has its high bit set in what is + // left, and so can others above it, but not below it. + let found = (quotes.wrapping_sub(LOW) & !quotes | slashes.wrapping_sub(LOW) & !slashes) & HIGH; + if found != 0 { + return Some(at + (found.trailing_zeros() / 8) as usize); + } + at += 8; + } + memchr::memchr2(b'"', b'\\', rest.get(at..).unwrap_or_default()).map(|found| at + found) +} + +/// A member of an object, as the bytes it is made of. +#[derive(Clone, Copy)] +pub(super) struct Member { + /// Where its leading whitespace starts, which is just after the `{` or + /// the `,` before it. + pub lead: usize, + /// The opening quote of its key. + pub key_start: usize, + /// Just after the closing quote of its key. + pub key_end: usize, + /// Whether the key has an escape in it, so that its bytes are not its + /// name. + pub escaped: bool, + pub value_start: usize, + pub value_end: usize, +} + +/// Where an object is up to, which [`Scan::next_member`] keeps. +pub(super) struct Open { + lead: usize, +} + +/// What is done to a member of an object. +pub(super) enum Action { + /// The key is written as the native key, with its value, or with this one. + Rename(&'static str, Option), + /// The member is removed, since a native key says what it says, or it + /// says nothing. + Remove, + /// The native key is written before it, with its value, or with this one, + /// and it stays. + Insert(&'static str, Option), +} + +/// What is written in place of a part of the document. +enum Piece { + /// A key, which is a name in quotes. + Quoted(&'static str), + Bytes(Vec), + Nothing, +} + +/// A part of the document, from `start` to `end`, that is written as `with`. +struct Replacement { + start: usize, + end: usize, + with: Piece, +} + +/// A position in a document, which values are read from, and the edits to it. +pub(super) struct Scan<'j> { + json: &'j [u8], + pos: usize, + replacements: Vec, +} + +impl<'j> Scan<'j> { + pub(super) fn new(json: &'j [u8]) -> Scan<'j> { + Scan { + json, + pos: 0, + replacements: Vec::new(), + } + } + + pub(super) fn error(&self, what: &str) -> Error { + self.error_at(self.pos, what) + } + + /// Returns an error that says where in the document, as a line and a + /// column that count from 1, `what` is. + pub(super) fn error_at(&self, at: usize, what: &str) -> Error { + let before = &self.json[..at.min(self.json.len())]; + let line = memchr::memchr_iter(b'\n', before).count() + 1; + let column = before.len() - memchr::memrchr(b'\n', before).map_or(0, |newline| newline + 1) + 1; + Error(format!("line {line}, column {column}: {what}")) + } + + /// Returns the bytes from `start` to `end`, or none if they are not in the + /// document. + pub(super) fn raw(&self, start: usize, end: usize) -> &'j [u8] { + debug_assert!( + start <= end && end <= self.json.len(), + "{start}..{end} is not in the document" + ); + self.json.get(start..end).unwrap_or_default() + } + + /// Passes over whitespace, which is mostly the spaces that a document is + /// indented with, so they are passed over eight at a time. + pub(super) fn skip_whitespace(&mut self) { + let mut pos = self.pos; + // There is mostly none, or one space, as there is after a colon. + if let Some(&[first, second]) = self.json.get(pos..).and_then(|rest| rest.first_chunk::<2>()) { + if first > b' ' { + return; + } + if first == b' ' && second > b' ' { + self.pos = pos + 1; + return; + } + } + loop { + let Some(chunk) = self.json.get(pos..).and_then(|rest| rest.first_chunk::<8>()) else { + while self + .json + .get(pos) + .is_some_and(|b| matches!(b, b' ' | b'\t' | b'\n' | b'\r')) + { + pos += 1; + } + break; + }; + let not_spaces = u64::from_le_bytes(*chunk) ^ 0x2020_2020_2020_2020; + if not_spaces == 0 { + pos += 8; + continue; + } + pos += (not_spaces.trailing_zeros() / 8) as usize; + if matches!(self.json[pos], b'\t' | b'\n' | b'\r') { + pos += 1; + } else { + break; + } + } + self.pos = pos; + } + + fn peek(&self) -> Result { + self.json + .get(self.pos) + .copied() + .ok_or_else(|| self.error("the document ends")) + } + + /// Returns whether the value at the position is an object. + pub(super) fn at_object(&self) -> bool { + self.json.get(self.pos) == Some(&b'{') + } + + /// Passes over the string at the position, and returns whether it has an + /// escape in it. + fn skip_string(&mut self) -> Result { + if self.peek()? != b'"' { + return Err(self.error("a string is expected")); + } + self.pos += 1; + let mut escaped = false; + loop { + let Some(at) = self.json.get(self.pos..).and_then(end_of_run) else { + return Err(self.error("a string does not end")); + }; + self.pos += at; + if self.json[self.pos] == b'"' { + self.pos += 1; + return Ok(escaped); + } + escaped = true; + self.pos += 2; + } + } + + /// Passes over the value at the position. + pub(super) fn skip_value(&mut self) -> Result<(), Error> { + match self.peek()? { + b'"' => self.skip_string().map(|_| ()), + b'{' | b'[' => { + let mut depth = 0usize; + loop { + while self.json.get(self.pos).is_some_and(|&b| !STRUCTURAL[b as usize]) { + self.pos += 1; + } + match self.peek()? { + b'"' => { + self.skip_string()?; + } + b'{' | b'[' => { + depth += 1; + self.pos += 1; + } + _ => { + depth -= 1; + self.pos += 1; + if depth == 0 { + return Ok(()); + } + } + } + } + } + _ => { + let start = self.pos; + while self + .json + .get(self.pos) + .is_some_and(|b| !matches!(b, b',' | b'}' | b']' | b' ' | b'\t' | b'\n' | b'\r')) + { + self.pos += 1; + } + if self.pos == start { + return Err(self.error("a value is expected")); + } + Ok(()) + } + } + } + + /// Enters the object at the position, if it is one. + pub(super) fn open_object(&mut self) -> Result, Error> { + if self.peek()? != b'{' { + return Ok(None); + } + self.pos += 1; + Ok(Some(Open { lead: self.pos })) + } + + /// Returns the next member of the object, which stands at its value, or + /// none at the end of the object. The value has to be passed over, by + /// whatever reads it, before [`end_member`](Scan::end_member). + pub(super) fn next_member(&mut self, open: &mut Open) -> Result, Error> { + self.skip_whitespace(); + if self.peek()? == b'}' { + self.pos += 1; + return Ok(None); + } + let key_start = self.pos; + let escaped = self.skip_string()?; + let key_end = self.pos; + self.skip_whitespace(); + if self.peek()? != b':' { + return Err(self.error("a key has no value")); + } + self.pos += 1; + self.skip_whitespace(); + Ok(Some(Member { + lead: open.lead, + key_start, + key_end, + escaped, + value_start: self.pos, + value_end: self.pos, + })) + } + + /// Ends `member`, whose value has been passed over. + pub(super) fn end_member(&mut self, open: &mut Open, member: &mut Member) { + member.value_end = self.pos; + self.skip_whitespace(); + if self.json.get(self.pos) == Some(&b',') { + self.pos += 1; + open.lead = self.pos; + } + } + + /// Enters the array at the position, if it is one. + pub(super) fn open_array(&mut self) -> Result { + if self.peek()? != b'[' { + return Ok(false); + } + self.pos += 1; + Ok(true) + } + + /// Returns whether the array has another element, which the position + /// stands at, or else is past the end of the array. + pub(super) fn next_element(&mut self) -> Result { + self.skip_whitespace(); + if self.peek()? == b']' { + self.pos += 1; + return Ok(false); + } + Ok(true) + } + + /// Ends an element, which has been passed over. + pub(super) fn end_element(&mut self) { + self.skip_whitespace(); + if self.json.get(self.pos) == Some(&b',') { + self.pos += 1; + } + } + + /// Returns the bytes that `member` is called, which are the bytes of its + /// key as they are, unless it has an escape in it. + pub(super) fn name(&self, member: &Member) -> Result, Error> { + if member.escaped { + return self.key(member).map(|key| Cow::Owned(key.into_owned().into_bytes())); + } + Ok(Cow::Borrowed(self.raw_key(member))) + } + + /// Returns the key of `member` between its quotes. + pub(super) fn raw_key(&self, member: &Member) -> &'j [u8] { + self.raw(member.key_start + 1, member.key_end.saturating_sub(1)) + } + + /// Returns the key of `member`, with its escapes read. + pub(super) fn key(&self, member: &Member) -> Result, Error> { + let name = self.raw(member.key_start + 1, member.key_end.saturating_sub(1)); + if memchr::memchr(b'\\', name).is_none() { + return std::str::from_utf8(name) + .map(Cow::Borrowed) + .map_err(|_| self.error_at(member.key_start, "a key is not UTF-8")); + } + self.decode(member.key_start, member.key_end).map(Cow::Owned) + } + + /// Returns whether the key of `member` is `name`. + pub(super) fn key_is(&self, member: &Member, name: &str) -> bool { + if member.escaped { + self.key(member).is_ok_and(|key| key == name) + } else { + self.raw_key(member) == name.as_bytes() + } + } + + /// Reads the JSON from `start` to `end` as a `T`. + pub(super) fn decode(&self, start: usize, end: usize) -> Result { + serde_json::from_slice(self.raw(start, end)).map_err(|err| self.error_at(start, &err.to_string())) + } + + /// Writes down what to do to the members of an object, each as its place + /// among them and the action for it. + pub(super) fn edit(&mut self, members: &[Member], actions: Vec<(usize, Action)>) { + let mut removed = vec![false; members.len()]; + for (at, action) in actions { + let member = &members[at]; + match action { + Action::Rename(native, value) => { + self.replace(member.key_start, member.key_end, Piece::Quoted(native)); + if let Some(value) = value { + self.replace(member.value_start, member.value_end, Piece::Bytes(value.into_bytes())); + } + } + Action::Insert(native, value) => { + // The native key goes first, so that the old one is left + // as it is, to the comma after it: a diff of the document + // is only the lines that are added. It has the whitespace + // the old one has, before it and between it and the value. + let mut bytes = format!("\"{native}\"").into_bytes(); + bytes.extend_from_slice(self.raw(member.key_end, member.value_start)); + match value { + Some(value) => bytes.extend_from_slice(value.as_bytes()), + None => bytes.extend_from_slice(self.raw(member.value_start, member.value_end)), + } + bytes.push(b','); + bytes.extend_from_slice(self.raw(member.lead, member.key_start)); + self.replace(member.key_start, member.key_start, Piece::Bytes(bytes)); + } + Action::Remove => removed[at] = true, + } + } + + // Removed members go with their commas, so that one is left between + // each two that are kept, and each that is kept keeps its whitespace. + match removed.iter().position(|removed| !removed) { + None if members.is_empty() => {} + None => self.replace(members[0].lead, members[members.len() - 1].value_end, Piece::Nothing), + Some(first) => { + if first > 0 { + self.replace(members[0].lead, members[first].lead, Piece::Nothing); + } + for (i, gone) in removed.iter().enumerate().skip(first + 1) { + if *gone { + self.replace(members[i - 1].value_end, members[i].value_end, Piece::Nothing); + } + } + } + } + } + + fn replace(&mut self, start: usize, end: usize, with: Piece) { + self.replacements.push(Replacement { start, end, with }); + } + + /// Returns the document with the edits to be made, or none if there are + /// none. + pub(super) fn finish(mut self) -> Result>, Error> { + if self.replacements.is_empty() { + return Ok(None); + } + self.replacements.sort_unstable_by_key(|r| (r.start, r.end)); + let mut parts = Vec::with_capacity(self.replacements.len() * 2 + 1); + let mut written = Vec::new(); + let mut at = 0; + for r in &self.replacements { + if r.start < at { + return Err(Error("two edits overlap".to_owned())); + } + if r.start > at { + parts.push(Part::Document(at, r.start)); + } + let from = written.len(); + match &r.with { + Piece::Quoted(name) => { + written.push(b'"'); + written.extend_from_slice(name.as_bytes()); + written.push(b'"'); + } + Piece::Bytes(bytes) => written.extend_from_slice(bytes), + Piece::Nothing => {} + } + if written.len() > from { + parts.push(Part::Written(from, written.len())); + } + at = r.end; + } + if at < self.json.len() { + parts.push(Part::Document(at, self.json.len())); + } + Ok(Some(Edited { + json: self.json, + written, + parts, + })) + } +} + +/// A part of an edited document, as where it is. +enum Part { + /// Bytes of the document that was read, from here to there. + Document(usize, usize), + /// Bytes that were written in place of others, from here to there. + Written(usize, usize), +} + +/// How many parts are handed to one vectored write, which is as many as +/// the system takes at least. +const PARTS_AT_ONCE: usize = 512; + +/// A document with its edits made, which are made as it is written out: the +/// parts of the document that are left as they are are not copied to be put +/// together, and are written from where they were read. +pub struct Edited<'j> { + json: &'j [u8], + written: Vec, + parts: Vec, +} + +impl fmt::Debug for Edited<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Edited") + .field("parts", &self.parts.len()) + .finish_non_exhaustive() + } +} + +impl Edited<'_> { + /// Writes the document to `out`. + pub fn write_to(&self, out: &mut W) -> io::Result<()> { + let mut batch: Vec> = Vec::with_capacity(PARTS_AT_ONCE); + for part in &self.parts { + let bytes = match *part { + Part::Document(start, end) => &self.json[start..end], + Part::Written(start, end) => &self.written[start..end], + }; + batch.push(IoSlice::new(bytes)); + if batch.len() == PARTS_AT_ONCE { + write_all(out, &mut batch)?; + } + } + write_all(out, &mut batch) + } + + /// Returns the document as bytes. + pub fn to_vec(&self) -> Vec { + let mut bytes = Vec::with_capacity(self.json.len() + self.json.len() / 16); + self.write_to(&mut bytes).expect("writing to the memory cannot fail"); + bytes + } +} + +/// Writes all of `batch`, and empties it. +fn write_all(out: &mut W, batch: &mut Vec>) -> io::Result<()> { + let mut left = &mut batch[..]; + while !left.is_empty() { + match out.write_vectored(left) { + Ok(0) => return Err(io::ErrorKind::WriteZero.into()), + Ok(written) => IoSlice::advance_slices(&mut left, written), + Err(err) if err.kind() == io::ErrorKind::Interrupted => {} + Err(err) => return Err(err), + } + } + batch.clear(); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Where the first `"` or `\` is, read a byte at a time. + fn naive_end_of_run(bytes: &[u8]) -> Option { + bytes.iter().position(|&b| b == b'"' || b == b'\\') + } + + #[test] + fn a_run_ends_where_a_quote_or_a_backslash_is_at_any_offset() { + for filler in [b'a', b' ', 0x7F, 0x80, 0xC3, 0xFF, 0x01, b'#'] { + for len in 0..80 { + for end in *b"\"\\" { + let none = vec![filler; len]; + assert_eq!(end_of_run(&none), None, "{filler:#x} x {len}"); + for at in 0..len { + let mut bytes = vec![filler; len]; + bytes[at] = end; + assert_eq!(end_of_run(&bytes), Some(at), "{filler:#x} x {len}, {end:#x} at {at}"); + } + } + } + } + } + + #[test] + fn the_first_of_a_quote_and_a_backslash_ends_a_run_even_beside_bytes_that_borrow() { + // 0x01 and 0x00 below a quote are where a subtraction borrows from it. + for prefix in [&b"\x01\x01"[..], b"\x00\x01", b"\x22\\", b"\\\x22", b"a\x23\x21"] { + for pad in 0..40 { + let mut bytes = vec![b'z'; pad]; + bytes.extend_from_slice(prefix); + bytes.extend_from_slice(b"\"tail\\"); + assert_eq!(end_of_run(&bytes), naive_end_of_run(&bytes), "{prefix:?} after {pad}"); + } + } + } + + /// Passes over whitespace a byte at a time. + fn naive_skip_whitespace(bytes: &[u8]) -> usize { + bytes + .iter() + .position(|b| !matches!(b, b' ' | b'\t' | b'\n' | b'\r')) + .unwrap_or(bytes.len()) + } + + #[test] + fn whitespace_is_passed_over_as_it_is_a_byte_at_a_time() { + let alphabet = [b' ', b'\n', b'\t', b'\r', b'x', 0x0C]; + let mut documents: Vec> = vec![Vec::new()]; + // Every document of up to six bytes from the alphabet. + for len in 1..=6 { + let mut longer = Vec::new(); + for document in documents.iter().filter(|document| document.len() == len - 1) { + for &byte in &alphabet { + let mut document = document.clone(); + document.push(byte); + longer.push(document); + } + } + documents.extend(longer); + } + // Long runs of spaces, which are eight at a time, that end in each of the ways. + for spaces in 0..70 { + for end in [&b""[..], b"x", b"\n", b"\n x", b"\r\n\t x", b"\x0C"] { + let mut document = vec![b' '; spaces]; + document.extend_from_slice(end); + documents.push(document); + } + } + for document in documents { + let mut scan = Scan::new(&document); + scan.skip_whitespace(); + assert_eq!(scan.pos, naive_skip_whitespace(&document), "{document:?}"); + } + } + + /// Returns the object `json` with the members called `keys` removed. + fn without(json: &str, keys: &[&str]) -> String { + let mut scan = Scan::new(json.as_bytes()); + let mut open = scan.open_object().unwrap().unwrap(); + let mut members = Vec::new(); + while let Some(mut member) = scan.next_member(&mut open).unwrap() { + scan.skip_value().unwrap(); + scan.end_member(&mut open, &mut member); + members.push(member); + } + let removals = keys + .iter() + .map(|key| { + ( + members.iter().position(|member| scan.key_is(member, key)).unwrap(), + Action::Remove, + ) + }) + .collect(); + scan.edit(&members, removals); + let edited = scan.finish().unwrap().expect("something was removed"); + String::from_utf8(edited.to_vec()).unwrap() + } + + #[test] + fn an_object_whose_members_are_all_removed_is_left_empty_as_it_was_written() { + // What was between the last member and the end of the object is left. + assert_eq!(without(r#"{"a": 1, "b": [2]}"#, &["a", "b"]), "{}"); + assert_eq!(without("{ \"a\": 1,\n \"b\": [2] }", &["a", "b"]), "{ }"); + assert_eq!(without(r#"{"a": 1}"#, &["a"]), "{}"); + } + + #[test] + fn the_members_that_are_kept_keep_one_comma_between_them_wherever_the_others_were() { + let json = r#"{"a": 1, "b": 2, "c": 3, "d": 4}"#; + + // A member that is kept has the whitespace it was written with. + assert_eq!(without(json, &["a"]), r#"{ "b": 2, "c": 3, "d": 4}"#); + assert_eq!(without(json, &["d"]), r#"{"a": 1, "b": 2, "c": 3}"#); + assert_eq!(without(json, &["b", "c"]), r#"{"a": 1, "d": 4}"#); + assert_eq!(without(json, &["a", "b", "c"]), r#"{ "d": 4}"#); + assert_eq!(without(json, &["b", "c", "d"]), r#"{"a": 1}"#); + assert_eq!(without(json, &["a", "c"]), r#"{ "b": 2, "d": 4}"#); + } +} diff --git a/src/normalize/walk.rs b/src/normalize/walk.rs new file mode 100644 index 000000000..db9efbdaf --- /dev/null +++ b/src/normalize/walk.rs @@ -0,0 +1,490 @@ +//! What a document means, which is where a key is to be rewritten. +//! +//! The walk follows the document as `forge` reads it: down from `paths` and +//! `components` to the operations, the parameters and the schemas, which are +//! the objects that have keys to rewrite, and no further. Everything else is +//! passed over. It decides what to do to each key as the object it is in +//! ends, and writes it down as an edit, so a document is read once. + +use serde_json::Value; + +use super::scan::{Action, Edited, Member, Scan}; +use super::{Conflict, Error, Options, Report}; +use crate::compat; +use crate::metadata::{self, BoolFormat, DefinitionField, Hit, ParameterField, SchemaField, Tier}; + +/// How deep schemas may be nested, where a document is not to be followed any +/// further. The check of a document puts no limit on how deep it is, and a +/// document could be nested as deep as it is long. +const DEPTH: usize = 256; + +/// A kind of object with extensions. +trait Level { + type Field: Copy; + + /// Returns what `key` says on the object, whichever tool wrote it. + fn read(key: &str) -> Option>; + + /// Returns the native key that says what `field` says. + fn native(field: Self::Field) -> &'static str; + + /// Returns whether `value`, written as `field` says, is something that + /// no native key says, which is left as it is written: it is not for a + /// rewrite to say it some other way, or to drop what only it says. + fn has_no_native_form(_field: Self::Field, _value: &Value) -> bool { + false + } + + /// Returns the JSON that `value`, written as `field` says, is written as + /// when it is written as the native key says, if that is not as it is. + fn written(_field: Self::Field, _value: &Value) -> Option { + None + } + + /// Returns whether `a` and `b`, written as `field` says, say the same. + fn same(_field: Self::Field, a: &Value, b: &Value) -> bool { + a == b + } +} + +struct Definition; +struct Parameter; +struct Schema; + +/// Returns whether a skip is written as a list of targets, which a native +/// skip, that is for every target, does not say. +fn skip_is_targets(format: BoolFormat, value: &Value) -> bool { + matches!(format, BoolFormat::BoolOrTargets) && value.is_array() +} + +impl Level for Definition { + type Field = DefinitionField; + + fn read(key: &str) -> Option> { + metadata::definition::read(key).or_else(|| compat::definition(key)) + } + + fn native(field: DefinitionField) -> &'static str { + metadata::definition::key(field) + } + + fn has_no_native_form(field: DefinitionField, value: &Value) -> bool { + matches!(field, DefinitionField::Skip(format) if skip_is_targets(format, value)) + } + + /// A group path is written as the names joined by dots, as the native key + /// is written in Cloudflare's documents. One that a dotted string cannot + /// say, because a name has a dot in it, say, stays an array. + fn written(field: DefinitionField, value: &Value) -> Option { + let (DefinitionField::Group, Value::Array(names)) = (field, value) else { + return None; + }; + let path = dotted(names)?; + Some(serde_json::to_string(&path).expect("a string is written as JSON")) + } + + fn same(field: DefinitionField, a: &Value, b: &Value) -> bool { + match field { + DefinitionField::Group => group_path(a).is_some_and(|a| group_path(b) == Some(a)) || a == b, + _ => a == b, + } + } +} + +/// Returns the names of an array joined by dots, if each is a name that a +/// dotted string reads back as it is. +fn dotted(names: &[Value]) -> Option { + let mut path = String::new(); + for (i, name) in names.iter().enumerate() { + let name = name.as_str().filter(|name| !name.is_empty() && !name.contains('.'))?; + if i > 0 { + path.push('.'); + } + path.push_str(name); + } + Some(path) +} + +/// Returns the names a group path says: a string is read as names separated +/// by dots, and the elements of an array are the names, which are not split. +fn group_path(value: &Value) -> Option> { + match value { + Value::String(path) if path.is_empty() => Some(Vec::new()), + Value::String(path) => Some(path.split('.').collect()), + Value::Array(names) => names.iter().map(Value::as_str).collect(), + _ => None, + } +} + +impl Level for Parameter { + type Field = ParameterField; + + fn read(key: &str) -> Option> { + metadata::parameter::read(key).or_else(|| compat::parameter(key)) + } + + fn native(field: ParameterField) -> &'static str { + metadata::parameter::key(field) + } +} + +impl Level for Schema { + type Field = SchemaField; + + fn read(key: &str) -> Option> { + metadata::schema::read(key).or_else(|| compat::schema(key)) + } + + fn native(field: SchemaField) -> &'static str { + metadata::schema::key(field) + } + + fn has_no_native_form(field: SchemaField, value: &Value) -> bool { + matches!(field, SchemaField::Skip(format) if skip_is_targets(format, value)) + } +} + +/// A step on the way to an object, kept as where it is in the document: it is +/// only written out for a conflict. +enum Step { + /// A key, as its quotes and what is between them. + Key(usize, usize), + Index(usize), +} + +/// Walks a document, and returns the document with the edits made, if there +/// are any, and what was done. +pub(super) fn walk<'j>(json: &'j [u8], options: &Options) -> Result<(Option>, Report), Error> { + let mut walk = Walk { + scan: Scan::new(json), + options, + report: Report::default(), + members: Vec::new(), + path: Vec::new(), + depth: 0, + }; + walk.scan.skip_whitespace(); + walk.document()?; + let Walk { scan, report, .. } = walk; + Ok((scan.finish()?, report)) +} + +struct Walk<'j, 'o> { + scan: Scan<'j>, + options: &'o Options, + report: Report, + /// The members of the objects being read that have keys to rewrite, the + /// innermost last, which is all of one object after its own members and + /// none of those of the objects in it. + members: Vec, + path: Vec, + depth: usize, +} + +impl Walk<'_, '_> { + fn document(&mut self) -> Result<(), Error> { + let Some(mut open) = self.scan.open_object()? else { + return self.scan.skip_value(); + }; + while let Some(mut member) = self.scan.next_member(&mut open)? { + let key = self.scan.name(&member)?; + self.path.push(Step::Key(member.key_start, member.key_end)); + match &*key { + b"paths" => self.each_value(Self::path_item)?, + b"components" => self.components()?, + _ => self.scan.skip_value()?, + } + self.path.pop(); + self.scan.end_member(&mut open, &mut member); + } + Ok(()) + } + + /// Reads each member of the object at the position with `child`, which + /// has to pass over the value, and skips a value that is not an object. + fn each_value(&mut self, child: fn(&mut Self) -> Result<(), Error>) -> Result<(), Error> { + let Some(mut open) = self.scan.open_object()? else { + return self.scan.skip_value(); + }; + while let Some(mut member) = self.scan.next_member(&mut open)? { + self.path.push(Step::Key(member.key_start, member.key_end)); + child(self)?; + self.path.pop(); + self.scan.end_member(&mut open, &mut member); + } + Ok(()) + } + + /// Reads each element of the array at the position with `child`, and skips + /// a value that is not an array. + fn each_element(&mut self, child: fn(&mut Self) -> Result<(), Error>) -> Result<(), Error> { + if !self.scan.open_array()? { + return self.scan.skip_value(); + } + let mut index = 0; + while self.scan.next_element()? { + self.path.push(Step::Index(index)); + child(self)?; + self.path.pop(); + self.scan.end_element(); + index += 1; + } + Ok(()) + } + + /// Reads the member of the object at the position that is called `name` + /// with `child`, and passes over the others. + fn only(&mut self, name: &str, child: fn(&mut Self) -> Result<(), Error>) -> Result<(), Error> { + let Some(mut open) = self.scan.open_object()? else { + return self.scan.skip_value(); + }; + while let Some(mut member) = self.scan.next_member(&mut open)? { + if self.scan.key_is(&member, name) { + self.path.push(Step::Key(member.key_start, member.key_end)); + child(self)?; + self.path.pop(); + } else { + self.scan.skip_value()?; + } + self.scan.end_member(&mut open, &mut member); + } + Ok(()) + } + + fn components(&mut self) -> Result<(), Error> { + let Some(mut open) = self.scan.open_object()? else { + return self.scan.skip_value(); + }; + while let Some(mut member) = self.scan.next_member(&mut open)? { + let key = self.scan.name(&member)?; + self.path.push(Step::Key(member.key_start, member.key_end)); + match &*key { + b"schemas" => self.each_value(Self::schema)?, + b"parameters" => self.each_value(Self::parameter)?, + b"requestBodies" | b"responses" => self.each_value(Self::content_of)?, + b"pathItems" => self.each_value(Self::path_item)?, + _ => self.scan.skip_value()?, + } + self.path.pop(); + self.scan.end_member(&mut open, &mut member); + } + Ok(()) + } + + fn path_item(&mut self) -> Result<(), Error> { + let Some(mut open) = self.scan.open_object()? else { + return self.scan.skip_value(); + }; + while let Some(mut member) = self.scan.next_member(&mut open)? { + let key = self.scan.name(&member)?; + self.path.push(Step::Key(member.key_start, member.key_end)); + match &*key { + b"parameters" => self.each_element(Self::parameter)?, + b"get" | b"put" | b"post" | b"delete" | b"options" | b"head" | b"patch" | b"trace" | b"query" + | b"connect" => { + self.operation()?; + } + b"additionalOperations" => self.each_value(Self::operation)?, + _ => self.scan.skip_value()?, + } + self.path.pop(); + self.scan.end_member(&mut open, &mut member); + } + Ok(()) + } + + fn operation(&mut self) -> Result<(), Error> { + self.object::(Self::operation_children) + } + + /// Reads the member `key` of an operation that has objects to read in + /// it, and returns whether it did. + fn operation_children(&mut self, key: &[u8]) -> Result { + match key { + b"parameters" => self.each_element(Self::parameter)?, + b"requestBody" => self.content_of()?, + b"responses" => self.each_value(Self::content_of)?, + b"x-forge-aliases" => self.each_element(Self::alias)?, + _ => return Ok(false), + } + Ok(true) + } + + fn alias(&mut self) -> Result<(), Error> { + self.object::(|_, _| Ok(false)) + } + + fn parameter(&mut self) -> Result<(), Error> { + self.object::(Self::parameter_children) + } + + fn parameter_children(&mut self, key: &[u8]) -> Result { + match key { + b"schema" => self.schema()?, + b"content" => self.media_types()?, + _ => return Ok(false), + } + Ok(true) + } + + /// Reads a request body or a response, which have nothing but a `content` + /// to read. + fn content_of(&mut self) -> Result<(), Error> { + self.only("content", Self::media_types) + } + + fn media_types(&mut self) -> Result<(), Error> { + self.each_value(Self::media) + } + + fn media(&mut self) -> Result<(), Error> { + self.only("schema", Self::schema) + } + + fn schema(&mut self) -> Result<(), Error> { + self.depth += 1; + if self.depth > DEPTH { + return Err(self.scan.error("the schemas are nested too deeply")); + } + let done = self.object::(Self::schema_children); + self.depth -= 1; + done + } + + fn schema_children(&mut self, key: &[u8]) -> Result { + match key { + b"properties" | b"$defs" => self.each_value(Self::schema)?, + b"additionalProperties" | b"unevaluatedProperties" | b"not" => self.schema()?, + b"items" if self.scan.at_object() => self.schema()?, + b"items" | b"prefixItems" | b"allOf" | b"anyOf" | b"oneOf" => self.each_element(Self::schema)?, + _ => return Ok(false), + } + Ok(true) + } + + /// Reads the object at the position, which is of the kind `L`: each member + /// with `children`, which says whether it passed over its value, and then + /// the keys of the object that are to be rewritten. + fn object(&mut self, children: fn(&mut Self, &[u8]) -> Result) -> Result<(), Error> { + let Some(mut open) = self.scan.open_object()? else { + return self.scan.skip_value(); + }; + let base = self.members.len(); + // Most objects have no key that begins with `x-`, and are done with + // when they end. + let mut extension = false; + while let Some(mut member) = self.scan.next_member(&mut open)? { + extension |= member.escaped || self.scan.raw_key(&member).starts_with(b"x-"); + let name = self.scan.name(&member)?; + self.path.push(Step::Key(member.key_start, member.key_end)); + if !children(self, &name)? { + self.scan.skip_value()?; + } + self.path.pop(); + self.scan.end_member(&mut open, &mut member); + self.members.push(member); + } + if extension { + self.rewrite::(base)?; + } + self.members.truncate(base); + Ok(()) + } + + /// Decides what to do to the keys of the object whose members start at + /// `base`, which is of the kind `L`, that a native key replaces. + fn rewrite(&mut self, base: usize) -> Result<(), Error> { + let mut actions: Vec<(usize, Action)> = Vec::new(); + for at in 0..self.members.len() - base { + let member = self.members[base + at]; + // Most keys are not extensions, which is told without reading + // them, unless they are written with an escape. + if !member.escaped && !self.scan.raw_key(&member).starts_with(b"x-") { + continue; + } + let key = self.scan.key(&member)?; + if !key.starts_with("x-") { + continue; + } + let Some(hit) = L::read(&key).filter(|hit| hit.tier != Tier::Native) else { + continue; + }; + let native = L::native(hit.field); + let members = &self.members[base..]; + if let Some(again) = members.iter().skip(at + 1).find(|other| self.scan.key_is(other, &key)) { + return Err(self + .scan + .error_at(again.key_start, &format!("{key} is written twice in this object"))); + } + + let value: Value = self.scan.decode(member.value_start, member.value_end)?; + if L::has_no_native_form(hit.field, &value) { + continue; + } + // The native key that is beside is the one that decides, so it has + // to be one: `forge` reads no key that is written twice. + let mut named = members.iter().filter(|other| self.scan.key_is(other, native)); + let beside: Option = match named.next() { + Some(other) => { + if let Some(again) = named.next() { + return Err(self + .scan + .error_at(again.key_start, &format!("{native} is written twice in this object"))); + } + Some(self.scan.decode(other.value_start, other.value_end)?) + } + None => None, + }; + if beside + .as_ref() + .is_some_and(|beside| !L::same(hit.field, &value, beside)) + { + let location = self.pointer(); + self.report.conflicts.push(Conflict { + location, + key: key.to_string(), + native, + }); + } + + let writes = beside.is_none(); + let text = L::written(hit.field, &value).filter(|_| writes); + if text.is_some() { + self.report.joined += 1; + } + let action = match (self.options.leave_old_extensions, writes) { + (false, true) => Action::Rename(native, text), + (false, false) => Action::Remove, + (true, true) => Action::Insert(native, text), + (true, false) => continue, + }; + match self.report.migrated.get_mut(&*key) { + Some(count) => *count += 1, + None => { + self.report.migrated.insert(key.to_string(), 1); + } + } + actions.push((at, action)); + } + if !actions.is_empty() { + self.scan.edit(&self.members[base..], actions); + } + Ok(()) + } + + /// Returns where the object being read is, as a JSON Pointer. + fn pointer(&self) -> String { + let mut out = String::new(); + for step in &self.path { + out.push('/'); + match *step { + Step::Key(start, end) => { + let key: String = serde_json::from_slice(self.scan.raw(start, end)).unwrap_or_default(); + out.push_str(&key.replace('~', "~0").replace('/', "~1")); + } + Step::Index(index) => out.push_str(&index.to_string()), + } + } + out + } +} diff --git a/tests/compat/main.rs b/tests/compat/main.rs index c44b04ea4..59b110659 100644 --- a/tests/compat/main.rs +++ b/tests/compat/main.rs @@ -10,6 +10,11 @@ //! which has no vendor names in it, runs without it. mod common; +#[cfg(feature = "normalize")] +#[path = "../wasm/dump.rs"] +mod dump; mod fern; mod native_first; +#[cfg(feature = "normalize")] +mod normalize; mod stainless; diff --git a/tests/compat/normalize.rs b/tests/compat/normalize.rs new file mode 100644 index 000000000..26624b305 --- /dev/null +++ b/tests/compat/normalize.rs @@ -0,0 +1,1047 @@ +//! `forge::normalize`: a document written with other tools' keys is rewritten +//! to the native ones, and builds the same `Forge` as before. + +use forge::normalize::{Options, Report, normalize}; +use serde_json::{Value, json}; + +use super::common::{assert_same, describe, operation_with, parse, parse_err, responding_with}; + +const KEEP: Options = Options { + leave_old_extensions: true, +}; + +/// Normalizes `document`, and returns what it became, as it is written, and +/// what was done. +#[track_caller] +fn edit(document: &str, options: &Options) -> (String, Report) { + let normalized = normalize(document.as_bytes(), options).unwrap(); + let text = normalized + .json + .map_or_else(|| document.to_owned(), |json| String::from_utf8(json.to_vec()).unwrap()); + (text, normalized.report) +} + +/// Normalizes `document`, and returns what it became, as a value, and what +/// was done. +#[track_caller] +fn run(document: &str, options: &Options) -> (Value, Report) { + let (text, report) = edit(document, options); + (serde_json::from_str(&text).unwrap(), report) +} + +/// Returns the document at `pointer` of `value`. +#[track_caller] +fn at<'v>(value: &'v Value, pointer: &str) -> &'v Value { + value + .pointer(pointer) + .unwrap_or_else(|| panic!("{pointer} is not in {value}")) +} + +/// Every key of the adapters, at the kind of object it is read on. +const ALL: &str = r##"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": { + "/a/{id}": {"get": { + "x-fern-sdk-group-name": ["a"], "x-fern-sdk-method-name": "get", "x-fern-availability": "beta", + "parameters": [{"name": "id", "in": "path", "required": true, + "x-fern-parameter-name": "ident", "schema": {"type": "string"}}], + "responses": {"200": {"description": "ok", "content": {"application/json": { + "schema": {"$ref": "#/components/schemas/Thing"}}}}}}}, + "/b": {"delete": { + "x-fern-sdk-group-name": ["b"], "x-fern-sdk-method-name": "gone", "x-fern-ignore": true, + "responses": {"204": {"description": "ok"}}}} + }, + "components": {"schemas": {"Thing": { + "type": "object", "x-fern-type-name": "Widget", + "properties": { + "id": {"type": "string", "x-fern-property-name": "widget_id", "x-sensitive": true}, + "legacy": {"type": "string", "x-stainless-skip": true}}}}} +}"##; + +#[test] +fn a_normalized_document_builds_the_same_forge() { + let (normalized, report) = run(ALL, &Options::default()); + + assert!(report.changed()); + assert_same(ALL, &normalized.to_string()); +} + +#[test] +fn every_key_is_rewritten_as_its_native_key() { + let (normalized, report) = run(ALL, &Options::default()); + + let op = at(&normalized, "/paths/~1a~1{id}/get"); + assert_eq!(op["x-forge-group-name"], "a"); + assert_eq!(op["x-forge-method-name"], "get"); + assert_eq!(op["x-forge-availability"], "beta"); + assert_eq!( + at(&normalized, "/paths/~1a~1{id}/get/parameters/0")["x-forge-parameter-name"], + "ident" + ); + assert_eq!(at(&normalized, "/paths/~1b/delete")["x-forge-skip"], true); + let thing = at(&normalized, "/components/schemas/Thing"); + assert_eq!(thing["x-forge-model-name"], "Widget"); + assert_eq!(thing["properties"]["id"]["x-forge-property-name"], "widget_id"); + assert_eq!(thing["properties"]["id"]["x-forge-sensitive"], true); + assert_eq!(thing["properties"]["legacy"]["x-forge-skip"], true); + + let text = normalized.to_string(); + for old in ["x-fern-", "x-stainless-", "x-sensitive"] { + assert!(!text.contains(old), "{old} is left in {text}"); + } + assert_eq!(report.migrated.values().sum::(), 11); + assert_eq!(report.migrated["x-fern-sdk-method-name"], 2); + assert!(report.conflicts.is_empty()); +} + +#[test] +fn a_key_is_rewritten_where_it_stands_among_the_keys() { + let (text, _) = edit( + &operation_with( + r#""summary": "s", "x-fern-sdk-method-name": "get", "description": "d", "x-fern-sdk-group-name": ["a"],"#, + ), + &Options::default(), + ); + + let at = |key: &str| text.find(key).unwrap(); + assert!(at("summary") < at("x-forge-method-name")); + assert!(at("x-forge-method-name") < at("description")); + assert!(at("description") < at("x-forge-group-name")); +} + +#[test] +fn keys_that_have_no_native_key_are_left_alone() { + let document = operation_with( + r#""x-fern-audiences": ["internal"], "x-fern-enum": 1, "x-stainless-terraform-configurability": "computed", + "x-fern-sdk-group-name": ["a"], "x-fern-sdk-method-name": "get","#, + ); + let (normalized, _) = run(&document, &Options::default()); + + let op = at(&normalized, "/paths/~1a/get"); + assert_eq!(op["x-fern-audiences"], json!(["internal"])); + assert_eq!(op["x-fern-enum"], 1); + assert_eq!(op["x-stainless-terraform-configurability"], "computed"); +} + +#[test] +fn a_key_is_rewritten_only_on_the_objects_it_is_read_on() { + // `x-fern-ignore` is read on an operation and not on a schema, so a + // native `x-forge-skip` there would drop a field the document keeps. + let document = responding_with( + r#"{"type": "object", "properties": {"id": {"type": "string", "x-fern-ignore": true, + "x-fern-sdk-method-name": "x"}}}"#, + "{}", + ); + let (normalized, report) = run(&document, &Options::default()); + + assert!(!report.changed()); + assert_eq!(normalized, serde_json::from_str::(&document).unwrap()); + assert_same(&document, &normalized.to_string()); +} + +#[test] +fn a_native_key_is_never_replaced() { + let document = operation_with( + r#""x-fern-sdk-method-name": "old", "x-forge-method-name": "new", "x-fern-sdk-group-name": ["a"],"#, + ); + let (normalized, report) = run(&document, &Options::default()); + + let op = at(&normalized, "/paths/~1a/get"); + assert_eq!(op["x-forge-method-name"], "new"); + assert!(op.get("x-fern-sdk-method-name").is_none()); + assert_eq!(report.conflicts.len(), 1); + assert_eq!(report.conflicts[0].location, "/paths/~1a/get"); + assert_eq!(report.conflicts[0].key, "x-fern-sdk-method-name"); + assert_eq!(report.conflicts[0].native, "x-forge-method-name"); +} + +#[test] +fn a_key_that_says_what_the_native_key_says_is_no_conflict() { + let document = operation_with( + r#""x-fern-sdk-method-name": "get", "x-forge-method-name": "get", "x-fern-sdk-group-name": ["a"],"#, + ); + let (normalized, report) = run(&document, &Options::default()); + + assert!(report.conflicts.is_empty()); + assert!( + at(&normalized, "/paths/~1a/get") + .get("x-fern-sdk-method-name") + .is_none() + ); +} + +#[test] +fn the_old_keys_can_be_left_beside_the_native_ones() { + let (normalized, report) = run(ALL, &KEEP); + + let op = at(&normalized, "/paths/~1a~1{id}/get"); + assert_eq!(op["x-fern-sdk-method-name"], "get"); + assert_eq!(op["x-forge-method-name"], "get"); + let thing = at(&normalized, "/components/schemas/Thing"); + assert_eq!(thing["properties"]["id"]["x-sensitive"], true); + assert_eq!(thing["properties"]["id"]["x-forge-sensitive"], true); + assert_eq!(report.migrated.values().sum::(), 11); + assert_same(ALL, &normalized.to_string()); +} + +#[test] +fn normalizing_twice_changes_nothing() { + for options in [Options::default(), KEEP] { + let (once, _) = edit(ALL, &options); + let (twice, report) = edit(&once, &options); + + assert!(!report.changed(), "{options:?}"); + assert_eq!(once, twice, "{options:?}"); + } +} + +#[test] +fn a_list_of_targets_to_skip_is_left_as_it_is_in_both_modes() { + let document = responding_with( + r#"{"type": "object", "properties": {"a": {"type": "string", "x-stainless-skip": ["terraform"]}, + "b": {"type": "string", "x-stainless-skip": false}}}"#, + "{}", + ); + let path = "/paths/~1a/get/responses/200/content/application~1json/schema/properties"; + + let (normalized, report) = run(&document, &Options::default()); + + assert_eq!( + *at(&normalized, path), + json!({ + "a": {"type": "string", "x-stainless-skip": ["terraform"]}, + "b": {"type": "string", "x-forge-skip": false} + }) + ); + assert_eq!(report.migrated["x-stainless-skip"], 1); + assert_same(&document, &normalized.to_string()); + + let (kept, report) = run(&document, &KEEP); + + assert_eq!( + *at(&kept, path), + json!({ + "a": {"type": "string", "x-stainless-skip": ["terraform"]}, + "b": {"type": "string", "x-forge-skip": false, "x-stainless-skip": false} + }) + ); + assert_eq!(report.migrated["x-stainless-skip"], 1); +} + +#[test] +fn a_list_of_targets_is_left_beside_a_native_skip_and_is_not_a_conflict() { + let before = with_property(r#"{"type":"string","x-forge-skip":true,"x-stainless-skip":["t"]}"#); + + for options in [Options::default(), KEEP] { + let normalized = normalize(before.as_bytes(), &options).unwrap(); + + assert!(normalized.json.is_none() && !normalized.report.changed()); + assert!(normalized.report.conflicts.is_empty()); + } +} + +#[test] +fn a_list_of_targets_is_not_in_the_way_of_what_is_rewritten_around_it() { + let before = with_property(r#"{"x-stainless-skip":["t"],"x-sensitive":true,"type":"string"}"#); + let after = with_property(r#"{"x-stainless-skip":["t"],"x-forge-sensitive":true,"type":"string"}"#); + + let (text, report) = edit(&before, &Options::default()); + + assert_eq!(text, after); + assert_eq!(report.migrated.len(), 1); +} + +#[test] +fn the_deprecated_native_key_is_rewritten() { + let document = responding_with( + r#"{"type": "object", "properties": {"secret": {"type": "string", "x-sensitive": true}}}"#, + "{}", + ); + let (normalized, report) = run(&document, &Options::default()); + + let secret = at( + &normalized, + "/paths/~1a/get/responses/200/content/application~1json/schema/properties/secret", + ); + assert_eq!(*secret, json!({"type": "string", "x-forge-sensitive": true})); + assert_eq!(report.migrated["x-sensitive"], 1); +} + +#[test] +fn aliases_are_rewritten() { + let document = + operation_with(r#""x-forge-aliases": [{"x-fern-sdk-group-name": ["a"], "x-fern-sdk-method-name": "pause"}],"#); + let (normalized, _) = run(&document, &Options::default()); + + assert_eq!( + *at(&normalized, "/paths/~1a/get/x-forge-aliases/0"), + json!({"x-forge-group-name": "a", "x-forge-method-name": "pause"}) + ); + assert_same(&document, &normalized.to_string()); +} + +#[test] +fn schemas_are_found_wherever_forge_reads_them() { + let skipped = json!({"type": "string", "x-stainless-skip": true}); + let media = |schema: &Value| json!({"text/plain": {"schema": schema}}); + let document = json!({ + "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, + "paths": {"/a": { + "parameters": [{"name": "p", "in": "query", "x-fern-parameter-name": "q", "schema": skipped}], + "get": { + "requestBody": {"content": {"application/json": {"schema": { + "allOf": [skipped], "items": skipped, "additionalProperties": skipped, + "prefixItems": [skipped], "not": skipped, "$defs": {"d": skipped}}}}}, + "responses": {"200": {"description": "ok", "content": media(&skipped)}}}}}, + "components": { + "parameters": {"p": {"name": "p", "in": "query", "content": media(&skipped)}}, + "requestBodies": {"r": {"content": media(&skipped)}}, + "responses": {"s": {"description": "ok", "content": media(&skipped)}}, + "pathItems": {"i": {"get": {"responses": {"200": {"description": "ok", "content": media(&skipped)}}}}}} + }); + let (normalized, report) = run(&document.to_string(), &Options::default()); + + assert!(!normalized.to_string().contains("x-stainless-skip")); + assert_eq!(report.migrated["x-stainless-skip"], 12); + assert_eq!(report.migrated["x-fern-parameter-name"], 1); +} + +#[test] +fn a_conflict_says_where_it_is_as_a_json_pointer() { + let document = r#"{"openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a/{id}": {"get": {"parameters": [ + {"name": "id", "in": "path", "x-fern-parameter-name": "a", "x-forge-parameter-name": "b", + "schema": {"type": "string"}}], + "responses": {"204": {"description": "ok"}}}}}}"#; + let (_, report) = run(document, &Options::default()); + + assert_eq!(report.conflicts[0].location, "/paths/~1a~1{id}/get/parameters/0"); +} + +/// A document with a key to rewrite in each of the ways a document is written, +/// and everything else that a rewrite must not touch. +const AWKWARD: &str = concat!( + "{\r\n", + "\t\"openapi\" : \"3.0.3\",\r\n", + "\t\"info\":{\"title\":\"caf\\u00e9 \\/ t\",\"version\":\"1\"},\r\n", + "\t\"paths\":{\"/a\":{\"get\":{ \"x-fern-sdk-method-name\" :\"get\" ,\"x-fern-sdk-group-name\":[ \"a\" ],\r\n", + "\t\t\"description\":\"\\u00e9\\n\",\"responses\":{\"200\":{\"description\":\"ok\",\"content\":{\"application/json\":{", + "\"schema\":{\"type\":\"object\",\"properties\":{\r\n", + "\t\t\t\"a\":{\"type\":\"number\",\"example\":116.11599994199997,\"x-fern-property-name\":\"b\"},\r\n", + "\t\t\t\"c\":{\"type\":\"number\",\"example\":0.000005,\"minimum\":1E5,\"maximum\":1.0,\"x-sensitive\":true}", + "}}}}}}}}}\r\n", + "}\r\n", + "\r\n", +); + +#[test] +fn nothing_but_the_keys_that_are_rewritten_changes() { + let (text, report) = edit(AWKWARD, &Options::default()); + + // The group is the one value that is written again, as a dotted string. + let want = AWKWARD + .replace("\"x-fern-sdk-method-name\"", "\"x-forge-method-name\"") + .replace("\"x-fern-sdk-group-name\":[ \"a\" ]", "\"x-forge-group-name\":\"a\"") + .replace("\"x-fern-property-name\"", "\"x-forge-property-name\"") + .replace("\"x-sensitive\"", "\"x-forge-sensitive\""); + assert_eq!(text, want); + assert_eq!(report.migrated.values().sum::(), 4); + for kept in [ + "116.11599994199997", + "0.000005", + "1E5", + "1.0", + "caf\\u00e9 \\/ t", + "\\u00e9\\n", + ] { + assert!(text.contains(kept), "{kept}"); + } +} + +/// Returns a document with one schema in `components.schemas` that has the +/// property `p` written as `property`. +fn with_property(property: &str) -> String { + format!( + r#"{{"openapi":"3.0.3","info":{{"title":"t","version":"1"}},"paths":{{}},"components":{{"schemas":{{"S":{{"type":"object","properties":{{"p":{property}}}}}}}}}}}"# + ) +} + +#[test] +fn a_removed_key_takes_its_comma_and_leaves_the_others_as_they_were() { + // Each key that is removed has the native key that says what it says + // beside it, wherever that is. + for (before, after) in [ + ( + r#"{"type":"string","x-forge-sensitive":true,"x-sensitive":false}"#, + r#"{"type":"string","x-forge-sensitive":true}"#, + ), + ( + r#"{"x-sensitive":false,"x-forge-sensitive":true,"type":"string"}"#, + r#"{"x-forge-sensitive":true,"type":"string"}"#, + ), + ( + r#"{"type":"string","x-sensitive":false,"x-forge-sensitive":true,"format":"uri"}"#, + r#"{"type":"string","x-forge-sensitive":true,"format":"uri"}"#, + ), + ( + r#"{"x-forge-sensitive":true,"x-sensitive":false}"#, + r#"{"x-forge-sensitive":true}"#, + ), + ( + r#"{"x-sensitive":false,"x-forge-sensitive":true}"#, + r#"{"x-forge-sensitive":true}"#, + ), + ( + r#"{"x-sensitive":false,"x-stainless-skip":true,"x-forge-sensitive":true,"x-forge-skip":false,"type":"string"}"#, + r#"{"x-forge-sensitive":true,"x-forge-skip":false,"type":"string"}"#, + ), + ( + r#"{"type":"string","x-sensitive":false,"x-forge-sensitive":true,"x-stainless-skip":true,"x-forge-skip":false}"#, + r#"{"type":"string","x-forge-sensitive":true,"x-forge-skip":false}"#, + ), + ] { + let (text, _) = edit(&with_property(before), &Options::default()); + + assert_eq!(text, with_property(after), "{before}"); + } +} + +#[test] +fn a_removed_key_leaves_the_whitespace_of_the_others() { + let pretty = |members: &str| with_property(&format!("{{{members}}}")); + for (before, after) in [ + ( + "\n \"type\": \"string\",\n \"x-forge-sensitive\": true,\n \"x-sensitive\":\n false\n ", + "\n \"type\": \"string\",\n \"x-forge-sensitive\": true\n ", + ), + ( + "\n \"x-sensitive\": false,\n \"x-forge-sensitive\": true,\n \"type\": \"string\"\n ", + "\n \"x-forge-sensitive\": true,\n \"type\": \"string\"\n ", + ), + ( + "\n\t\"type\": \"string\",\n\t\"x-sensitive\": false,\n\t\"x-forge-sensitive\": true,\n\t\"format\": \"uri\"\n", + "\n\t\"type\": \"string\",\n\t\"x-forge-sensitive\": true,\n\t\"format\": \"uri\"\n", + ), + ] { + let (text, _) = edit(&pretty(before), &Options::default()); + + assert_eq!(text, pretty(after), "{before:?}"); + } +} + +#[test] +fn a_key_that_is_left_is_preceded_by_the_native_key_written_the_same_way() { + for (before, after) in [ + ( + r#"{"type":"string","x-fern-property-name":"a"}"#, + r#"{"type":"string","x-forge-property-name":"a","x-fern-property-name":"a"}"#, + ), + ( + "{\n \"x-fern-property-name\" : [\"a\"],\n \"type\": \"string\"\n }", + "{\n \"x-forge-property-name\" : [\"a\"],\n \"x-fern-property-name\" : [\"a\"],\n \"type\": \"string\"\n }", + ), + ] { + let (text, _) = edit(&with_property(before), &KEEP); + + assert_eq!(text, with_property(after), "{before}"); + } +} + +#[test] +fn leaving_the_old_keys_only_adds_lines() { + let pretty = serde_json::to_string_pretty(&serde_json::from_str::(ALL).unwrap()).unwrap(); + + let (text, report) = edit(&pretty, &KEEP); + + assert!(report.changed()); + let mut original = pretty.lines().peekable(); + for line in text.lines() { + if original.peek() == Some(&line) { + original.next(); + } + } + assert_eq!(original.next(), None, "a line of the document is changed or gone"); + assert!(text.lines().count() > pretty.lines().count()); +} + +#[test] +fn a_group_path_is_written_as_the_names_joined_by_dots() { + let document = operation_with(r#""x-fern-sdk-group-name": ["a", "b", "c"], "x-fern-sdk-method-name": "get","#); + + let (normalized, _) = run(&document, &Options::default()); + + assert_eq!(at(&normalized, "/paths/~1a/get")["x-forge-group-name"], "a.b.c"); + assert_same(&document, &normalized.to_string()); +} + +#[test] +fn a_group_path_written_over_several_lines_becomes_one_string() { + let before = operation_with( + "\n \"x-fern-sdk-group-name\": [\n \"a\",\n \"b\"\n ],\n \"x-fern-sdk-method-name\": \"get\",", + ); + let after = operation_with("\n \"x-forge-group-name\": \"a.b\",\n \"x-forge-method-name\": \"get\","); + + let (text, _) = edit(&before, &Options::default()); + + assert_eq!(text, after); +} + +#[test] +fn a_group_path_that_is_a_string_is_written_as_it_was() { + let document = operation_with(r#""x-fern-sdk-group-name": "a.b", "x-fern-sdk-method-name": "get","#); + + let (text, _) = edit(&document, &Options::default()); + + assert_eq!( + text, + operation_with(r#""x-forge-group-name": "a.b", "x-forge-method-name": "get","#) + ); +} + +#[test] +fn a_group_path_with_no_names_is_the_empty_string() { + let (normalized, _) = run( + &operation_with(r#""x-fern-sdk-group-name": [], "x-fern-sdk-method-name": "get","#), + &Options::default(), + ); + + assert_eq!(at(&normalized, "/paths/~1a/get")["x-forge-group-name"], ""); +} + +#[test] +fn a_group_path_a_dotted_string_cannot_say_is_kept_as_an_array() { + for names in [r#"["a.b", "c"]"#, r#"["a", ""]"#, r#"["a", 1]"#] { + let document = operation_with(&format!( + r#""x-fern-sdk-group-name": {names}, "x-fern-sdk-method-name": "get","# + )); + + let (text, _) = edit(&document, &Options::default()); + + assert_eq!( + text, + operation_with(&format!( + r#""x-forge-group-name": {names}, "x-forge-method-name": "get","# + )), + "{names}" + ); + } +} + +#[test] +fn a_fern_group_with_a_dot_in_a_name_stays_an_array_byte_for_byte() { + // Written over several lines, so that each byte of the array is checked. + let before = operation_with( + "\n \"x-fern-sdk-group-name\": [\n \"a.b\",\n \"c\"\n ],\n \"x-fern-sdk-method-name\": \"get\",", + ); + let after = operation_with( + "\n \"x-forge-group-name\": [\n \"a.b\",\n \"c\"\n ],\n \"x-forge-method-name\": \"get\",", + ); + + let (text, _) = edit(&before, &Options::default()); + + assert_eq!(text, after); + assert!(!text.contains("a.b.c"), "the names were joined"); +} + +#[test] +fn a_group_path_that_is_joined_is_read_back_as_the_same_path() { + for names in [ + vec!["a"], + vec!["a", "b"], + vec!["zero-trust", "dlp", "custom-prompt-topics"], + vec!["with_underscore", "v2", "2fa"], + vec!["caf\u{e9}", "\u{4e2d}\u{6587}"], + vec!["a", "b", "c", "d", "e", "f", "g"], + ] { + let array = format!( + "[{}]", + names + .iter() + .map(|name| format!("\"{name}\"")) + .collect::>() + .join(", ") + ); + let document = operation_with(&format!( + r#""x-fern-sdk-group-name": {array}, "x-fern-sdk-method-name": "get","# + )); + + let (normalized, _) = run(&document, &Options::default()); + + let joined = at(&normalized, "/paths/~1a/get")["x-forge-group-name"] + .as_str() + .unwrap(); + assert_eq!(joined, names.join("."), "{names:?}"); + assert_same(&document, &normalized.to_string()); + } +} + +#[test] +fn a_group_with_a_dot_in_a_name_is_still_the_same_groups() { + let document = operation_with(r#""x-fern-sdk-group-name": ["a.b", "c"], "x-fern-sdk-method-name": "get","#); + + let (text, _) = edit(&document, &Options::default()); + + assert_same(&document, &text); + // Two names, `a.b` and `c`. Joined into `"a.b.c"` they would be three, so + // that is the mistake this keeps from being made. + assert!( + describe(&parse(&text)).contains("group a_b.c"), + "{}", + describe(&parse(&text)) + ); + let joined = operation_with(r#""x-forge-group-name": "a.b.c", "x-forge-method-name": "get","#); + assert_ne!(describe(&parse(&joined)), describe(&parse(&text))); +} + +#[test] +fn leaving_the_old_key_copies_a_group_with_a_dot_as_an_array() { + let before = operation_with(r#""x-fern-sdk-group-name": ["a.b", "c"], "x-fern-sdk-method-name": "get","#); + let after = operation_with( + r#""x-forge-group-name": ["a.b", "c"],"x-fern-sdk-group-name": ["a.b", "c"], "x-forge-method-name": "get", "x-fern-sdk-method-name": "get","#, + ); + + let (text, _) = edit(&before, &KEEP); + + assert_eq!(text, after); +} + +#[test] +fn only_the_groups_with_a_dot_in_a_name_stay_arrays() { + let document = r#"{"openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": { + "/a": {"get": {"x-fern-sdk-group-name": ["a.b", "c"], "x-fern-sdk-method-name": "get", "responses": {"204": {"description": "ok"}}}}, + "/b": {"get": {"x-fern-sdk-group-name": ["x", "y"], "x-fern-sdk-method-name": "get", "responses": {"204": {"description": "ok"}}}}, + "/c": {"get": {"x-fern-sdk-group-name": ["p", "q.r"], "x-fern-sdk-method-name": "get", "responses": {"204": {"description": "ok"}}}}}}"#; + + let (normalized, _) = run(document, &Options::default()); + + assert_eq!( + at(&normalized, "/paths/~1a/get")["x-forge-group-name"], + json!(["a.b", "c"]) + ); + assert_eq!(at(&normalized, "/paths/~1b/get")["x-forge-group-name"], "x.y"); + assert_eq!( + at(&normalized, "/paths/~1c/get")["x-forge-group-name"], + json!(["p", "q.r"]) + ); + assert_same(document, &normalized.to_string()); +} + +#[test] +fn an_alias_with_a_dot_in_a_group_name_stays_an_array() { + let document = operation_with( + r#""x-forge-aliases": [{"x-fern-sdk-group-name": ["a.b", "c"], "x-fern-sdk-method-name": "pause"}, {"x-fern-sdk-group-name": ["x", "y"], "x-fern-sdk-method-name": "stop"}],"#, + ); + + let (normalized, _) = run(&document, &Options::default()); + + assert_eq!( + at(&normalized, "/paths/~1a/get/x-forge-aliases/0")["x-forge-group-name"], + json!(["a.b", "c"]) + ); + assert_eq!( + at(&normalized, "/paths/~1a/get/x-forge-aliases/1")["x-forge-group-name"], + "x.y" + ); + assert_same(&document, &normalized.to_string()); +} + +#[test] +fn a_group_with_a_dot_is_not_the_same_as_the_dotted_string_beside_it() { + // `["a.b", "c"]` is two groups, and `"a.b.c"` is three, so this is a + // conflict, and the same array beside itself is none. + let different = operation_with( + r#""x-fern-sdk-group-name": ["a.b", "c"], "x-forge-group-name": "a.b.c", "x-forge-method-name": "get","#, + ); + let same = operation_with( + r#""x-fern-sdk-group-name": ["a.b", "c"], "x-forge-group-name": ["a.b", "c"], "x-forge-method-name": "get","#, + ); + + assert_eq!(edit(&different, &Options::default()).1.conflicts.len(), 1); + assert!(edit(&same, &Options::default()).1.conflicts.is_empty()); +} + +#[test] +fn leaving_the_old_key_writes_the_native_one_as_a_string_and_keeps_the_array() { + let before = operation_with(r#""x-fern-sdk-group-name": ["a", "b"], "x-fern-sdk-method-name": "get","#); + let after = operation_with( + r#""x-forge-group-name": "a.b","x-fern-sdk-group-name": ["a", "b"], "x-forge-method-name": "get", "x-fern-sdk-method-name": "get","#, + ); + + let (text, _) = edit(&before, &KEEP); + + assert_eq!(text, after); +} + +#[test] +fn a_native_group_that_says_the_same_in_the_other_form_is_no_conflict() { + for (native, old) in [(r#""a.b""#, r#"["a", "b"]"#), (r#"["a", "b"]"#, r#""a.b""#)] { + let document = operation_with(&format!( + r#""x-fern-sdk-group-name": {old}, "x-forge-group-name": {native}, "x-forge-method-name": "get","# + )); + + let (_, report) = edit(&document, &Options::default()); + + assert!(report.conflicts.is_empty(), "{native} and {old}"); + } +} + +#[test] +fn a_native_group_that_says_something_else_is_a_conflict() { + let document = operation_with( + r#""x-fern-sdk-group-name": ["a", "b"], "x-forge-group-name": "a.c", "x-forge-method-name": "get","#, + ); + + let (normalized, report) = run(&document, &Options::default()); + + assert_eq!(at(&normalized, "/paths/~1a/get")["x-forge-group-name"], "a.c"); + assert_eq!(report.conflicts.len(), 1); +} + +#[test] +fn a_key_written_twice_in_an_object_is_an_error() { + let document = operation_with( + r#""x-fern-sdk-method-name": "a", "x-fern-sdk-method-name": "b", "x-fern-sdk-group-name": ["a"],"#, + ); + + let err = normalize(document.as_bytes(), &Options::default()) + .unwrap_err() + .to_string(); + + assert!(err.contains("x-fern-sdk-method-name is written twice"), "{err}"); +} + +/// `forge` reads no key that is written twice in an object it reads, whatever +/// the key is: a native one, one of another tool, or one with no extension in +/// its name. +#[test] +fn forge_rejects_a_key_written_twice_in_an_object() { + for keys in [ + r#""x-forge-method-name": "a", "x-forge-method-name": "b", "x-forge-group-name": "g","#, + r#""x-fern-sdk-method-name": "a", "x-fern-sdk-method-name": "b", "x-fern-sdk-group-name": ["g"],"#, + r#""x-forge-method-name": "a", "x-forge-method-name": "b", "x-fern-sdk-method-name": "c", "x-forge-group-name": "g","#, + r#""description": "a", "description": "b", "x-forge-method-name": "a", "x-forge-group-name": "g","#, + ] { + let err = parse_err(&operation_with(keys)); + + assert!(err.contains("duplicate field"), "{keys}: {err}"); + } +} + +#[test] +fn a_native_key_written_twice_beside_a_key_that_is_rewritten_is_an_error_in_both_modes() { + // Which of the two is the native key that is beside is not for a rewrite + // to say, and what it writes could not be read by `forge` either way. + for (keys, name) in [ + ( + r#""x-forge-method-name": "a", "x-forge-method-name": "b", "x-fern-sdk-method-name": "c", "x-forge-group-name": "g","#, + "x-forge-method-name", + ), + ( + r#""x-fern-sdk-method-name": "c", "x-forge-method-name": "a", "x-forge-group-name": "g", "x-forge-method-name": "b","#, + "x-forge-method-name", + ), + // One of them written with an escape is the same key. + ( + r#""x-fern-ignore": true, "x-forge-skip": true, "x\u002dforge-skip": false,"#, + "x-forge-skip", + ), + ] { + for options in [Options::default(), KEEP] { + let err = normalize(operation_with(keys).as_bytes(), &options) + .unwrap_err() + .to_string(); + + assert!(err.contains(&format!("{name} is written twice")), "{keys}: {err}"); + assert!(err.contains("line 2"), "{err}"); + } + } +} + +#[test] +fn a_native_key_written_twice_with_no_key_to_rewrite_beside_it_is_left_as_it_is() { + // Nothing is rewritten there, so nothing is read there: it is what + // `forge` says when it reads the document, which `normalize` is not. + let document = operation_with( + r#""x-forge-method-name": "a", "x-forge-method-name": "b", "x-forge-group-name": "g", "description": "x", "description": "y","#, + ); + + for options in [Options::default(), KEEP] { + let normalized = normalize(document.as_bytes(), &options).unwrap(); + + assert!(normalized.json.is_none() && !normalized.report.changed()); + } +} + +#[test] +fn a_native_key_written_twice_in_one_object_does_not_stop_the_others_being_rewritten() { + let document = r#"{"openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": {}, + "components": {"schemas": { + "Dup": {"x-forge-skip": true, "x-forge-skip": false}, + "Fine": {"x-sensitive": true}}}}"#; + + let (text, report) = edit(document, &Options::default()); + + assert!(text.contains(r#""Fine": {"x-forge-sensitive": true}"#), "{text}"); + assert!( + text.contains(r#""Dup": {"x-forge-skip": true, "x-forge-skip": false}"#), + "{text}" + ); + assert_eq!(report.migrated["x-sensitive"], 1); +} + +#[test] +fn a_key_written_with_an_escape_is_rewritten_as_the_key_it_is() { + let before = operation_with(r#""x-fern-sdk-group-name": ["a"], "x\u002dfern-sdk-method-name": "get","#); + let after = operation_with(r#""x-forge-group-name": "a", "x-forge-method-name": "get","#); + + let (text, _) = edit(&before, &Options::default()); + + assert_eq!(text, after); + assert_same(&before, &text); +} + +#[test] +fn a_container_written_with_an_escape_is_followed() { + let before = responding_with( + r#"{"type": "object", "propert\u0069es": {"id": {"type": "string", "x-sensitive": true}}}"#, + "{}", + ); + + let (text, report) = edit(&before, &Options::default()); + + assert_eq!(text, before.replace("\"x-sensitive\"", "\"x-forge-sensitive\"")); + assert_eq!(report.migrated["x-sensitive"], 1); +} + +#[test] +fn mangled_documents_do_not_panic_and_what_is_written_is_json() { + let junk = [b'"', b'{', b'}', b'[', b']', b',', b':', b'\\', 0xFF, b'x', b' ']; + let mut documents: Vec> = Vec::new(); + for end in 0..=ALL.len() { + documents.push(ALL.as_bytes()[..end].to_vec()); + } + for at in 0..ALL.len() { + let mut without = ALL.as_bytes().to_vec(); + without.remove(at); + documents.push(without); + for byte in junk { + let mut changed = ALL.as_bytes().to_vec(); + changed[at] = byte; + documents.push(changed); + } + } + + for document in &documents { + for options in [Options::default(), KEEP] { + if let Ok(normalized) = normalize(document, &options) + && let Some(json) = normalized.json + { + assert!( + serde_json::from_slice::(&json.to_vec()).is_ok(), + "{}", + String::from_utf8_lossy(document) + ); + } + } + } +} + +/// A writer that takes a few bytes of what it is given each time, and is +/// interrupted now and then, as a pipe is. +struct Trickle { + taken: Vec, + calls: usize, +} + +impl std::io::Write for Trickle { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + self.calls += 1; + // A writer that is given the same bytes again and again has to fail + // the test, and not fill the memory. + assert!(self.taken.len() < 1 << 22, "more was written than the document has"); + if self.calls.is_multiple_of(5) { + return Err(std::io::ErrorKind::Interrupted.into()); + } + let n = bytes.len().min(7); + self.taken.extend_from_slice(&bytes[..n]); + Ok(n) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +/// A document with `n` schemas that each have a key to rewrite, which is more +/// parts to write than are handed to the system at once. +fn many_schemas(n: usize) -> String { + let schemas: Vec = (0..n) + .map(|i| format!(r#""s{i}": {{"type": "string", "x-sensitive": true}}"#)) + .collect(); + format!( + r#"{{"openapi": "3.0.3", "info": {{"title": "t", "version": "1"}}, "paths": {{}}, + "components": {{"schemas": {{{}}}}}}}"#, + schemas.join(",\n") + ) +} + +#[test] +fn an_edited_document_is_written_whole_however_much_the_writer_takes() { + for n in [1, 300, 700] { + let document = many_schemas(n); + let normalized = normalize(document.as_bytes(), &Options::default()).unwrap(); + let edited = normalized.json.unwrap(); + let expected = edited.to_vec(); + + let mut trickle = Trickle { + taken: Vec::new(), + calls: 0, + }; + edited.write_to(&mut trickle).unwrap(); + let mut whole = Vec::new(); + edited.write_to(&mut whole).unwrap(); + + assert_eq!(trickle.taken, expected, "{n} schemas, a few bytes at a time"); + assert_eq!(whole, expected, "{n} schemas"); + let text = String::from_utf8(expected).unwrap(); + assert_eq!(text.matches("\"x-forge-sensitive\"").count(), n); + assert!(!text.contains("\"x-sensitive\"")); + assert_same(&document, &text); + } +} + +#[test] +fn a_document_that_ends_in_the_middle_of_an_escape_is_an_error_and_not_a_panic() { + // Where a key or a value is passed over, and where it is read. + for document in [ + r#"{"a": "x\"#, + r#"{"x-fern-ignore": "x\"#, + r#"{"paths": {"/a": {"get": {"x-fern-sdk-method-name": "x\"#, + r#"{"a\"#, + r#"{"paths": {"/a\"#, + ] { + assert!( + normalize(document.as_bytes(), &Options::default()).is_err(), + "{document}" + ); + } +} + +#[test] +fn schemas_nested_without_end_are_an_error_and_not_a_stack_overflow() { + let depth = 20_000; + let document = format!( + r#"{{"components": {{"schemas": {{"a": {}{}{}}}}}}}"#, + r#"{"properties": {"a": "#.repeat(depth), + "{}", + "}}".repeat(depth), + ); + + assert!(normalize(document.as_bytes(), &Options::default()).is_err()); +} + +#[test] +fn values_that_are_not_read_may_be_nested_as_deep_as_they_like() { + let depth = 200_000; + let document = format!(r#"{{"x-other": {}{}}}"#, "[".repeat(depth), "]".repeat(depth)); + + let normalized = normalize(document.as_bytes(), &Options::default()).unwrap(); + + assert!(!normalized.report.changed()); +} + +#[test] +fn a_document_that_is_broken_where_it_is_edited_is_blamed_for_it() { + // The comma that is missing is between two keys that are rewritten, and + // the scan does not mind, so it is what is written that is not JSON. + let document = operation_with(r#""x-fern-sdk-method-name": "get" "x-fern-sdk-group-name": ["a"],"#); + + let err = normalize(document.as_bytes(), &Options::default()) + .unwrap_err() + .to_string(); + + assert!(err.contains("expected `,` or `}`"), "{err}"); + assert!(err.contains("line 2"), "{err}"); +} + +/// The document is checked as a whole, wherever it is walked and wherever it +/// is not, here `info`, and whether there is something to rewrite in it or not. +#[test] +fn a_document_that_is_not_json_is_an_error_where_there_is_nothing_to_rewrite_in_it() { + let document = br#"{"info": {"title": "t" "version": 1}, "paths": {}}"#; + + let err = normalize(document, &Options::default()).unwrap_err().to_string(); + + assert!(err.contains("expected `,` or `}`"), "{err}"); +} + +/// A long document is checked on another thread, which is the same check. +#[test] +fn a_long_document_is_checked_and_rewritten_as_a_short_one_is() { + let padding = "p".repeat(300_000); + let valid = operation_with(r#""x-fern-sdk-group-name": ["a"], "x-fern-sdk-method-name": "get","#) + .replace("\"t\"", &format!("\"{padding}\"")); + let broken = valid.replace("\"version\"", "\"version\" 1,\"x\""); + let not_utf8 = { + let mut bytes = valid.clone().into_bytes(); + let at = bytes.windows(5).position(|window| window == b"ppppp").unwrap(); + bytes[at] = 0xFF; + bytes + }; + + let (text, report) = edit(&valid, &Options::default()); + + assert!(valid.len() > 1 << 18 && report.changed()); + assert!(text.contains("x-forge-group-name") && !text.contains("x-fern-")); + assert_same(&valid, &text); + assert!(normalize(broken.as_bytes(), &Options::default()).is_err()); + let err = normalize(¬_utf8, &Options::default()).unwrap_err().to_string(); + assert!(err.contains("not UTF-8"), "{err}"); +} + +#[test] +fn a_document_that_is_cut_off_where_it_is_walked_is_an_error() { + let err = normalize(br#"{"openapi": "3.0.3", "paths": {"#, &Options::default()) + .unwrap_err() + .to_string(); + + assert!(err.contains("EOF while parsing"), "{err}"); +} + +#[test] +fn a_document_that_is_not_utf8_is_an_error_when_there_is_something_to_rewrite_in_it() { + let mut document = + operation_with(r#""x-fern-sdk-group-name": ["a"], "x-fern-sdk-method-name": "get","#).into_bytes(); + let at = document.windows(5).position(|window| window == b"3.0.3").unwrap(); + document[at] = 0xFF; + + let err = normalize(&document, &Options::default()).unwrap_err().to_string(); + + assert!(err.contains("not UTF-8"), "{err}"); +} + +/// Needs the released aggregate: `FORGE_SPEC=openapi.forge.json cargo test +/// --release --test compat aggregate -- --ignored --nocapture`. +#[test] +#[ignore = "needs FORGE_SPEC, the path of a large document"] +fn the_aggregate_builds_the_same_forge_normalized() { + let path = std::env::var("FORGE_SPEC").expect("FORGE_SPEC is the path of the document"); + let json = std::fs::read(&path).unwrap(); + let before = super::dump::dump(&serde_json::from_slice(&json).unwrap()); + + for options in [Options::default(), KEEP] { + let normalized = normalize(&json, &options).unwrap(); + let after = super::dump::dump(&serde_json::from_slice(&normalized.json.unwrap().to_vec()).unwrap()); + + let report = normalized.report; + eprintln!( + "{options:?}: {} keys, {} conflicts", + report.migrated.values().sum::(), + report.conflicts.len() + ); + for (key, count) in &report.migrated { + eprintln!(" {key}: {count}"); + } + assert!(before == after, "{options:?}: the dumps differ"); + } +} From cb5f908d117033a893e21f7d0ba90175bbe9842a Mon Sep 17 00:00:00 2001 From: Niels Swimberghe Date: Tue, 27 Oct 2026 19:25:20 -0400 Subject: [PATCH 24/25] feat: forge normalize command `forge normalize [--leave-old-extensions] [--check] [-v] ...` rewrites other tools' extensions into the native ones, in place. FORGE-1. An input is a file, a folder (every .json file below it, except hidden ones and those in a node_modules), a glob pattern, which is taken as it is written, or - for standard input to standard output. - One line for each file that is rewritten, and a summary. `-v` adds how many times each key was rewritten. Conflicts are warnings. - `--check` writes nothing and fails if anything would change, to check that documents are normalized. - Files are rewritten by writing a new file beside them and renaming it, so that none is left half written, and in place where that is not possible, as for a hard link. A file with more than one name is rewritten once, and a link stays a link. - A file that is not valid JSON, is read-only, or is YAML is an error and is left as it is. `normalize` is a subcommand of clap's: `args_conflicts_with_subcommands` lets `forge --target ...` and `forge normalize ...` be told apart. A document that is itself called `normalize` is given as `./normalize`. --- src/bin/forge/main.rs | 84 ++++- src/bin/forge/normalize.rs | 344 +++++++++++++++++ tests/compat/main.rs | 2 + tests/compat/normalize_cli.rs | 692 ++++++++++++++++++++++++++++++++++ 4 files changed, 1110 insertions(+), 12 deletions(-) create mode 100644 src/bin/forge/normalize.rs create mode 100644 tests/compat/normalize_cli.rs diff --git a/src/bin/forge/main.rs b/src/bin/forge/main.rs index 6781dd8ea..85b73d5b3 100644 --- a/src/bin/forge/main.rs +++ b/src/bin/forge/main.rs @@ -3,6 +3,7 @@ //! ```text //! forge openapi.json --target ts --out sdk //! forge openapi.json --target wasm://plugin.wasm --out sdk +//! forge normalize 'specs/*.json' //! ``` use std::fs; @@ -13,21 +14,42 @@ use std::process::ExitCode; use clap::Parser; use forge::{Err, Forge}; +#[cfg(feature = "normalize")] +mod normalize; mod ts; /// Generates an SDK from an OpenAPI document. +/// +/// A document that is named `normalize` is given as `./normalize`. #[derive(Parser)] -#[command(version)] +#[command(version, args_conflicts_with_subcommands = true)] struct Args { + #[cfg(feature = "normalize")] + #[command(subcommand)] + command: Option, /// The OpenAPI JSON document, or - to read it from standard input. - spec: PathBuf, + #[arg(required = true)] + spec: Option, /// The language of the SDK: `ts` for TypeScript, or `wasm://PATH` for /// the plugin compiled to a WebAssembly component at PATH. - #[arg(long, value_parser = Target::parse)] - target: Target, + #[arg(long, value_parser = Target::parse, required = true)] + target: Option, /// The directory to write the SDK to. - #[arg(long)] - out: PathBuf, + #[arg(long, required = true)] + out: Option, +} + +/// What `forge` does other than generate an SDK. +#[cfg(feature = "normalize")] +#[derive(clap::Subcommand)] +enum Command { + /// Rewrite other tools' extensions into the native x-forge-* ones, in place. + /// + /// A document that has none is left as it is. A native key is never + /// replaced: when a document says the same thing twice, the native key is + /// kept, as it is when `forge` reads the document, and the other is + /// reported if it says something else. + Normalize(normalize::Args), } /// What forge generates an SDK with. @@ -68,20 +90,33 @@ fn main() -> ExitCode { } } -/// Generates the SDK that `args` ask for. +/// Does what `args` ask for. fn run(args: Args) -> Result<(), Err> { - match args.target { + #[cfg(feature = "normalize")] + if let Some(Command::Normalize(args)) = args.command { + return normalize::run(args); + } + // They are required, which a command is the only way around. + let (Some(spec), Some(target), Some(out)) = (args.spec, args.target, args.out) else { + unreachable!("clap asks for a spec, a target and an out when there is no command"); + }; + generate(&spec, target, &out) +} + +/// Generates the SDK for the document at `spec`, and writes it to `out`. +fn generate(spec: &Path, target: Target, out: &Path) -> Result<(), Err> { + match target { Target::Ts => { - let forge = read(&args.spec)?; - forge::write(&args.out, forge::run(&forge, &ts::Generator)?) + let forge = read(spec)?; + forge::write(out, forge::run(&forge, &ts::Generator)?) } #[cfg(feature = "wasm")] Target::Wasm(path) => { // Compiling the plugin and reading the document are both slow, // so they run at the same time. - let (plugin, forge) = rayon::join(|| forge::wasm::WasmPlugin::from_file(&path), || read(&args.spec)); + let (plugin, forge) = rayon::join(|| forge::wasm::WasmPlugin::from_file(&path), || read(spec)); let (plugin, forge) = (plugin?, forge?); - forge::write(&args.out, forge::run(&forge, &plugin)?) + forge::write(out, forge::run(&forge, &plugin)?) } } } @@ -104,11 +139,36 @@ fn read(path: &Path) -> Result { #[cfg(test)] mod tests { use clap::CommandFactory; + #[cfg(feature = "normalize")] + use clap::Parser; + #[cfg(feature = "normalize")] + use std::path::Path; use super::Args; + #[cfg(feature = "normalize")] + use super::Command; #[test] fn args() { Args::command().debug_assert(); } + + #[cfg(feature = "normalize")] + #[test] + fn a_command_and_a_document_are_told_apart() { + let parse = |args: &[&str]| Args::try_parse_from(std::iter::once("forge").chain(args.iter().copied())); + + let command = parse(&["normalize", "--check", "a.json"]).unwrap(); + assert!(matches!(command.command, Some(Command::Normalize(_))) && command.spec.is_none()); + + let document = parse(&["./normalize", "--target", "ts", "--out", "sdk"]).unwrap(); + assert!(document.command.is_none() && document.spec.as_deref() == Some(Path::new("./normalize"))); + + // What a command or a document needs is not waived for the other. + assert!(parse(&["normalize"]).is_err()); + assert!(parse(&["a.json"]).is_err()); + assert!(parse(&["normalize", "--target", "ts", "a.json"]).is_err()); + assert!(parse(&["--target", "ts", "normalize", "a.json"]).is_err()); + assert!(parse(&[]).is_err()); + } } diff --git a/src/bin/forge/normalize.rs b/src/bin/forge/normalize.rs new file mode 100644 index 000000000..3bafceb9b --- /dev/null +++ b/src/bin/forge/normalize.rs @@ -0,0 +1,344 @@ +//! `forge normalize`, which rewrites the extensions other tools' metadata is +//! written with into the native ones, in the files it is given. + +use std::collections::HashSet; +use std::fs; +use std::io::{self, Read, Write}; +use std::path::{Path, PathBuf}; + +use forge::Err; +use forge::normalize::{Edited, Normalized, Options, Report, normalize}; +use rayon::prelude::*; + +/// What `forge normalize` is given, which says what it does in `main.rs`. +#[derive(clap::Args)] +pub struct Args { + /// The OpenAPI JSON documents to rewrite, as paths, as folders, which + /// have every `.json` file below them rewritten, except those that are + /// hidden or in a node_modules, or as glob patterns such as + /// 'specs/**/*.json', which are taken as they are written. Or - to read one from standard input and write + /// it to standard output. + #[arg(required = true)] + inputs: Vec, + /// Keep each key that is rewritten, beside the native key that replaces + /// it, for what still reads the old one. + #[arg(long)] + leave_old_extensions: bool, + /// Say what would be rewritten, and write nothing. It fails if there is + /// anything to rewrite, to check that documents are normalized. + #[arg(long)] + check: bool, + /// Say how many times each key was rewritten. + #[arg(short, long)] + verbose: bool, +} + +/// A file to rewrite. +struct Input { + /// The path it was given by, which is how it is named. + shown: PathBuf, + /// The file it is, with every link followed, which is what is read and + /// written: a link stays a link, and a file given twice is rewritten once. + real: PathBuf, +} + +/// How a run says what it did. +#[derive(Clone, Copy)] +struct Say { + check: bool, + verbose: bool, +} + +pub fn run(args: Args) -> Result<(), Err> { + let options = Options { + leave_old_extensions: args.leave_old_extensions, + }; + let say = Say { + check: args.check, + verbose: args.verbose, + }; + if args.inputs.iter().any(|input| input == "-") { + if args.inputs.len() > 1 { + return Err("- cannot be given with other inputs".into()); + } + return filter(&options, say); + } + + let results: Vec<(PathBuf, Result)> = expand(&args.inputs)? + .into_par_iter() + .map(|input| { + let result = rewrite(&input.real, &options, say).map_err(|err| format!("{}: {err}", input.shown.display())); + (input.shown, result) + }) + .collect(); + + let (mut rewritten, mut unchanged, mut failed, mut warnings) = (0, 0, 0, 0); + for (path, result) in results { + match result { + Ok(report) => { + if report.changed() { + rewritten += 1; + } else { + unchanged += 1; + } + warnings += report.conflicts.len(); + print(&path.display().to_string(), &report, say); + } + Err(err) => { + failed += 1; + eprintln!("forge: {err}"); + } + } + } + let total = rewritten + unchanged; + let warned = match warnings { + 0 => String::new(), + n => format!(", {n} {}", plural(n, "warning", "warnings")), + }; + if failed > 0 { + return Err(format!("{failed} {} could not be processed", plural(failed, "file", "files")).into()); + } + if say.check { + if rewritten > 0 { + return Err(format!( + "{rewritten} of {total} {} would be rewritten{warned}", + plural(total, "file", "files") + ) + .into()); + } + eprintln!( + "forge: {total} {} checked, none would be rewritten{warned}", + plural(total, "file", "files") + ); + } else { + eprintln!( + "forge: {total} {}: {rewritten} rewritten, {unchanged} unchanged{warned}", + plural(total, "file", "files") + ); + } + Ok(()) +} + +/// Returns `one` if `n` is 1, and `many` if it is not. +fn plural<'a>(n: usize, one: &'a str, many: &'a str) -> &'a str { + if n == 1 { one } else { many } +} + +/// Rewrites the document read from standard input to standard output. +fn filter(options: &Options, say: Say) -> Result<(), Err> { + let mut json = Vec::new(); + io::stdin().read_to_end(&mut json)?; + let normalized = normalize(&json, options).map_err(|err| format!("stdin: {err}"))?; + if say.check { + print("stdin", &normalized.report, say); + if normalized.report.changed() { + return Err("the document would be rewritten".into()); + } + return Ok(()); + } + let mut out = io::stdout().lock(); + match &normalized.json { + Some(edited) => edited.write_to(&mut out)?, + None => out.write_all(&json)?, + } + // Standard output is held until a line is whole, and what is left at the + // end, which is all of a document that is one line, is not otherwise + // known to have been written. + out.flush()?; + print("stdin", &normalized.report, say); + Ok(()) +} + +/// Returns the files `inputs` name: each is a path, a folder, which is every +/// `.json` file below it, or a glob pattern if it has any of `*?[` and is not +/// a file or a folder by that name, which must match a file. +fn expand(inputs: &[String]) -> Result, Err> { + let mut seen = HashSet::new(); + let mut files = Vec::new(); + for input in inputs { + let found = if Path::new(input).is_file() { + vec![PathBuf::from(input)] + } else if Path::new(input).is_dir() { + let pattern = format!("{}/**/*.json", glob::Pattern::escape(input.trim_end_matches('/'))); + let found = glob_files(&pattern, input, true)?; + if found.is_empty() { + return Err(format!("{input}: no .json files below it").into()); + } + found + } else if input.contains(['*', '?', '[']) { + let found = glob_files(input, input, false)?; + if found.is_empty() { + return Err(format!("{input}: no files match").into()); + } + found + } else { + // Not there, which reading it says. + vec![PathBuf::from(input)] + }; + for shown in found { + let real = fs::canonicalize(&shown).unwrap_or_else(|_| shown.clone()); + if seen.insert(Identity::of(&real)) { + files.push(Input { shown, real }); + } + } + } + Ok(files) +} + +/// Which file a path is, so that a file with more than one name, which a hard +/// link is, is found to be one, and is not rewritten by two threads at once. +#[derive(PartialEq, Eq, Hash)] +enum Identity { + /// The device it is on, and where on it. + #[cfg(unix)] + Inode(u64, u64), + Path(PathBuf), +} + +impl Identity { + fn of(real: &Path) -> Identity { + #[cfg(unix)] + if let Ok(metadata) = fs::metadata(real) { + use std::os::unix::fs::MetadataExt; + + return Identity::Inode(metadata.dev(), metadata.ino()); + } + Identity::Path(real.to_owned()) + } +} + +/// Returns the files the glob `pattern` matches, which came from `input`. A +/// folder that is given has the files below it that are not hidden, and not in +/// a `node_modules`, which is `folder`; a pattern is taken as it is written. +fn glob_files(pattern: &str, input: &str, folder: bool) -> Result, Err> { + let options = glob::MatchOptions { + require_literal_leading_dot: folder, + ..glob::MatchOptions::new() + }; + let mut files = Vec::new(); + for path in glob::glob_with(pattern, options).map_err(|err| format!("{input}: {err}"))? { + let path = path.map_err(|err| format!("{input}: {err}"))?; + let packaged = folder + && path + .strip_prefix(input.trim_end_matches('/')) + .is_ok_and(|below| below.components().any(|part| part.as_os_str() == "node_modules")); + if path.is_file() && !packaged { + files.push(path); + } + } + Ok(files) +} + +/// Rewrites the file at `path`, if it has a key to rewrite, and says what it +/// found, which is all it does when it is only checking. +fn rewrite(path: &Path, options: &Options, say: Say) -> Result { + let yaml = path + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("yml") || extension.eq_ignore_ascii_case("yaml")); + if yaml { + return Err("YAML is not supported yet: convert the document to JSON first".into()); + } + let json = fs::read(path)?; + let Normalized { json, report } = normalize(&json, options)?; + if let Some(edited) = &json + && !say.check + { + if fs::metadata(path)?.permissions().readonly() { + return Err("the file is read-only, so it is not rewritten".into()); + } + write(path, edited)?; + } + Ok(report) +} + +/// Writes `json` to the file at `path`. It is written beside it first, and put +/// in its place, so that it is never left half written. Where a new file cannot +/// take the place of this one, because it has other links, which would be left +/// with the old one, or because its folder cannot be written to, it is written +/// in place. +fn write(path: &Path, edited: &Edited) -> Result<(), Err> { + if linked(path) { + return Ok(write_in_place(path, edited)?); + } + match write_atomically(path, edited) { + Err(err) if err.kind() == io::ErrorKind::PermissionDenied => Ok(write_in_place(path, edited)?), + written => Ok(written?), + } +} + +fn write_in_place(path: &Path, edited: &Edited) -> io::Result<()> { + edited.write_to(&mut fs::File::create(path)?) +} + +/// Returns whether the file at `path` has other names. +#[cfg(unix)] +fn linked(path: &Path) -> bool { + use std::os::unix::fs::MetadataExt; + + fs::metadata(path).is_ok_and(|metadata| metadata.nlink() > 1) +} + +#[cfg(not(unix))] +fn linked(_: &Path) -> bool { + false +} + +fn write_atomically(path: &Path, edited: &Edited) -> io::Result<()> { + let name = path.file_name().map(|name| name.to_string_lossy()).unwrap_or_default(); + let temporary = path.with_file_name(format!(".{name}.forge-normalize-{}", std::process::id())); + let permissions = fs::metadata(path)?.permissions(); + let written = create_private(&temporary) + .and_then(|mut file| { + // Before it has anything in it, so that it is not readable by more + // than the file it replaces is, at any time. + file.set_permissions(permissions)?; + edited.write_to(&mut file) + }) + .and_then(|()| fs::rename(&temporary, path)); + if written.is_err() { + let _ = fs::remove_file(&temporary); + } + written +} + +/// Creates the file at `path`, which is not there, and is not a link to +/// another file that is, and that only its owner can read. +fn create_private(path: &Path) -> io::Result { + let mut options = fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + + options.mode(0o600); + } + options.open(path) +} + +/// Says what was done to the document called `name`, and what was not. +fn print(name: &str, report: &Report, say: Say) { + if report.changed() { + let total: usize = report.migrated.values().sum(); + let did = if say.check { "would rewrite" } else { "rewrote" }; + eprintln!("{name}: {did} {total} {}", plural(total, "key", "keys")); + if say.verbose { + for (key, count) in &report.migrated { + eprintln!(" {key}: {count}"); + } + if report.joined > 0 { + eprintln!( + " {} group {} written as a dotted string", + report.joined, + plural(report.joined, "path", "paths") + ); + } + } + } + for conflict in &report.conflicts { + eprintln!( + "{name}: warning: {}: {} says something other than {}, which is kept", + conflict.location, conflict.key, conflict.native + ); + } +} diff --git a/tests/compat/main.rs b/tests/compat/main.rs index 59b110659..81ae59177 100644 --- a/tests/compat/main.rs +++ b/tests/compat/main.rs @@ -17,4 +17,6 @@ mod fern; mod native_first; #[cfg(feature = "normalize")] mod normalize; +#[cfg(all(feature = "normalize", feature = "cli"))] +mod normalize_cli; mod stainless; diff --git a/tests/compat/normalize_cli.rs b/tests/compat/normalize_cli.rs new file mode 100644 index 000000000..be5520438 --- /dev/null +++ b/tests/compat/normalize_cli.rs @@ -0,0 +1,692 @@ +//! `forge normalize`, the command, on files and on standard input. + +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output, Stdio}; + +use serde_json::Value; + +/// A document with one operation, written on one line, with no final newline. +const FERN: &str = r#"{"openapi":"3.0.3","info":{"title":"t","version":"1"},"paths":{"/a":{"get":{"x-fern-sdk-group-name":["a"],"x-fern-sdk-method-name":"get","responses":{"204":{"description":"ok"}}}}}}"#; + +/// The same document, written native. +const NATIVE: &str = r#"{"openapi":"3.0.3","info":{"title":"t","version":"1"},"paths":{"/a":{"get":{"x-forge-group-name":"a","x-forge-method-name":"get","responses":{"204":{"description":"ok"}}}}}}"#; + +/// A folder of its own to write files in, which is empty. +fn dir(name: &str) -> PathBuf { + let dir = Path::new(env!("CARGO_TARGET_TMPDIR")).join("normalize").join(name); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + dir +} + +/// Runs `forge normalize` with `args`, writing `stdin` to it. +fn normalize(args: &[&str], stdin: &str) -> Output { + forge(&[&["normalize"], args].concat(), stdin) +} + +/// Runs `forge` with `args`, writing `stdin` to it. +fn forge(args: &[&str], stdin: &str) -> Output { + let mut child = Command::new(env!("CARGO_BIN_EXE_forge")) + .args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + // Beside the output being read: a child that has written more than a pipe + // holds is not reading its input until that is read. + let mut input = child.stdin.take().unwrap(); + let stdin = stdin.to_owned(); + // A child that exits before it has read it all is not a failure to write. + let writer = std::thread::spawn(move || input.write_all(stdin.as_bytes())); + let output = child.wait_with_output().unwrap(); + let _ = writer.join().unwrap(); + output +} + +fn text(bytes: &[u8]) -> &str { + std::str::from_utf8(bytes).unwrap() +} + +#[track_caller] +fn succeeded(out: &Output) { + assert!(out.status.success(), "forge failed: {}", text(&out.stderr)); +} + +#[track_caller] +fn failed(out: &Output) -> &str { + assert!(!out.status.success(), "forge succeeded"); + text(&out.stderr) +} + +#[test] +fn a_file_is_rewritten_in_place_as_it_was_written() { + let dir = dir("in-place"); + let file = dir.join("spec.json"); + fs::write(&file, FERN).unwrap(); + + let out = normalize(&[file.to_str().unwrap()], ""); + + succeeded(&out); + assert_eq!( + fs::read_to_string(&file).unwrap(), + NATIVE, + "still on one line, with no final newline" + ); + let err = text(&out.stderr); + assert!(err.contains("rewrote 2 keys"), "{err}"); + assert!( + !err.contains("x-fern-sdk-method-name: 1"), + "the keys are listed only with -v: {err}" + ); + assert!(err.contains("1 file: 1 rewritten, 0 unchanged"), "{err}"); + let leftovers: Vec<_> = fs::read_dir(&dir).unwrap().collect(); + assert_eq!(leftovers.len(), 1, "no file is left beside it"); +} + +#[test] +fn nothing_but_the_keys_that_are_rewritten_changes() { + let dir = dir("untouched"); + let file = dir.join("spec.json"); + // Indented by four spaces, with a number that has more digits than a + // float keeps, another written as an exponent, and escapes. + let before = "{\n \"openapi\": \"3.0.3\",\n \"info\": {\"title\": \"t\\u00e9\\/\", \"version\": \"1\"},\n \"paths\": {\"/a\": {\"get\": {\n \"x-fern-sdk-group-name\": [\"a\"],\n \"x-fern-sdk-method-name\": \"get\",\n \"responses\": {\"200\": {\"description\": \"ok\", \"content\": {\"application/json\": {\"schema\": {\"type\": \"number\", \"example\": 116.11599994199997, \"minimum\": 0.000005, \"maximum\": 1E5}}}}}\n }}}\n}\n"; + fs::write(&file, before).unwrap(); + + succeeded(&normalize(&[file.to_str().unwrap()], "")); + + let after = before + .replace("\"x-fern-sdk-group-name\": [\"a\"]", "\"x-forge-group-name\": \"a\"") + .replace("x-fern-sdk-method-name", "x-forge-method-name"); + assert_eq!(fs::read_to_string(&file).unwrap(), after); +} + +#[test] +fn a_glob_finds_files_in_folders_below_it() { + let dir = dir("glob"); + fs::create_dir_all(dir.join("deep/er")).unwrap(); + for path in ["a.json", "deep/b.json", "deep/er/c.json"] { + fs::write(dir.join(path), FERN).unwrap(); + } + fs::write(dir.join("notes.txt"), FERN).unwrap(); + + let out = normalize(&[&format!("{}/**/*.json", dir.display())], ""); + + succeeded(&out); + assert!(text(&out.stderr).contains("3 rewritten"), "{}", text(&out.stderr)); + for path in ["a.json", "deep/b.json", "deep/er/c.json"] { + assert_eq!(fs::read_to_string(dir.join(path)).unwrap(), NATIVE, "{path}"); + } + assert_eq!(fs::read_to_string(dir.join("notes.txt")).unwrap(), FERN); +} + +#[test] +fn a_file_that_needs_nothing_is_left_as_it_is() { + let dir = dir("native"); + let file = dir.join("spec.json"); + let written = format!("{NATIVE}\n\n"); + fs::write(&file, &written).unwrap(); + + let out = normalize(&[file.to_str().unwrap()], ""); + + succeeded(&out); + assert_eq!(fs::read_to_string(&file).unwrap(), written); + assert!( + text(&out.stderr).contains("1 file: 0 rewritten, 1 unchanged"), + "{}", + text(&out.stderr) + ); +} + +#[test] +fn the_old_keys_can_be_left() { + let dir = dir("leave"); + let file = dir.join("spec.json"); + fs::write(&file, FERN).unwrap(); + + succeeded(&normalize(&["--leave-old-extensions", file.to_str().unwrap()], "")); + + let value: Value = serde_json::from_str(&fs::read_to_string(&file).unwrap()).unwrap(); + let operation = &value["paths"]["/a"]["get"]; + assert_eq!(operation["x-fern-sdk-method-name"], "get"); + assert_eq!(operation["x-forge-method-name"], "get"); +} + +#[test] +fn standard_input_is_written_to_standard_output() { + let out = normalize(&["-"], FERN); + + succeeded(&out); + assert_eq!(text(&out.stdout), NATIVE); +} + +#[test] +fn standard_input_that_needs_nothing_comes_out_as_it_went_in() { + let document = format!(" {NATIVE} \n"); + + let out = normalize(&["-"], &document); + + succeeded(&out); + assert_eq!(text(&out.stdout), document); +} + +#[test] +fn standard_input_cannot_be_given_with_files() { + let out = normalize(&["-", "spec.json"], ""); + + let err = failed(&out); + + assert!(err.contains("- cannot be given with other inputs"), "{err}"); +} + +#[test] +fn a_glob_that_matches_nothing_is_an_error() { + let dir = dir("nothing"); + + let out = normalize(&[&format!("{}/*.json", dir.display())], ""); + + let err = failed(&out); + assert!(err.contains("no files match"), "{err}"); +} + +#[test] +fn a_file_that_is_missing_is_an_error_that_names_it() { + let out = normalize(&["missing.json"], ""); + + let err = failed(&out); + assert!(err.contains("forge: missing.json: "), "{err}"); +} + +#[test] +fn a_file_that_is_not_json_is_an_error_and_the_others_are_still_rewritten() { + let dir = dir("invalid"); + fs::write(dir.join("a.json"), FERN).unwrap(); + // It has to look like it might change, or it is not parsed. + fs::write(dir.join("b.json"), r#"{"x-fern-ignore": "#).unwrap(); + + let out = normalize(&[&format!("{}/*.json", dir.display())], ""); + + let err = failed(&out); + assert!(err.contains("b.json"), "{err}"); + assert!(err.contains("1 file could not be processed"), "{err}"); + assert_eq!(fs::read_to_string(dir.join("a.json")).unwrap(), NATIVE); + assert_eq!(fs::read_to_string(dir.join("b.json")).unwrap(), r#"{"x-fern-ignore": "#); +} + +#[test] +fn a_native_key_wins_and_the_one_that_differs_is_reported() { + let dir = dir("conflict"); + let file = dir.join("spec.json"); + fs::write( + &file, + r#"{"openapi":"3.0.3","info":{"title":"t","version":"1"},"paths":{"/a":{"get":{"x-fern-sdk-method-name":"old","x-forge-method-name":"new","x-forge-group-name":["a"],"responses":{"204":{"description":"ok"}}}}}}"#, + ) + .unwrap(); + + let out = normalize(&[file.to_str().unwrap()], ""); + + succeeded(&out); + let err = text(&out.stderr); + assert!(err.contains("warning"), "{err}"); + assert!( + err.contains("/paths/~1a/get: x-fern-sdk-method-name says something other than x-forge-method-name"), + "{err}" + ); + let written = fs::read_to_string(&file).unwrap(); + assert!(written.contains(r#""x-forge-method-name":"new""#), "{written}"); + assert!(!written.contains("x-fern"), "{written}"); +} + +#[test] +fn a_group_that_cannot_be_a_dotted_string_is_kept_and_nothing_is_said_about_it() { + let dir = dir("kept"); + let file = dir.join("spec.json"); + fs::write(&file, FERN.replace(r#"["a"]"#, r#"["a.b"]"#)).unwrap(); + + let out = normalize(&[file.to_str().unwrap()], ""); + + succeeded(&out); + let err = text(&out.stderr); + assert!( + !err.contains("note") && !err.contains("warning"), + "an array that is kept is expected: {err}" + ); + assert_eq!( + fs::read_to_string(&file).unwrap(), + NATIVE.replace(r#""x-forge-group-name":"a""#, r#""x-forge-group-name":["a.b"]"#), + "the array is kept, and only the keys are renamed" + ); +} + +#[test] +fn what_it_did_is_a_line_for_each_file_and_the_keys_are_listed_with_v() { + let dir = dir("verbose"); + for name in ["a.json", "b.json"] { + fs::write(dir.join(name), FERN).unwrap(); + } + let (a, b) = (dir.join("a.json"), dir.join("b.json")); + + let quiet = normalize(&[a.to_str().unwrap()], ""); + let verbose = normalize(&["-v", b.to_str().unwrap()], ""); + + let (quiet, verbose) = (text(&quiet.stderr), text(&verbose.stderr)); + assert_eq!(quiet.lines().count(), 2, "{quiet}"); + assert!(verbose.contains(" x-fern-sdk-group-name: 1"), "{verbose}"); + assert!(verbose.contains(" x-fern-sdk-method-name: 1"), "{verbose}"); +} + +#[test] +fn check_writes_nothing_and_fails_when_there_is_something_to_rewrite() { + let dir = dir("check"); + let (old, native) = (dir.join("old.json"), dir.join("native.json")); + fs::write(&old, FERN).unwrap(); + fs::write(&native, NATIVE).unwrap(); + + let out = normalize(&["--check", old.to_str().unwrap(), native.to_str().unwrap()], ""); + + let err = failed(&out); + assert!(err.contains("old.json: would rewrite 2 keys"), "{err}"); + assert!(err.contains("forge: 1 of 2 files would be rewritten"), "{err}"); + assert_eq!(fs::read_to_string(&old).unwrap(), FERN, "nothing is written"); +} + +#[test] +fn check_succeeds_when_there_is_nothing_to_rewrite() { + let dir = dir("check-clean"); + let file = dir.join("spec.json"); + fs::write(&file, NATIVE).unwrap(); + + let out = normalize(&["--check", file.to_str().unwrap()], ""); + + succeeded(&out); + assert!( + text(&out.stderr).contains("1 file checked, none would be rewritten"), + "{}", + text(&out.stderr) + ); +} + +#[test] +fn check_on_standard_input_writes_nothing_and_fails_when_there_is_something_to_rewrite() { + let out = normalize(&["--check", "-"], FERN); + + let err = failed(&out); + assert!(err.contains("the document would be rewritten"), "{err}"); + assert!(out.stdout.is_empty(), "nothing is written"); + assert!(normalize(&["--check", "-"], NATIVE).status.success()); +} + +#[test] +fn a_folder_is_every_json_file_below_it() { + let dir = dir("folder"); + fs::create_dir_all(dir.join("deep/er")).unwrap(); + for path in ["a.json", "deep/b.json", "deep/er/c.json"] { + fs::write(dir.join(path), FERN).unwrap(); + } + fs::write(dir.join("notes.txt"), FERN).unwrap(); + fs::write(dir.join("deep/spec.yaml"), "x: 1").unwrap(); + + let out = normalize(&[dir.to_str().unwrap()], ""); + + succeeded(&out); + assert!( + text(&out.stderr).contains("3 files: 3 rewritten"), + "{}", + text(&out.stderr) + ); + for path in ["a.json", "deep/b.json", "deep/er/c.json"] { + assert_eq!(fs::read_to_string(dir.join(path)).unwrap(), NATIVE, "{path}"); + } + assert_eq!(fs::read_to_string(dir.join("notes.txt")).unwrap(), FERN); +} + +#[test] +fn a_folder_leaves_out_what_is_hidden_and_what_is_in_a_node_modules() { + let dir = dir("folder-hidden"); + for path in [".hidden", "node_modules/pkg", "deep/node_modules", "deep/.cache"] { + fs::create_dir_all(dir.join(path)).unwrap(); + } + for path in [ + "a.json", + ".hidden/b.json", + "node_modules/pkg/c.json", + "deep/node_modules/d.json", + "deep/.cache/e.json", + "deep/f.json", + ] { + fs::write(dir.join(path), FERN).unwrap(); + } + + let out = normalize(&[dir.to_str().unwrap()], ""); + + succeeded(&out); + assert!( + text(&out.stderr).contains("2 files: 2 rewritten"), + "{}", + text(&out.stderr) + ); + for path in ["a.json", "deep/f.json"] { + assert_eq!(fs::read_to_string(dir.join(path)).unwrap(), NATIVE, "{path}"); + } + for path in [ + ".hidden/b.json", + "node_modules/pkg/c.json", + "deep/node_modules/d.json", + "deep/.cache/e.json", + ] { + assert_eq!(fs::read_to_string(dir.join(path)).unwrap(), FERN, "{path}"); + } + + // A pattern is taken as it is written, whatever it passes through. + let out = normalize(&[&format!("{}/.hidden/*.json", dir.display())], ""); + succeeded(&out); + assert_eq!(fs::read_to_string(dir.join(".hidden/b.json")).unwrap(), NATIVE); +} + +#[test] +fn a_folder_with_no_json_file_is_an_error() { + let dir = dir("empty-folder"); + fs::write(dir.join("notes.txt"), "x").unwrap(); + + let out = normalize(&[dir.to_str().unwrap()], ""); + + let err = failed(&out); + assert!(err.contains("no .json files below it"), "{err}"); +} + +#[test] +fn a_yaml_file_is_an_error_and_is_not_reported_as_native() { + let dir = dir("yaml"); + for name in ["spec.yml", "spec.YAML"] { + fs::write(dir.join(name), "openapi: 3.0.3\nx-fern-ignore: true\n").unwrap(); + let file = dir.join(name); + + let out = normalize(&[file.to_str().unwrap()], ""); + + let err = failed(&out); + assert!(err.contains("YAML is not supported yet"), "{name}: {err}"); + assert!(err.contains(name), "{name}: {err}"); + assert!(!err.contains("unchanged"), "{name}: {err}"); + } +} + +#[test] +fn a_file_that_is_not_an_object_is_an_error_whatever_it_is_named() { + let dir = dir("not-an-object"); + for (name, content) in [ + ("yaml.json", "openapi: 3.0.3\nx-fern-ignore: true\n"), + ("array.json", "[]"), + ("empty.json", ""), + ("blank.json", " \n"), + ] { + fs::write(dir.join(name), content).unwrap(); + let file = dir.join(name); + + let out = normalize(&[file.to_str().unwrap()], ""); + + let err = failed(&out); + assert!(err.contains(name), "{name}: {err}"); + assert!(!err.contains("unchanged"), "{name}: {err}"); + assert_eq!(fs::read_to_string(&file).unwrap(), content, "{name} is left as it is"); + } +} + +#[test] +fn the_usage_says_forge_normalize_and_the_help_of_forge_lists_the_command() { + let help = normalize(&["--help"], ""); + let general = forge(&["--help"], ""); + + assert!( + text(&help.stdout).contains("Usage: forge normalize"), + "{}", + text(&help.stdout) + ); + assert!(text(&help.stdout).contains("--check"), "{}", text(&help.stdout)); + assert!( + text(&general.stdout).contains("Commands:") && text(&general.stdout).contains(" normalize "), + "{}", + text(&general.stdout) + ); + assert!( + text(&general.stdout).contains("forge "), + "{}", + text(&general.stdout) + ); + let missing = normalize(&[], ""); + assert!(failed(&missing).contains("Usage: forge normalize")); +} + +#[cfg(unix)] +#[test] +fn a_link_stays_a_link_and_the_file_it_points_to_is_rewritten() { + let dir = dir("symlink"); + fs::create_dir_all(dir.join("real")).unwrap(); + fs::create_dir_all(dir.join("links")).unwrap(); + fs::write(dir.join("real/spec.json"), FERN).unwrap(); + std::os::unix::fs::symlink("../real/spec.json", dir.join("links/spec.json")).unwrap(); + + let out = normalize(&[dir.join("links/spec.json").to_str().unwrap()], ""); + + succeeded(&out); + assert!( + fs::symlink_metadata(dir.join("links/spec.json")) + .unwrap() + .file_type() + .is_symlink(), + "the link was replaced by a file" + ); + assert_eq!(fs::read_to_string(dir.join("real/spec.json")).unwrap(), NATIVE); + assert!( + text(&out.stderr).contains("links/spec.json: rewrote 2 keys"), + "{}", + text(&out.stderr) + ); +} + +#[test] +fn a_file_given_more_than_once_is_rewritten_once() { + let dir = dir("twice"); + let file = dir.join("spec.json"); + fs::write(&file, FERN).unwrap(); + let again = format!("{}/./spec.json", dir.display()); + + for _ in 0..5 { + fs::write(&file, FERN).unwrap(); + let out = normalize( + &[file.to_str().unwrap(), &again, &format!("{}/*.json", dir.display())], + "", + ); + + succeeded(&out); + assert!( + text(&out.stderr).contains("1 file: 1 rewritten"), + "{}", + text(&out.stderr) + ); + assert_eq!(fs::read_to_string(&file).unwrap(), NATIVE); + } +} + +#[test] +fn a_file_whose_name_has_glob_characters_is_that_file() { + let dir = dir("brackets"); + let file = dir.join("spec[1].json"); + fs::write(&file, FERN).unwrap(); + + let out = normalize(&[file.to_str().unwrap()], ""); + + succeeded(&out); + assert_eq!(fs::read_to_string(&file).unwrap(), NATIVE); +} + +#[cfg(unix)] +#[test] +fn a_read_only_file_is_not_rewritten() { + use std::os::unix::fs::PermissionsExt; + + let dir = dir("read-only"); + let file = dir.join("spec.json"); + fs::write(&file, FERN).unwrap(); + fs::set_permissions(&file, fs::Permissions::from_mode(0o444)).unwrap(); + + let out = normalize(&[file.to_str().unwrap()], ""); + let check = normalize(&["--check", file.to_str().unwrap()], ""); + + let err = failed(&out); + assert!( + err.contains("spec.json: the file is read-only, so it is not rewritten"), + "{err}" + ); + assert_eq!(fs::read_to_string(&file).unwrap(), FERN); + assert!( + text(&check.stderr).contains("would rewrite 2 keys"), + "a check only reads: {}", + text(&check.stderr) + ); +} + +#[test] +fn a_byte_order_mark_is_named_and_the_file_is_left_as_it_is() { + let dir = dir("bom"); + let file = dir.join("spec.json"); + let content = format!("\u{feff}{FERN}"); + fs::write(&file, &content).unwrap(); + + let out = normalize(&[file.to_str().unwrap()], ""); + + let err = failed(&out); + assert!( + err.contains("spec.json: the document starts with a byte order mark"), + "{err}" + ); + assert_eq!(fs::read_to_string(&file).unwrap(), content); +} + +#[test] +fn a_key_written_twice_is_an_error_that_says_where() { + let dir = dir("duplicate"); + let file = dir.join("spec.json"); + fs::write( + &file, + "{\"openapi\":\"3.0.3\",\"info\":{\"title\":\"t\",\"version\":\"1\"},\"paths\":{\"/a\":{\"get\":{\n \"x-fern-sdk-group-name\":[\"a\"],\n \"x-fern-sdk-method-name\":\"get\",\n \"x-fern-sdk-method-name\":\"again\"}}}}", + ) + .unwrap(); + + let out = normalize(&[file.to_str().unwrap()], ""); + + let err = failed(&out); + assert!( + err.contains("spec.json: line 4, column 3: x-fern-sdk-method-name is written twice in this object"), + "{err}" + ); +} + +#[test] +fn the_summary_counts_the_warnings() { + let dir = dir("warnings"); + let file = dir.join("spec.json"); + fs::write( + &file, + r#"{"openapi":"3.0.3","info":{"title":"t","version":"1"},"paths":{"/a":{"get":{"x-fern-sdk-method-name":"old","x-forge-method-name":"new","x-forge-group-name":"a","responses":{"204":{"description":"ok"}}}}}}"#, + ) + .unwrap(); + + let out = normalize(&[file.to_str().unwrap()], ""); + + succeeded(&out); + assert!( + text(&out.stderr).contains("1 file: 1 rewritten, 0 unchanged, 1 warning"), + "{}", + text(&out.stderr) + ); +} + +#[test] +fn standard_input_is_called_stdin_and_a_failure_on_it_says_so() { + let out = normalize(&["-"], FERN); + let broken = normalize(&["-"], "{\"x-fern-ignore\": "); + + assert!( + text(&out.stderr).starts_with("stdin: rewrote 2 keys"), + "{}", + text(&out.stderr) + ); + assert!(failed(&broken).contains("forge: stdin: "), "{}", text(&broken.stderr)); +} + +#[test] +fn v_says_how_many_group_paths_were_joined() { + let dir = dir("joined"); + let file = dir.join("spec.json"); + fs::write(&file, FERN).unwrap(); + + let out = normalize(&["-v", file.to_str().unwrap()], ""); + + assert!( + text(&out.stderr).contains(" 1 group path written as a dotted string"), + "{}", + text(&out.stderr) + ); +} + +#[cfg(unix)] +#[test] +fn a_hard_link_stays_a_link_to_the_file_that_is_rewritten() { + let dir = dir("hard-link"); + let (a, b) = (dir.join("a.json"), dir.join("b.json")); + fs::write(&a, FERN).unwrap(); + fs::hard_link(&a, &b).unwrap(); + + succeeded(&normalize(&[a.to_str().unwrap()], "")); + + assert_eq!(fs::read_to_string(&a).unwrap(), NATIVE); + assert_eq!( + fs::read_to_string(&b).unwrap(), + NATIVE, + "the other name is the same file, which is rewritten" + ); +} + +#[cfg(unix)] +#[test] +fn two_names_of_one_file_are_one_file_and_are_rewritten_once() { + let dir = dir("hard-links-given"); + let (a, b) = (dir.join("a.json"), dir.join("b.json")); + fs::write(&a, FERN).unwrap(); + fs::hard_link(&a, &b).unwrap(); + + let out = normalize(&[a.to_str().unwrap(), b.to_str().unwrap()], ""); + + succeeded(&out); + assert!( + text(&out.stderr).contains("1 file: 1 rewritten"), + "{}", + text(&out.stderr) + ); + assert_eq!(fs::read_to_string(&b).unwrap(), NATIVE); +} + +#[cfg(unix)] +#[test] +fn a_file_in_a_folder_that_cannot_be_written_to_is_rewritten_in_place() { + use std::os::unix::fs::PermissionsExt; + + let dir = dir("read-only-folder"); + let file = dir.join("spec.json"); + fs::write(&file, FERN).unwrap(); + fs::set_permissions(&dir, fs::Permissions::from_mode(0o555)).unwrap(); + // The folder cannot be written to, unless this is run as the owner of + // everything, which it is not a test of. + if fs::write(dir.join("probe"), "").is_ok() { + fs::set_permissions(&dir, fs::Permissions::from_mode(0o755)).unwrap(); + return; + } + + let out = normalize(&[file.to_str().unwrap()], ""); + + fs::set_permissions(&dir, fs::Permissions::from_mode(0o755)).unwrap(); + succeeded(&out); + assert_eq!(fs::read_to_string(&file).unwrap(), NATIVE); +} From d8e6715db2c1aa3402db5382cc9b6f3cb678026d Mon Sep 17 00:00:00 2001 From: Niels Swimberghe Date: Tue, 27 Oct 2026 19:25:20 -0400 Subject: [PATCH 25/25] docs: document forge normalize The package README says how to use it and what it leaves alone, and the compatibility layer's README says how it is built on the adapter tables, what it does that is not a key becoming its native key, and what to delete to remove it with the layer. --- AGENTS.md | 1 + packages/forge/README.md | 46 ++++++++++++++++++++++++++++++++++++++++ src/compat/README.md | 25 ++++++++++++++++++++-- 3 files changed, 70 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 39e758679..e8a63a4a6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,6 +17,7 @@ Only `src/compat/` knows vendor extensions (`x-fern-*`, `x-stainless-*`). It is - Never name a vendor extension outside `src/compat/` and `tests/compat/`, comments and docs included. `tests/boundary.rs` enforces this (`just boundary`). - Compat is behind the default-on `compat` feature and is only a fallback: native `x-forge-*` keys always win. - Support for a new vendor key means mapping it onto an existing native primitive in an adapter. Add the native key first if there is none. +- `forge normalize` rewrites vendor keys to native ones using the same adapter tables, so it needs no list of its own. - The docs packages (`astro-fern`, `fern-forge`, `cloudflare-api-site`) read vendor keys themselves and are not covered by the boundary. ## Conventions diff --git a/packages/forge/README.md b/packages/forge/README.md index 39bc2163f..412422a3d 100644 --- a/packages/forge/README.md +++ b/packages/forge/README.md @@ -16,6 +16,52 @@ npx @cloudflare/forge openapi.json --target ts --out sdk Pass `-` instead of a path to read the document from standard input. Run `forge --help` for every option. +## Normalizing a document + +Forge's own metadata is the `x-forge-*` extensions. Documents that were +written for other tools carry those tools' extensions instead, and +`forge normalize` rewrites them into the native ones: + +```sh +npx forge normalize 'specs/**/*.json' +``` + +(To generate from a document that is itself called `normalize`, write it as +`./normalize`.) + +It takes paths, folders (every `.json` file below them, except hidden ones and +those in a `node_modules`) and quoted glob patterns, which are taken as they +are written, and rewrites each file in place. It prints a line for each file +it rewrites to standard error, and `-v` adds how many times each key was +rewritten. A file with +nothing to rewrite is left as it is. A link is followed, and stays a link, and +so does a hard link; a file that is given twice is rewritten once; and a file +that is read-only is left as it is, with an error. Documents have to be JSON: +YAML is not supported yet, and neither is a byte order mark. A file that is +not valid JSON is an error, and is left as it is. + +Only the keys change: the rest of the file, including its whitespace, the +order of its keys and the text of every number, stays byte for byte as it was, +so a diff of the file is the keys. The one value that is written again is a +group path, which becomes the names joined by dots (`["a", "b"]` becomes +`"a.b"`); a path that a dotted string cannot say, such as a name with a dot in +it, is kept as it was. A key that no native key says, such as a skip that is a list +of targets, is left exactly as it is written, whichever mode is used. Native +keys always win: when a document +says the same thing twice, the native key is kept, and the other is reported +as a warning if it says something else. + +```sh +# Keep the old keys beside the native ones, for what still reads them. +npx forge normalize --leave-old-extensions openapi.json + +# Write nothing, and fail if anything would be rewritten: for a check in CI. +npx forge normalize --check specs + +# Read from standard input, write to standard output, and generate from that. +cat openapi.json | npx forge normalize - | npx forge - --target ts --out sdk +``` + ## Plugins A plugin generates an SDK, or anything else, from the document: forge parses diff --git a/src/compat/README.md b/src/compat/README.md index 6ebaee086..477600234 100644 --- a/src/compat/README.md +++ b/src/compat/README.md @@ -28,6 +28,22 @@ Everything below follows from that. `mod.rs` is the only way in: one resolver per kind of object, and one name lookup per kind so errors can name the key the author wrote. +## `forge normalize` + +`forge normalize` (`src/normalize/`) runs these tables as a rewrite instead +of a read: it finds a key with the same lookups the reader uses, at the same +kinds of object, and writes the native key that says the same thing. There is +no second list of keys, so a key added to an adapter is normalized too, and a +key the reader does not read on an object is not rewritten there. Its tests +are in `tests/compat/`, with the other vendor-keyed documents. + +One thing it does is not a key becoming its native key: a group path that +is an array becomes the names joined by dots, unless a name has a dot in it. +And what no native key says is left exactly as it is written, in both modes, +and is not counted or reported: a skip that is a list of targets, which a +native skip, that is for every target, does not say. A rewrite does not drop +what only the old key says. + ## Adding a vendor key 1. Find the native primitive it stands for in `src/metadata/`. If there is @@ -36,12 +52,17 @@ Everything below follows from that. 2. Map it in the adapter for its tool, or add an adapter next to `fern.rs` and `stainless.rs` and register it in `mod.rs`. 3. Add a test in `tests/compat/` showing that the vendor-keyed document - parses to the same `Forge` as the native-keyed one. + parses to the same `Forge` as the native-keyed one. `forge normalize` + picks the key up without a change, and rewrites it to the native key. ## Removing the layer Delete this folder and `tests/compat/`, `mod compat` in `src/lib.rs`, the `compat` arms in `src/link/resolve.rs`, and the `compat` feature and its -`[[test]]` entry in `Cargo.toml`. No native extension changes. A few +`[[test]]` entry in `Cargo.toml`. `forge normalize` is built on these tables: +it goes too, which is `src/normalize/`, `mod normalize` in `src/lib.rs`, +`src/bin/forge/normalize.rs`, `Command` in `src/bin/forge/main.rs`, and the +`normalize` feature, the `glob` and `memchr` dependencies and the CLI tests +of `tests/compat/`. No native extension changes. A few native-side items exist only for the layer and are left unused; removing them is a mechanical follow-up.