From c012837d5877e2912d805f4c6040a010ec31adab Mon Sep 17 00:00:00 2001 From: Niels Swimberghe Date: Tue, 27 Oct 2026 21:05:21 -0400 Subject: [PATCH 01/20] feat!: forge reads native extension keys only, and forge normalize rewrites the rest `forge` reads a native `x-forge-*` vocabulary and nothing else. The extensions other tools write (`x-fern-*`, `x-stainless-skip`, `x-sensitive`) are ordinary extensions to it, and a new command, `forge normalize`, rewrites them into the native ones, so the spec that is produced is itself what gets transformed. FORGE-1. The native vocabulary: `x-forge-method-name`, `x-forge-group-name` (a dotted string or an array), `x-forge-skip`, `x-forge-availability`, `x-forge-model-name`, `x-forge-property-name`, `x-forge-parameter-name` and `x-forge-sensitive`, kept by `src/metadata/` per kind of object, alongside `x-forge-hidden`, `x-forge-require-confirmation`, `x-forge-aliases` and `x-forge-commands`. The reader asks it for a key and finds one written twice; there is no fallback and no ranking between keys. `forge normalize [--leave-old-extensions] [--check] [-v] ...` takes files, folders (every .json file below them, except hidden ones and those in a node_modules), glob patterns, or - for standard input to standard output, and rewrites them in place. - It edits the bytes of the document and does not write it again, so whitespace, the order of keys and the text of every number stay as they were, and a diff is only the keys. It walks the document once with no tree, and checks it to be JSON on a second thread. - A key is rewritten only at the kind of object `forge` reads its native key at, so it never gets a meaning it did not have. The tables of other tools' keys are `src/normalize/vendor/`, per level, the only code that names them; a boundary test enforces it. - A native key is never replaced: when a document says the same thing twice, the native key is kept, and the other is dropped, or 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. What no native key says, such as a skip that is a list of targets, is left as it is written. - What it cannot rewrite, a key written twice or a value that cannot be read, is left and warned about, with where a parser would stop. - Files are replaced by writing a new one beside them and renaming it, or 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. - `--check` writes nothing and fails if anything would change. RFC 0001 describes the design, and AGENTS.md says that compatibility with other tools' extensions is sectioned off in `src/normalize/vendor/`. Breaking: a document written with other tools' keys builds only what its native keys say until it has been through `forge normalize`. --- AGENTS.md | 10 + Cargo.lock | 8 + Cargo.toml | 14 +- justfile | 4 + packages/forge/README.md | 48 + packages/forge/plugin/forge.wit | 2 +- packages/forge/src/index.ts | 6 +- rfcs/0001-metadata-resolution-layer.md | 76 ++ src/bin/forge/main.rs | 84 +- src/bin/forge/normalize.rs | 356 ++++++++ src/lib.rs | 46 +- src/link.rs | 58 +- src/link/extension.rs | 55 ++ src/link/openapi.rs | 311 ++++--- src/metadata/mod.rs | 399 ++++++++ src/normalize/mod.rs | 225 +++++ src/normalize/scan.rs | 678 ++++++++++++++ src/normalize/vendor/README.md | 41 + src/normalize/vendor/fern.rs | 87 ++ src/normalize/vendor/mod.rs | 61 ++ src/normalize/vendor/stainless.rs | 31 + src/normalize/vendor/tests.rs | 258 ++++++ src/normalize/vendor/unprefixed.rs | 22 + src/normalize/walk.rs | 509 +++++++++++ src/plugin/ts/tests.rs | 6 +- src/plugin/wasm.rs | 2 +- src/types.rs | 20 +- tests/boundary.rs | 124 +++ tests/cli.rs | 2 +- tests/normalize/common.rs | 154 ++++ tests/normalize/fern.rs | 323 +++++++ tests/normalize/main.rs | 21 + tests/normalize/native_first.rs | 337 +++++++ tests/normalize/normalize.rs | 1169 ++++++++++++++++++++++++ tests/normalize/normalize_cli.rs | 699 ++++++++++++++ tests/normalize/stainless.rs | 104 +++ tests/parse/aliases.rs | 104 ++- tests/parse/bodies.rs | 28 +- tests/parse/common.rs | 4 +- tests/parse/document.rs | 4 +- tests/parse/extensions.rs | 34 +- tests/parse/groups.rs | 192 ++-- tests/parse/headers.rs | 58 +- tests/parse/methods.rs | 81 +- tests/parse/models.rs | 46 +- tests/parse/requests.rs | 144 +-- 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 | 37 +- tests/parse/types/refs.rs | 24 +- tests/parse/types/scalars.rs | 6 +- tests/parse/types/unions.rs | 12 +- tests/wasm.rs | 24 +- tests/wasm/ts/wit/forge.wit | 2 +- 57 files changed, 6633 insertions(+), 547 deletions(-) create mode 100644 rfcs/0001-metadata-resolution-layer.md create mode 100644 src/bin/forge/normalize.rs create mode 100644 src/link/extension.rs create mode 100644 src/metadata/mod.rs create mode 100644 src/normalize/mod.rs create mode 100644 src/normalize/scan.rs create mode 100644 src/normalize/vendor/README.md create mode 100644 src/normalize/vendor/fern.rs create mode 100644 src/normalize/vendor/mod.rs create mode 100644 src/normalize/vendor/stainless.rs create mode 100644 src/normalize/vendor/tests.rs create mode 100644 src/normalize/vendor/unprefixed.rs create mode 100644 src/normalize/walk.rs create mode 100644 tests/boundary.rs create mode 100644 tests/normalize/common.rs create mode 100644 tests/normalize/fern.rs create mode 100644 tests/normalize/main.rs create mode 100644 tests/normalize/native_first.rs create mode 100644 tests/normalize/normalize.rs create mode 100644 tests/normalize/normalize_cli.rs create mode 100644 tests/normalize/stainless.rs diff --git a/AGENTS.md b/AGENTS.md index 432588972..34b11c094 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 rewritten into them by `forge normalize` (`src/normalize/`). + +## Vendor extensions + +Compatibility with other tools' extensions (`x-fern-*`, `x-stainless-*`, and `x-sensitive`) is sectioned off into its own area, `src/normalize/vendor/`, and nowhere else in the codebase knows them. `forge` does not read them: they are ordinary extensions to it, and `forge normalize` is what rewrites them into native ones. Any new compatibility key belongs in that area. See [`src/normalize/vendor/README.md`](src/normalize/vendor/README.md). + +- Never name a vendor extension outside `src/normalize/vendor/` and `tests/normalize/`, comments and docs included. `tests/boundary.rs` enforces this (`just boundary`). +- Run `forge normalize --leave-old-extensions` on a vendor-keyed document before `forge` reads it. The old keys stay for tools that still read them. +- 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/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 184531c7a..3ed6539c6 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,10 +25,14 @@ name = "parse" harness = false [features] -default = ["cli"] +default = ["cli", "normalize"] +# `forge::normalize`, and `forge normalize` when the `cli` feature is on: it +# rewrites the extensions other tools' metadata is written with into the +# native `x-forge-*` ones, which are the only ones `forge` reads. +normalize = ["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"] @@ -49,6 +55,10 @@ required-features = ["cli"] name = "wasm" required-features = ["cli", "wasm"] +[[test]] +name = "normalize" +required-features = ["normalize"] + [lints.clippy] # https://rust-lang.github.io/rust-clippy/rust-1.88.0/index.html diff --git a/justfile b/justfile index 77cc872cf..3254deb27 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 vendor tables of `forge normalize` +boundary: + cargo test --locked --test boundary + # Release builds # # Every way of getting release binaries puts them in target/binaries// diff --git a/packages/forge/README.md b/packages/forge/README.md index 39bc2163f..07eecf1d1 100644 --- a/packages/forge/README.md +++ b/packages/forge/README.md @@ -16,6 +16,54 @@ 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, and they are the only +ones it reads. Documents that were written for other tools carry those tools' +extensions instead, and `forge normalize` rewrites them into the native ones. +Run it before forge reads the document, with `--leave-old-extensions` if +something else still reads the old keys: + +```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/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..d40d60c63 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'; /** @@ -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/rfcs/0001-metadata-resolution-layer.md b/rfcs/0001-metadata-resolution-layer.md new file mode 100644 index 000000000..c832192e2 --- /dev/null +++ b/rfcs/0001-metadata-resolution-layer.md @@ -0,0 +1,76 @@ +# RFC 0001: Native metadata, and `forge normalize` + +## Summary + +`forge` reads a native `x-forge-*` vocabulary and nothing else. The extensions other tools write (`x-fern-*`, `x-stainless-skip`, `x-sensitive`) are ordinary extensions to it. A separate command, `forge normalize`, rewrites them into the native ones, so the spec that is produced is itself what gets transformed, and the compatibility knowledge is a layer of its own. + +## Motivation + +Vendor names were hardcoded in the readers, in value formats, in error messages, in the keys kept for plugins and in the public docs, so everything downstream inherited a vendor's vocabulary, and adding a vendor meant touching all of it. Reading them at runtime, even as a fallback, also means a ranking between keys and a vendor key that silently diverges from the native key beside it. Rewriting them in an explicit step makes the change visible as a diff, and reports a conflict. + +## Scope + +- `forge` reads native keys only: no tiers, no fallback, no feature to turn on. +- Nothing outside `src/normalize/vendor/` and `tests/normalize/` names a vendor extension, enforced by a test. +- A document, after `forge normalize`, builds what it built before. + +Not in scope: Fern's structural behavior (allOf/anyOf, inline request types), operation ID prefixing, and the docs packages, which read vendor keys themselves. Vendor keys with no Forge primitive are neither read nor rewritten. + +## The native vocabulary + +| Native key | Value | Meaning | Rewritten from | +| ------------------------ | --------------------------------------------------------------- | ------------------------------------------------- | ---------------------------------------------------------- | +| `x-forge-method-name` | string | the method's name | `x-fern-sdk-method-name` | +| `x-forge-group-name` | dotted string, or array of strings | the group path; an empty path makes no method | `x-fern-sdk-group-name` | +| `x-forge-skip` | bool | operation or alias: no method; property: no field | `x-fern-ignore` (operation), `x-stainless-skip` (property) | +| `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 value is a secret, to mask | `x-sensitive`, which other systems write too | + +`x-forge-hidden`, `x-forge-require-confirmation`, `x-forge-aliases` and `x-forge-commands` are unchanged. + +- Explicit keys, not one positional name key: tools that inline `$ref` targets would make a positional key silently rename fields. +- `skip`, not `ignore`: it says what happens, and stands apart from `x-forge-hidden`. One word covers operations and properties. +- Groups are a dotted string or an array, so a move from Cloudflare's dotted strings is a rename. A string is names separated by dots; an array is the names, which are not split. FORGE-3 expects to rename the key to `x-forge-namespace`. +- A native key at the wrong level is ignored. Validating later only tightens. + +## What `forge normalize` does + +- A key is rewritten only at the kind of object `forge` reads its native key at: an operation or an alias, a parameter, or a schema. +- The document is edited as bytes, not written again: whitespace, key order and the text of every number stay as they were, so a diff is only the keys. +- A native key is never replaced. When a document says the same thing twice, the native key is kept, and the other is dropped, or 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. +- What no native key says, such as a skip that is a list of targets, is left as it is written. +- `--leave-old-extensions` keeps each key that is rewritten, beside the native one, for what still reads it. + +## Architecture + +``` +src/ +├─ metadata/ the native table, per kind of object: definition, +│ parameter, schema, document. Pure: `&str` in, small +│ values out. +├─ link/ reads native keys through `metadata`. +└─ normalize/ + ├─ vendor/ the tables of other tools' keys. The only code holding + │ them, sectioned off, and per level. + ├─ walk.rs where a key is to be rewritten, and to what. + └─ scan.rs reading JSON as bytes, and editing it where it is read. +``` + +`normalize` is a default-on feature; the engine builds without it. + +## Migration + +- Run `forge normalize` before `forge` reads a document written with vendor keys, with `--leave-old-extensions` where something else still reads them. A pipeline is `cat spec.json | forge normalize - | forge - --target ts --out sdk`. +- A document that is not normalized is not an error: `forge` finds no method names in it, and so no methods. +- Keys that are left are ordinary extensions, so plugins see them. + +## Alternatives considered + +- **A runtime fallback behind a feature**, native keys first and vendor keys for what has none: it costs tiers in the reader and a vendor key that diverges from the native one beside it. Rejected for an explicit rewrite. +- **Resolving after parsing, in the engine**: it must retain every extension of a large document. +- **Normalizing in memory before every `generate`**: a pass over the document on every run, to save one in CI. +- **A `Vendor` trait** per tool: no primitive overlaps across vendors, so the table only grows rows. 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..f63ea322d --- /dev/null +++ b/src/bin/forge/normalize.rs @@ -0,0 +1,356 @@ +//! `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() + report.left.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 in the document called `name` could not be rewritten, or was +/// dropped for a native key that says something else. +fn warn(name: &str, report: &Report) { + for conflict in &report.conflicts { + eprintln!( + "{name}: warning: {}: {} says something other than {}, which is kept", + conflict.location, conflict.key, conflict.native + ); + } + for left in &report.left { + eprintln!( + "{name}: warning: {}: {} {}, and is left as it is", + left.location, left.key, left.why + ); + } +} + +/// 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") + ); + } + } + } + warn(name, report); +} diff --git a/src/lib.rs b/src/lib.rs index 55e1611d5..5050cdac1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,8 +5,11 @@ use std::{iter, ptr}; mod arena; mod json; mod link; +mod metadata; mod method; mod name; +#[cfg(feature = "normalize")] +pub mod normalize; mod plugin; mod types; pub use json::Json; @@ -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-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. @@ -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-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 @@ -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-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 @@ -261,12 +264,13 @@ 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-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-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) } @@ -314,7 +318,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 +327,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 +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-fern-ignore`. Unlike `x-fern-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 @@ -384,7 +388,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 +498,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-name`, + /// `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 other keys forge does not read - /// are not kept. An extension written more than once is there once, + /// `x-forge-aliases`, are left out. Other keys forge does not read, such as + /// the extensions other tools write, are kept, and `forge normalize` is + /// what rewrites those it knows into the ones forge reads. 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 +594,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 +612,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 +652,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..2afe77f36 100644 --- a/src/link.rs +++ b/src/link.rs @@ -6,6 +6,7 @@ //! `$ref`s, builds the types of each method's parameters, body and //! responses, and makes the schemas they use models as they are reached. +mod extension; mod openapi; mod read; @@ -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,42 @@ impl<'d> Builder<'d> { http_method: HttpMethod, ) { let doc = self.doc; - if definition.ignore { + if definition.skip.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", + extension::definition_name(definition.group_names.slot) + )); 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", + extension::definition_name(definition.method_name.slot) + )); 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", + extension::definition_name(definition.availability.slot) )); } } @@ -950,7 +959,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 +1276,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 @@ -2006,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. @@ -2162,7 +2158,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 +2208,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/extension.rs b/src/link/extension.rs new file mode 100644 index 000000000..85fb3f244 --- /dev/null +++ b/src/link/extension.rs @@ -0,0 +1,55 @@ +//! The native extension keys of the objects in [`openapi`](super::openapi): +//! how to name one from its slot, and how to find one written twice. +//! +//! What a key says is [`metadata`]'s to say. 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}; + +/// 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::definition::NAMES[usize::from(slot)] + } +} + +impl Kind for ParameterField { + fn name(slot: u8) -> &'static str { + metadata::parameter::NAMES[usize::from(slot)] + } +} + +impl Kind for SchemaField { + fn name(slot: u8) -> &'static str { + metadata::schema::NAMES[usize::from(slot)] + } +} + +impl Kind for DocumentField { + fn name(slot: u8) -> &'static str { + metadata::document::NAMES[usize::from(slot)] + } +} + +/// 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. +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(F::name(hit.slot))); + } + *seen |= bit; + Ok(()) +} + +/// 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/link/openapi.rs b/src/link/openapi.rs index ba7fa4906..dc2341c02 100644 --- a/src/link/openapi.rs +++ b/src/link/openapi.rs @@ -23,10 +23,12 @@ use std::marker::PhantomData; use serde::Deserialize; use serde::de::{self, DeserializeSeed, Deserializer, IgnoredAny, MapAccess, SeqAccess, Visitor}; +use super::extension; use super::read::{first, keys}; use crate::Name; use crate::arena::{Id, List, Span, Str, Strings}; use crate::json::{self, Json}; +use crate::metadata::{self, DefinitionField, DocumentField, 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 +238,43 @@ 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. +#[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, +} + +impl Val { + /// Keeps `value`, which `hit` gave. No other key says the same thing, and + /// one written twice is an error before it gets here. + fn set(&mut self, hit: &Hit, value: T) { + self.value = value; + self.slot = hit.slot; + } +} + /// 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) skip: 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,7 +299,7 @@ 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, } @@ -396,17 +415,16 @@ 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 `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. + /// Whether a property's schema is marked to be left out: it makes no + /// field. pub(super) skip: bool, } @@ -851,8 +869,8 @@ 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. +/// 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 Read for GroupNames { @@ -1036,6 +1054,49 @@ 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. 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 if v.starts_with("x-") => 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 +1104,11 @@ keys! { Servers = "servers", Paths = "paths", Components = "components", - Commands = "x-forge-commands", } } +entry!(DocumentEntry, DocumentKey, DocumentField, metadata::document::read); + struct DocumentVisitor<'a>(&'a mut Doc); impl<'de> Visitor<'de> for DocumentVisitor<'_> { @@ -1059,7 +1121,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) => { + extension::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 +1144,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 +1254,36 @@ 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. 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); + } + DefinitionField::Group => { + let names = map.next_value_seed(seed::(doc))?; + self.group_names.set(&hit, names); + } + DefinitionField::Skip => { + let skip = map.next_value()?; + self.skip.set(&hit, skip); } - DefinitionKey::Availability => { - self.availability = Some(map.next_value_seed(seed::(doc))?); + 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 +1306,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) => { + extension::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 +1416,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 +1433,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 metadata::definition::read(v) { + Some(hit) => AliasField::Definition(hit), + None if metadata::is_aliases(v) => AliasField::Other, + None => AliasField::Extension(v.to_owned()), }) } } @@ -1380,7 +1450,6 @@ impl<'de> Deserialize<'de> for AliasField { keys! { OperationKey { - Aliases = "x-forge-aliases", Summary = "summary", Description = "description", Deprecated = "deprecated", @@ -1404,9 +1473,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) => { + extension::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 +1492,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 +1514,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 +1536,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 metadata::definition::read(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 +1564,11 @@ keys! { Explode = "explode", Schema = "schema", Content = "content", - FieldName = "x-fern-parameter-name", } } +entry!(ParameterEntry, ParameterKey, ParameterField, metadata::parameter::read); + impl ReadObject for Parameter { type Out = Id; const EXPECTING: &'static str = "a Parameter Object"; @@ -1500,7 +1576,20 @@ 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) => { + extension::once(&hit, &mut seen_extensions)?; + match hit.field { + ParameterField::Name => { + param.field_name = map.next_value_seed(seed::(doc))?; + } + } + continue; + } + ParameterEntry::Key(key) => key, + }; key.once(&mut seen)?; match key { ParameterKey::Ref => param.reference = map.next_value_seed(seed::(doc))?, @@ -1517,7 +1606,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 +1707,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, metadata::schema::read); + impl ReadObject for SchemaObject { type Out = Id; const EXPECTING: &'static str = "a Schema Object"; @@ -1633,7 +1719,25 @@ 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) => { + extension::once(&hit, &mut seen_extensions)?; + match hit.field { + SchemaField::ModelName => { + schema.model_name = map.next_value_seed(seed::(doc))?; + } + SchemaField::PropertyName => { + schema.field_name = map.next_value_seed(seed::(doc))?; + } + SchemaField::Skip => schema.skip = map.next_value()?, + 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 +1780,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,35 +1883,6 @@ 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); - -impl<'de> Deserialize<'de> for StainlessSkip { - fn deserialize>(deserializer: D) -> Result { - struct SkipVisitor; - - impl<'de> Visitor<'de> for SkipVisitor { - type Value = StainlessSkip; - - 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_seq>(self, mut seq: A) -> Result { - while let Some(IgnoredAny) = seq.next_element()? {} - Ok(StainlessSkip(false)) - } - } - - deserializer.deserialize_any(SkipVisitor) - } -} - /// Reads `x-forge-commands`. A key with dots, such as /// `dynamic-routing.deployments`, is a path: it describes the group /// `deployments` inside `dynamic-routing`, the same as nesting the entry in diff --git a/src/metadata/mod.rs b/src/metadata/mod.rs new file mode 100644 index 000000000..cea15617b --- /dev/null +++ b/src/metadata/mod.rs @@ -0,0 +1,399 @@ +//! 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. Reading the values, and building a +//! `Forge` from them, is the work of `link`. It reads these keys and no +//! others: the extensions other tools write are ordinary extensions to it, +//! and `forge normalize` is what rewrites them into these. + +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"; + +/// What every native key begins with. 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 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 + /// in the table of names of its kind of object. + pub(crate) slot: u8, +} + +impl Hit { + /// Returns a key, in slot `slot`, which is under 64. + const fn new(field: F, slot: u8) -> Hit { + Hit { field, slot } + } +} + +/// 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: 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. + Skip, + /// 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. + Skip, + /// 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, +} + +/// Returns whether `key` is the `x-forge-aliases` of an operation. +pub(crate) fn is_aliases(key: &str) -> bool { + key == ALIASES +} + +/// The native keys of an operation, or of an alias in its `x-forge-aliases`. +pub(crate) mod definition { + use super::{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 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> { + if !key.starts_with(PREFIX) { + return None; + } + Some(match key { + METHOD_NAME => Hit::new(DefinitionField::Method, 0), + GROUP_NAME => Hit::new(DefinitionField::Group, 1), + SKIP => Hit::new(DefinitionField::Skip, 2), + AVAILABILITY => Hit::new(DefinitionField::Availability, 3), + HIDDEN => Hit::new(DefinitionField::Hidden, 4), + CONFIRMATION => Hit::new(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 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> { + if !key.starts_with(PREFIX) { + return None; + } + Some(match key { + NAME => Hit::new(ParameterField::Name, 0), + _ => return None, + }) + } +} + +/// The native keys of a Schema Object. +pub(crate) mod schema { + use super::{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 names of the native keys of a Schema Object, by slot. + pub(crate) const NAMES: [&str; 4] = [MODEL_NAME, PROPERTY_NAME, SKIP, 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> { + if !key.starts_with(PREFIX) { + return None; + } + Some(match key { + MODEL_NAME => Hit::new(SchemaField::ModelName, 0), + PROPERTY_NAME => Hit::new(SchemaField::PropertyName, 1), + SKIP => Hit::new(SchemaField::Skip, 2), + SENSITIVE => Hit::new(SchemaField::Sensitive, 3), + _ => 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::new(DocumentField::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::*; + + /// 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] { + [ + &[ + "x-forge-method-name", + "x-forge-group-name", + "x-forge-skip", + "x-forge-availability", + "x-forge-hidden", + "x-forge-require-confirmation", + ], + &["x-forge-parameter-name"], + &[ + "x-forge-model-name", + "x-forge-property-name", + "x-forge-skip", + "x-forge-sensitive", + ], + &["x-forge-commands"], + ] + } + + /// Asserts that each of `keys` is read by `read` in the slot that 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_eq!(names.get(usize::from(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() { + let [definitions, parameters, schemas, documents] = native_keys(); + 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); + } + + #[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, + DefinitionField::Availability, + DefinitionField::Hidden, + DefinitionField::Confirmation, + ] { + let hit = definition::read(definition::key(field)).unwrap(); + assert!(same(&hit.field) == same(&field)); + } + let hit = parameter::read(parameter::key(ParameterField::Name)).unwrap(); + assert!(matches!(hit.field, ParameterField::Name)); + for field in [ + SchemaField::ModelName, + SchemaField::PropertyName, + SchemaField::Skip, + SchemaField::Sensitive, + ] { + let hit = schema::read(schema::key(field)).unwrap(); + assert!(same(&hit.field) == same(&field)); + } + } + + #[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] + fn slots_are_unique_among_the_keys_of_an_object_and_fit_in_a_set() { + 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 < 64), "a set of keys read is a u64"); + }; + 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] + fn other_keys_are_not_native() { + for key in [ + "x-other-method-name", + "x-other-skip", + "x-custom", + "summary", + "x-forge-nope", + "x-forge-method", + // What other systems write for a native key, which is not read. + "x-sensitive", + ] { + 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::read("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/normalize/mod.rs b/src/normalize/mod.rs new file mode 100644 index 000000000..7c624a131 --- /dev/null +++ b/src/normalize/mod.rs @@ -0,0 +1,225 @@ +//! Rewrites the extensions other tools' metadata is written with into the +//! native `x-forge-*` ones, in a JSON document. +//! +//! `forge` reads the native keys and no others, so this is how a document that +//! was written for another tool becomes one it can build an SDK from. The key +//! a tool writes is found with the tables of [`vendor`], at the kinds of +//! object `forge` reads its native key at, so a key is only rewritten where +//! `forge` would have read the native one: 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 vendor; +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, + /// The keys that were left as they are written, because there was no + /// saying what they mean: one that is written twice, or has a value that + /// cannot be read. It is for whatever reads the document next to say if + /// that is wrong. + pub left: 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 was left as it is written. +#[derive(Debug)] +pub struct Left { + /// Where the object is, as a JSON Pointer. + pub location: String, + /// The key. + pub key: String, + /// Why it was left, which follows its name: `is written twice`. + pub why: &'static str, + /// The line, from 1, and the column, which is how many bytes of the line + /// there are before it, of where a parser that refused it would be: after + /// the name of the second copy of a key that is written twice, and at the + /// start of a value that cannot be read. + pub line: usize, + pub column: usize, +} + +impl Left { + /// Returns whether the key is left for being written twice. + pub fn written_twice(&self) -> bool { + self.why == WRITTEN_TWICE + } +} + +/// Why a key that is written twice is left. +const WRITTEN_TWICE: &str = "is written twice in this object"; + +/// Why a key whose value cannot be read is left. +const UNREADABLE: &str = "has a value that cannot be read"; + +/// 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..3188b2bd3 --- /dev/null +++ b/src/normalize/scan.rs @@ -0,0 +1,678 @@ +//! 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 the line, from 1, and the column, which is how many bytes of the + /// line there are before `at`, that a parser that has read up to `at` says + /// it is at: `serde_json` says it so, and an error that names where is + /// worded the same. + pub(super) fn consumed(&self, at: usize) -> (usize, usize) { + 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); + (line, column) + } + + /// 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. + /// A key that cannot be read, an escape that is a lone surrogate, is called + /// by what it is written as, which is no name there is a use for. + pub(super) fn name(&self, member: &Member) -> Cow<'j, [u8]> { + if member.escaped + && let Ok(key) = self.key(member) + { + return Cow::Owned(key.into_owned().into_bytes()); + } + 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/vendor/README.md b/src/normalize/vendor/README.md new file mode 100644 index 000000000..ee95f849e --- /dev/null +++ b/src/normalize/vendor/README.md @@ -0,0 +1,41 @@ +# The vendor tables + +`forge` reads one vocabulary: the native `x-forge-*` extensions in +[`src/metadata/`](../../metadata/mod.rs). The extensions other tools write +(Fern's `x-fern-*`, Stainless's `x-stainless-*`) are ordinary extensions to +it, kept for plugins like any other it does not read. This folder is the only +place that knows them, and what they say: `forge normalize` uses it to +rewrite them into native keys, before `forge` reads the document. + +## 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`). + The tests of vendor-keyed documents live in `tests/normalize/`. +- **Mapping, not semantics.** A table says which native field a key stands + for, and whether its value is written as the native key's is, as data. + Anything structural, such as how aliases become methods, belongs to the + builder, not here. The one value that is written again is a group path, which + becomes the names joined by dots (`walk.rs`). +- **A key is read only at the level `forge` reads its native key at**: an + operation or an alias, a parameter, or a schema. A key of an object `forge` + does not read it at is left alone, since rewriting it would give it a + meaning it did not have. +- **Native wins.** When a document has the native key, the vendor one is + dropped, or reported if it says something else, whatever order they appear + in. When a vendor key has no native form, such as a skip that is a list of + targets, it is left as it is written. + +## 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 ask it from `mod.rs`. +3. Add a test in `tests/normalize/` showing that the vendor-keyed document, + after `forge normalize`, builds the same `Forge` as the native-keyed one. + +`forge` itself needs no change: it never learns the key. diff --git a/src/normalize/vendor/fern.rs b/src/normalize/vendor/fern.rs new file mode 100644 index 000000000..edb222e63 --- /dev/null +++ b/src/normalize/vendor/fern.rs @@ -0,0 +1,87 @@ +//! The adapter for Fern's `x-fern-*` extensions. +//! +//! 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 +//! same in both. + +/// 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-"; + +/// The keys of an operation or an alias. +pub(super) mod definition { + use super::PREFIX; + use crate::metadata::DefinitionField; + use crate::normalize::vendor::Key; + + 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. + #[cfg(test)] + pub(in crate::normalize::vendor) const NAMES: [&str; 4] = [METHOD_NAME, GROUP_NAME, IGNORE, AVAILABILITY]; + + pub(in crate::normalize::vendor) fn read(key: &str) -> Option> { + if !key.starts_with(PREFIX) { + return None; + } + Some(match key { + METHOD_NAME => Key::same(DefinitionField::Method), + GROUP_NAME => Key::same(DefinitionField::Group), + IGNORE => Key::same(DefinitionField::Skip), + AVAILABILITY => Key::same(DefinitionField::Availability), + _ => return None, + }) + } +} + +/// The keys of a Parameter Object. +pub(super) mod parameter { + use super::PREFIX; + use crate::metadata::ParameterField; + use crate::normalize::vendor::Key; + + const NAME: &str = "x-fern-parameter-name"; + + /// The names of the adapter's keys of a Parameter Object. + #[cfg(test)] + pub(in crate::normalize::vendor) const NAMES: [&str; 1] = [NAME]; + + pub(in crate::normalize::vendor) fn read(key: &str) -> Option> { + if !key.starts_with(PREFIX) { + return None; + } + Some(match key { + NAME => Key::same(ParameterField::Name), + _ => return None, + }) + } +} + +/// The keys of a Schema Object. +pub(super) mod schema { + use super::PREFIX; + use crate::metadata::SchemaField; + use crate::normalize::vendor::Key; + + 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. + #[cfg(test)] + pub(in crate::normalize::vendor) const NAMES: [&str; 2] = [TYPE_NAME, PROPERTY_NAME]; + + pub(in crate::normalize::vendor) fn read(key: &str) -> Option> { + if !key.starts_with(PREFIX) { + return None; + } + Some(match key { + TYPE_NAME => Key::same(SchemaField::ModelName), + PROPERTY_NAME => Key::same(SchemaField::PropertyName), + _ => return None, + }) + } +} diff --git a/src/normalize/vendor/mod.rs b/src/normalize/vendor/mod.rs new file mode 100644 index 000000000..0fd9725d5 --- /dev/null +++ b/src/normalize/vendor/mod.rs @@ -0,0 +1,61 @@ +//! The keys other tools' metadata is written with, and the native extension of +//! [`metadata`](crate::metadata) that each says the same as. This is the only +//! code that holds them: `forge` itself reads native keys and no others. +//! +//! Each adapter, [`fern`] and [`stainless`], maps its tool's keys onto native +//! fields, and says whether the value is written as the native key's is. The +//! names other systems write without the `x-forge-` prefix are in +//! [`unprefixed`]. This module asks them, once per kind of object. +//! +//! A key has to be read at the kind of object `forge` reads its native key +//! at, so that a rewrite does not give a key a meaning it did not have. An +//! extension that says something `forge` has no primitive for is not here. + +mod fern; +mod stainless; +#[cfg(test)] +mod tests; +mod unprefixed; + +use crate::metadata::{DefinitionField, ParameterField, SchemaField}; + +/// What a key of another tool says, and how its value is written. +#[derive(Clone, Copy)] +pub(super) struct Key { + /// The native field that says the same. + pub(super) field: F, + /// Whether the value may be a list of targets, as well as a boolean. A + /// list of targets is not something a native key says. + pub(super) targets: bool, +} + +impl Key { + /// Returns a key whose value is written as the native key's is. + const fn same(field: F) -> Key { + Key { field, targets: false } + } + + /// Returns a key whose value is a boolean, or a list of targets. + const fn bool_or_targets(field: F) -> Key { + Key { field, targets: true } + } +} + +/// Returns what `key` says on an operation or an alias, if an adapter reads +/// it there. +pub(super) fn definition(key: &str) -> Option> { + fern::definition::read(key) +} + +/// Returns what `key` says on a Parameter Object, if an adapter reads it +/// there. +pub(super) fn parameter(key: &str) -> Option> { + fern::parameter::read(key) +} + +/// Returns what `key` says on a Schema Object, if an adapter reads it there. +pub(super) fn schema(key: &str) -> Option> { + fern::schema::read(key) + .or_else(|| stainless::schema::read(key)) + .or_else(|| unprefixed::schema::read(key)) +} diff --git a/src/normalize/vendor/stainless.rs b/src/normalize/vendor/stainless.rs new file mode 100644 index 000000000..54c8d909c --- /dev/null +++ b/src/normalize/vendor/stainless.rs @@ -0,0 +1,31 @@ +//! The adapter for Stainless's `x-stainless-*` extensions. +//! +//! `x-stainless-skip` may be a list of the targets to skip a property for, +//! which a native skip, that is for every target, does not say. + +/// 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-"; + +/// The keys of a Schema Object. +pub(super) mod schema { + use super::PREFIX; + use crate::metadata::SchemaField; + use crate::normalize::vendor::Key; + + const SKIP: &str = "x-stainless-skip"; + + /// The names of the adapter's keys of a Schema Object. + #[cfg(test)] + pub(in crate::normalize::vendor) const NAMES: [&str; 1] = [SKIP]; + + pub(in crate::normalize::vendor) fn read(key: &str) -> Option> { + if !key.starts_with(PREFIX) { + return None; + } + Some(match key { + SKIP => Key::bool_or_targets(SchemaField::Skip), + _ => return None, + }) + } +} diff --git a/src/normalize/vendor/tests.rs b/src/normalize/vendor/tests.rs new file mode 100644 index 000000000..1baff2a74 --- /dev/null +++ b/src/normalize/vendor/tests.rs @@ -0,0 +1,258 @@ +//! The vendor tables, and `normalize` on documents that are damaged. The second +//! is here, and not in `tests/normalize/`, as it is of what is private to +//! `normalize`, and names the keys of other tools, which only this module may. + +use super::*; +use crate::metadata; +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"); +} + +/// The keys of each kind of object that the tables read, with what each says. +fn definitions() -> Vec<(&'static str, Key)> { + fern::definition::NAMES + .iter() + .map(|&key| (key, definition(key).unwrap_or_else(|| panic!("{key} is not read")))) + .collect() +} + +fn parameters() -> Vec<(&'static str, Key)> { + fern::parameter::NAMES + .iter() + .map(|&key| (key, parameter(key).unwrap_or_else(|| panic!("{key} is not read")))) + .collect() +} + +fn schemas() -> Vec<(&'static str, Key)> { + [ + &fern::schema::NAMES[..], + &stainless::schema::NAMES, + &unprefixed::schema::NAMES, + ] + .concat() + .into_iter() + .map(|key| (key, schema(key).unwrap_or_else(|| panic!("{key} is not read")))) + .collect() +} + +#[test] +fn each_key_says_the_native_field_it_stands_for() { + for (key, want) in [ + ("x-fern-sdk-method-name", DefinitionField::Method), + ("x-fern-sdk-group-name", DefinitionField::Group), + ("x-fern-ignore", DefinitionField::Skip), + ("x-fern-availability", DefinitionField::Availability), + ] { + let read = definition(key).unwrap(); + assert!( + std::mem::discriminant(&read.field) == std::mem::discriminant(&want), + "{key}" + ); + } + assert!(matches!( + parameter("x-fern-parameter-name").unwrap().field, + ParameterField::Name + )); + for (key, want) in [ + ("x-fern-type-name", SchemaField::ModelName), + ("x-fern-property-name", SchemaField::PropertyName), + ("x-stainless-skip", SchemaField::Skip), + ("x-sensitive", SchemaField::Sensitive), + ] { + let read = schema(key).unwrap(); + assert!( + std::mem::discriminant(&read.field) == std::mem::discriminant(&want), + "{key}" + ); + } +} + +#[test] +fn only_the_skip_of_a_schema_that_stainless_writes_may_be_a_list_of_targets() { + for (key, read) in definitions() { + assert!(!read.targets, "{key}"); + } + for (key, read) in parameters() { + assert!(!read.targets, "{key}"); + } + for (key, read) in schemas() { + assert_eq!(read.targets, key == "x-stainless-skip", "{key}"); + } +} + +#[test] +fn a_key_is_read_only_at_its_level() { + let all = [ + fern::definition::NAMES.to_vec(), + fern::parameter::NAMES.to_vec(), + [ + &fern::schema::NAMES[..], + &stainless::schema::NAMES, + &unprefixed::schema::NAMES, + ] + .concat(), + ]; + for (level, keys) in all.iter().enumerate() { + for &key in keys { + let read = [ + definition(key).is_some(), + parameter(key).is_some(), + schema(key).is_some(), + ]; + for (other, reads) in read.into_iter().enumerate() { + assert_eq!(reads, other == level, "{key}"); + } + } + } +} + +#[test] +fn what_a_key_says_has_a_native_key_that_reads_as_it() { + for (key, read) in definitions() { + let native = metadata::definition::key(read.field); + let hit = metadata::definition::read(native).unwrap_or_else(|| panic!("{key} has no native key")); + assert!( + std::mem::discriminant(&hit.field) == std::mem::discriminant(&read.field), + "{key}" + ); + } + for (key, read) in parameters() { + assert!( + metadata::parameter::read(metadata::parameter::key(read.field)).is_some(), + "{key}" + ); + } + for (key, read) in schemas() { + let native = metadata::schema::key(read.field); + let hit = metadata::schema::read(native).unwrap_or_else(|| panic!("{key} has no native key")); + assert!( + std::mem::discriminant(&hit.field) == std::mem::discriminant(&read.field), + "{key}" + ); + } +} + +#[test] +fn no_key_of_another_tool_is_a_native_key() { + for (key, _) in definitions() { + assert!(metadata::definition::read(key).is_none(), "{key}"); + } + for (key, _) in parameters() { + assert!(metadata::parameter::read(key).is_none(), "{key}"); + } + for (key, _) in schemas() { + assert!(metadata::schema::read(key).is_none(), "{key}"); + } +} + +#[test] +fn the_other_extensions_of_these_tools_are_not_read() { + for key in [ + "x-fern-enum", + "x-fern-audiences", + "x-fern-pagination", + "x-fern-sdk-variables", + "x-stainless-terraform-configurability", + "x-stainless-deprecation-message", + // The native keys, which are not for a rewrite. + "x-forge-skip", + "x-forge-method-name", + ] { + assert!(definition(key).is_none(), "{key}"); + assert!(parameter(key).is_none(), "{key}"); + assert!(schema(key).is_none(), "{key}"); + } +} diff --git a/src/normalize/vendor/unprefixed.rs b/src/normalize/vendor/unprefixed.rs new file mode 100644 index 000000000..54b5e5536 --- /dev/null +++ b/src/normalize/vendor/unprefixed.rs @@ -0,0 +1,22 @@ +//! The names other systems write native keys under, without the `x-forge-` +//! prefix. They are in use, so they are not deprecated: they are rewritten. + +/// The keys of a Schema Object. +pub(super) mod schema { + use crate::metadata::SchemaField; + use crate::normalize::vendor::Key; + + /// What other systems write for `x-forge-sensitive`. + const SENSITIVE: &str = "x-sensitive"; + + /// The names of the keys of a Schema Object. + #[cfg(test)] + pub(in crate::normalize::vendor) const NAMES: [&str; 1] = [SENSITIVE]; + + pub(in crate::normalize::vendor) fn read(key: &str) -> Option> { + match key { + SENSITIVE => Some(Key::same(SchemaField::Sensitive)), + _ => None, + } + } +} diff --git a/src/normalize/walk.rs b/src/normalize/walk.rs new file mode 100644 index 000000000..c5d5747fd --- /dev/null +++ b/src/normalize/walk.rs @@ -0,0 +1,509 @@ +//! 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::vendor::{self, Key}; +use super::{Conflict, Error, Left, Options, Report, UNREADABLE, WRITTEN_TWICE}; +use crate::metadata::{self, DefinitionField, ParameterField, SchemaField}; + +/// 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, if another 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 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; + +impl Level for Definition { + type Field = DefinitionField; + + fn read(key: &str) -> Option> { + vendor::definition(key) + } + + fn native(field: DefinitionField) -> &'static str { + metadata::definition::key(field) + } + + /// 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> { + vendor::parameter(key) + } + + fn native(field: ParameterField) -> &'static str { + metadata::parameter::key(field) + } +} + +impl Level for Schema { + type Field = SchemaField; + + fn read(key: &str) -> Option> { + vendor::schema(key) + } + + fn native(field: SchemaField) -> &'static str { + metadata::schema::key(field) + } +} + +/// 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)) +} + +/// Notes that the key `key` of the object being read was left as it is +/// written, for `why`. A macro and not a method, so that what is borrowed of the +/// walk to decide it can be borrowed still. +macro_rules! leave { + ($walk:ident, $key:expr, $why:expr, $at:expr) => {{ + let location = $walk.pointer(); + let (line, column) = $walk.scan.consumed($at); + $walk.report.left.push(Left { + location, + key: $key.to_string(), + why: $why, + line, + column, + }); + }}; +} + +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) { + 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; + } + // A key that cannot be read is no key there is a use for. + let Ok(key) = self.scan.key(&member) else { + continue; + }; + if !key.starts_with("x-") { + continue; + } + let Some(hit) = L::read(&key) else { + continue; + }; + let native = L::native(hit.field); + let members = &self.members[base..]; + // A key written twice has no one thing to say: each is left, and + // said once, at the first. + if let Some(other) = members + .iter() + .position(|other| other.key_start != member.key_start && self.scan.key_is(other, &key)) + { + if member.key_start < members[other].key_start { + // As a parser that read it would say: after the second. + let after_the_second = members[other].key_end; + leave!(self, &key, WRITTEN_TWICE, after_the_second); + } + continue; + } + + let Ok(value) = self.scan.decode::(member.value_start, member.value_end) else { + leave!(self, &key, UNREADABLE, member.value_start); + continue; + }; + // A list of targets is something no native key says: it is left + // as it is written, and not for a rewrite to say some other way + // or to drop. + if hit.targets && value.is_array() { + continue; + } + // The native key that is beside is the one that decides, so it has + // to be one, and to have a value to read. If it is not, what + // `forge` makes of it is for `forge` to say. + let mut named = members.iter().filter(|other| self.scan.key_is(other, native)); + let beside: Option = match named.next() { + Some(other) => { + if let Some(second) = named.next() { + let after_the_second = second.key_end; + leave!(self, native, WRITTEN_TWICE, after_the_second); + continue; + } + match self.scan.decode(other.value_start, other.value_end) { + Ok(beside) => Some(beside), + Err(_) => { + leave!(self, native, UNREADABLE, other.value_start); + continue; + } + } + } + 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); + } + } + + /// 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/src/plugin/ts/tests.rs b/src/plugin/ts/tests.rs index f6c095bde..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-fern-sdk-group-name": ["pet-store", "list_all"], "x-fern-sdk-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-fern-sdk-group-name": "pet-store", "x-fern-sdk-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-fern-sdk-group-name": "other", "x-fern-sdk-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/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..55e6defbd 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-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> { @@ -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()] @@ -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. `x-sensitive`, which + /// other systems write for the same thing, is not read: `forge normalize` + /// rewrites it. 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-stainless-skip` 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 - /// 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. @@ -509,7 +511,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/boundary.rs b/tests/boundary.rs new file mode 100644 index 000000000..a0167c5be --- /dev/null +++ b/tests/boundary.rs @@ -0,0 +1,124 @@ +//! The boundary of the vendor tables: no other tool's extension is named +//! anywhere but in them. +//! +//! The compiler keeps what the tables read 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 tables and their 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"]; + +/// 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/normalize/vendor/", "tests/normalize/", "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_vendor_tables() { + 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| names(line, vendor)) { + found.push(format!("{path}:{}: {}", i + 1, line.trim())); + } + } + } + + assert!( + found.is_empty(), + "the extensions of other tools are named only in src/normalize/vendor/ and tests/normalize/, 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/normalize/vendor/") && text.contains(vendor)), + "src/normalize/vendor/ does not name {vendor}" + ); + } +} diff --git a/tests/cli.rs b/tests/cli.rs index 6b85c5fc4..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-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "get", "responses": {"200": {"description": "ok"}}}}} }"#; diff --git a/tests/normalize/common.rs b/tests/normalize/common.rs new file mode 100644 index 000000000..67233eff4 --- /dev/null +++ b/tests/normalize/common.rs @@ -0,0 +1,154 @@ +//! Helpers to build documents and to compare what two of them make, so that +//! each test is about one thing. + +use forge::normalize::{Options, normalize}; +use forge::{Forge, Type}; + +/// Parses `json` as a whole document, as `forge` reads it: only the native +/// keys mean anything. +#[track_caller] +pub fn parse(json: &str) -> Forge { + match serde_json::from_str(json) { + Ok(forge) => forge, + Err(err) => panic!("parse: {err}"), + } +} + +/// Parses `json` as a whole document after `forge normalize` has rewritten the +/// keys of other tools in it, as the commands are run one after the other. +#[track_caller] +pub fn parse_normalized(json: &str) -> Forge { + parse(&normalized(json)) +} + +/// Returns `json` after `forge normalize`. +#[track_caller] +pub fn normalized(json: &str) -> String { + let normalized = match normalize(json.as_bytes(), &Options::default()) { + Ok(normalized) => normalized, + Err(err) => panic!("normalize: {err}"), + }; + normalized + .json + .map_or_else(|| json.to_owned(), |edited| String::from_utf8(edited.to_vec()).unwrap()) +} + +/// Runs `forge normalize` on `json`, and returns what it made of it, and the +/// keys it left as they are written, each as where, the key and why. +#[track_caller] +pub fn normalized_and_left(json: &str) -> (String, Vec<(String, String, &'static str)>) { + let normalized = match normalize(json.as_bytes(), &Options::default()) { + Ok(normalized) => normalized, + Err(err) => panic!("normalize: {err}"), + }; + let left = normalized + .report + .left + .iter() + .map(|left| (left.location.clone(), left.key.clone(), left.why)) + .collect(); + let text = normalized + .json + .map_or_else(|| json.to_owned(), |edited| String::from_utf8(edited.to_vec()).unwrap()); + (text, left) +} + +/// 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-name": "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-name": ["a"], "x-forge-method-name": "get", + "responses": {{"200": {{"description": "ok", "content": {{"application/json": {{"schema": {schema}}}}}}}}}}}}}}}, + "components": {{"schemas": {schemas}}}}}"# + ) +} + +/// Asserts that `a` and `b` build the same `Forge` after `forge normalize`, as +/// far as `describe` says. +#[track_caller] +pub fn assert_same(a: &str, b: &str) { + let (a, b) = (describe(&parse_normalized(a)), describe(&parse_normalized(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| { + let secret = if field.is_sensitive() { "!" } else { "" }; + format!( + "{}:{}={}{secret}", + 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/normalize/fern.rs b/tests/normalize/fern.rs new file mode 100644 index 000000000..ccd14f62f --- /dev/null +++ b/tests/normalize/fern.rs @@ -0,0 +1,323 @@ +//! 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, normalized, normalized_and_left, operation_with, parse, parse_err, parse_normalized, + 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-name": ["accounts"], "x-forge-method-name": "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-name": ["a"], "x-forge-method-name": "get", "x-forge-availability": "{availability}","# + )), + ); + } +} + +/// 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-name": "a.b.c", "x-forge-method-name": "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-name": ["a.b", "c"], "x-forge-method-name": "get","#), + ); +} + +/// 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_normalized(&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_normalized(&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() { + // The value is written as it was, under the native key, which `forge` + // then says it does not accept. + let err = parse_err(&normalized(&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-name": ["a"], "x-forge-method-name": "get", "x-forge-skip": true,"#), + ); + assert_eq!( + parse_normalized(&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-name": ["a"], "x-forge-method-name": "get","#), + ); +} + +#[test] +fn a_parameter_name_is_read_as_native() { + let parameters = |key: &str| { + operation_with(&format!( + r#""x-forge-group-name": ["a"], "x-forge-method-name": "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_normalized(¶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_normalized(&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_normalized(&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-name", + "x-forge-method-name", + "x-forge-availability", + "x-forge-skip", + ), + ); +} + +#[test] +fn a_key_that_is_read_is_not_an_extension_of_the_method() { + let forge = parse_normalized(&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_normalized(&operation_with( + 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(); + + 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_native_key_the_rewrite_wrote() { + let err = parse_err(&normalized(&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-forge-availability "ga" is not"#), "{err}"); + + let err = parse_err(&normalized(&operation_with( + r#""x-fern-sdk-group-name": "accounts..members", "x-fern-sdk-method-name": "get","#, + ))); + assert!( + err.contains("GET /a: x-forge-group-name has a group name with no letters or digits"), + "{err}" + ); + + let err = parse_err(&normalized(&operation_with( + r#""x-fern-sdk-group-name": ["a"], "x-fern-sdk-method-name": "-","#, + ))); + assert!( + err.contains("GET /a: x-forge-method-name has no letters or digits"), + "{err}" + ); +} + +#[test] +fn an_error_in_an_alias_names_the_native_key_the_rewrite_wrote() { + let err = parse_err(&normalized(&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-forge-availability "ga" is not"#), + "{err}" + ); +} + +#[test] +fn a_key_written_twice_is_left_as_it_is_with_a_warning_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", + ), + ] { + // `forge` reads it as an ordinary extension. There is no one thing it + // says, so the rewrite leaves each, and does not stop. + let document = operation_with(keys); + parse(&document); + + let (text, left) = normalized_and_left(&document); + + assert_eq!(text, document, "{key}"); + assert_eq!( + left, + [( + "/paths/~1a/get".to_owned(), + key.to_owned(), + "is written twice in this object" + )] + ); + } +} + +#[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_normalized(&operation_with( + 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(); + 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_normalized(&schema)).contains("a:a="), + "{}", + describe(&parse_normalized(&schema)) + ); +} diff --git a/tests/normalize/main.rs b/tests/normalize/main.rs new file mode 100644 index 000000000..eacb117ae --- /dev/null +++ b/tests/normalize/main.rs @@ -0,0 +1,21 @@ +//! Tests for `forge normalize`: the keys other tools write, rewritten into the +//! native extensions that say the same things. +//! +//! `forge` reads the native keys and no others, so each primitive is tested +//! the same way: a document written with a tool's keys, after `forge +//! normalize`, must build the same `Forge` as its native twin. The tests of +//! what a rewrite does to the document itself, byte for byte, and of the +//! command, are written out. +//! +//! The suite of `tests/parse`, which has no vendor names in it, runs without +//! the `normalize` feature. + +mod common; +#[path = "../wasm/dump.rs"] +mod dump; +mod fern; +mod native_first; +mod normalize; +#[cfg(feature = "cli")] +mod normalize_cli; +mod stainless; diff --git a/tests/normalize/native_first.rs b/tests/normalize/native_first.rs new file mode 100644 index 000000000..8ada625a6 --- /dev/null +++ b/tests/normalize/native_first.rs @@ -0,0 +1,337 @@ +//! A native key wins: when a document has both a native key and the key of +//! another tool for the same thing, `forge normalize` keeps the native one, +//! whatever order they are written in and whatever the other value is, so long +//! as it is a value at all. + +use super::common::{assert_same, describe, normalized, operation_with, parse_err, parse_normalized, responding_with}; + +/// Asserts that every ordering of `native` and `vendor`, which are members of +/// an operation, builds what `expected` alone does. +#[track_caller] +fn native_wins(native: &str, vendor: &str, expected: &str) { + for keys in [format!("{native} {vendor}"), format!("{vendor} {native}")] { + assert_same(&operation_with(&keys), &operation_with(expected)); + } +} + +#[test] +fn the_native_method_wins() { + native_wins( + r#""x-forge-group-name": ["a"], "x-forge-method-name": "native","#, + r#""x-fern-sdk-method-name": "vendor","#, + r#""x-forge-group-name": ["a"], "x-forge-method-name": "native","#, + ); + assert!( + describe(&parse_normalized(&operation_with( + r#""x-forge-group-name": ["a"], "x-forge-method-name": "native", "x-fern-sdk-method-name": "vendor","# + ))) + .contains("method native") + ); +} + +#[test] +fn the_native_group_wins() { + native_wins( + 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-name": ["native"],"#, + ); +} + +#[test] +fn the_native_availability_wins_and_an_invalid_vendor_one_is_not_checked() { + native_wins( + r#""x-forge-group-name": ["a"], "x-forge-method-name": "get", "x-forge-availability": "beta","#, + r#""x-fern-availability": "ga","#, + r#""x-forge-group-name": ["a"], "x-forge-method-name": "get", "x-forge-availability": "beta","#, + ); +} + +#[test] +fn a_native_skip_of_false_keeps_the_method_a_vendor_one_would_drop() { + native_wins( + r#""x-forge-group-name": ["a"], "x-forge-method-name": "get", "x-forge-skip": false,"#, + r#""x-fern-ignore": true,"#, + r#""x-forge-group-name": ["a"], "x-forge-method-name": "get","#, + ); +} + +#[test] +fn a_native_skip_of_true_drops_the_method_a_vendor_one_would_keep() { + native_wins( + r#""x-forge-group-name": ["a"], "x-forge-method-name": "get", "x-forge-skip": true,"#, + r#""x-fern-ignore": false,"#, + r#""x-forge-group-name": ["a"], "x-forge-method-name": "get", "x-forge-skip": true,"#, + ); +} + +#[test] +fn the_native_parameter_name_wins() { + let parameter = |keys: &str| { + operation_with(&format!( + r#""x-forge-group-name": ["a"], "x-forge-method-name": "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_normalized(&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_skip_of_a_property_wins_over_a_stainless_one() { + let property = |keys: &str| { + responding_with( + &format!(r#"{{"type": "object", "properties": {{"a": {{"type": "string", {keys}}}}}}}"#), + "{}", + ) + }; + for keys in [ + r#""x-forge-skip": false, "x-stainless-skip": true"#, + r#""x-stainless-skip": true, "x-forge-skip": false"#, + ] { + assert!(describe(&parse_normalized(&property(keys))).contains("a:a="), "{keys}"); + } + for keys in [ + r#""x-forge-skip": true, "x-stainless-skip": false"#, + r#""x-stainless-skip": false, "x-forge-skip": true"#, + ] { + assert!(!describe(&parse_normalized(&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-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-name": ["a", "b"], "x-forge-method-name": "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-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","#, + ), + ); +} + +#[test] +fn the_value_of_a_key_beside_a_native_key_is_not_checked_whatever_it_is() { + // It is dropped, as the native key says it, so it does not matter that + // it is not even a value `forge` would take. + for keys in [ + r#""x-forge-availability": "beta", "x-fern-availability": 1,"#, + r#""x-fern-availability": 1, "x-forge-availability": "beta","#, + ] { + let document = operation_with(&format!( + r#""x-forge-group-name": ["a"], "x-forge-method-name": "get", {keys}"# + )); + + assert_same( + &document, + &operation_with( + r#""x-forge-group-name": ["a"], "x-forge-method-name": "get", "x-forge-availability": "beta","#, + ), + ); + } +} + +#[test] +fn a_native_key_written_twice_is_an_error_naming_it() { + let err = parse_err(&operation_with(r#""x-forge-skip": false, "x-forge-skip": false,"#)); + + 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-name": "native.group","#, + r#""x-fern-sdk-group-name": ["vendor"],"#, + 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-name": ["a"], "x-forge-method-name": "get", "x-forge-availability": "ga","#, + )); + + 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 vendor value is an error on its own, so the document parsing at + // all shows it was not checked. + native_wins( + r#""x-forge-group-name": ["a"], "x-forge-method-name": "get","#, + r#""x-fern-sdk-group-name": "a..b","#, + r#""x-forge-group-name": ["a"], "x-forge-method-name": "get","#, + ); + native_wins( + r#""x-forge-group-name": ["a"], "x-forge-method-name": "get","#, + r#""x-fern-sdk-method-name": "-","#, + r#""x-forge-group-name": ["a"], "x-forge-method-name": "get","#, + ); + // Alone, each is written under the native key, which `forge` then checks. + assert!( + parse_err(&normalized(&operation_with( + r#""x-fern-sdk-group-name": "a..b", "x-forge-method-name": "get","# + ))) + .contains("x-forge-group-name") + ); + assert!( + parse_err(&normalized(&operation_with( + r#""x-forge-group-name": ["a"], "x-fern-sdk-method-name": "-","# + ))) + .contains("x-forge-method-name") + ); +} + +/// Asserts that every ordering of `native` and `vendor`, 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, vendor: &str, expected: &str, method: &str) { + let alias = |keys: &str| operation_with(&format!(r#""x-forge-aliases": [{{{keys}}}],"#)); + for keys in [format!("{native}, {vendor}"), format!("{vendor}, {native}")] { + assert_same(&alias(&keys), &alias(expected)); + } + let described = describe(&parse_normalized(&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-name": ["a"], "x-forge-method-name": "native""#, + r#""x-fern-sdk-method-name": "vendor""#, + 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-name": ["native"]"#, + r#""x-fern-sdk-group-name": "vendor.group""#, + r#""x-forge-method-name": "get", "x-forge-group-name": ["native"]"#, + "get", + ); + native_wins_in_an_alias( + r#""x-forge-group-name": ["a"], "x-forge-method-name": "get", "x-forge-availability": "beta""#, + r#""x-fern-availability": "ga""#, + 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-name": ["a"], "x-forge-method-name": "get", "x-forge-skip": false"#, + r#""x-fern-ignore": true"#, + r#""x-forge-group-name": ["a"], "x-forge-method-name": "get""#, + "get", + ); +} + +#[test] +fn a_native_skip_in_an_alias_drops_the_method_a_vendor_one_would_keep() { + let alias = |keys: &str| { + operation_with(&format!( + r#""x-forge-aliases": [{{"x-forge-group-name": ["a"], "x-forge-method-name": "get", {keys}}}],"# + )) + }; + for keys in [ + r#""x-forge-skip": true, "x-fern-ignore": false"#, + r#""x-fern-ignore": false, "x-forge-skip": true"#, + ] { + assert_eq!(parse_normalized(&alias(keys)).groups().count(), 0, "{keys}"); + } +} + +/// `x-sensitive` is a key of another system as far as `forge` goes: it is the +/// rewrite that says what it means. +#[test] +fn x_forge_sensitive_wins_over_x_sensitive_in_either_order() { + let property = |keys: &str| { + responding_with( + &format!(r#"{{"type": "object", "properties": {{"password": {{"type": "string", {keys}}}}}}}"#), + "{}", + ) + }; + 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 described = describe(&parse_normalized(&property(keys))); + + assert_eq!( + described.contains("password:password=String!"), + sensitive, + "{keys}\n{described}" + ); + assert_same( + &property(keys), + &property(&format!(r#""x-forge-sensitive": {sensitive}"#)), + ); + } +} + +#[test] +fn x_sensitive_alone_makes_the_field_sensitive_through_the_rewrite_and_not_without_it() { + let document = responding_with( + r#"{"type": "object", "properties": {"password": {"type": "string", "x-sensitive": true}}}"#, + "{}", + ); + + assert!(!describe(&super::common::parse(&document)).contains('!')); + assert!(describe(&parse_normalized(&document)).contains("password:password=String!")); +} diff --git a/tests/normalize/normalize.rs b/tests/normalize/normalize.rs new file mode 100644 index 000000000..f0a4f5544 --- /dev/null +++ b/tests/normalize/normalize.rs @@ -0,0 +1,1169 @@ +//! `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, normalized_and_left, 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 x_sensitive_is_rewritten_to_x_forge_sensitive() { + 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_is_left_as_it_is_and_the_others_are_rewritten() { + let document = operation_with( + r#""x-fern-sdk-method-name": "a", "x-fern-sdk-method-name": "b", "x-fern-sdk-group-name": ["a"],"#, + ); + + let (text, report) = edit(&document, &Options::default()); + + assert_eq!( + text, + operation_with(r#""x-fern-sdk-method-name": "a", "x-fern-sdk-method-name": "b", "x-forge-group-name": "a","#) + ); + assert_eq!(report.migrated.len(), 1); + let [left] = &report.left[..] else { + panic!("{:?}", report.left) + }; + assert_eq!( + (left.location.as_str(), left.key.as_str(), left.why), + ( + "/paths/~1a/get", + "x-fern-sdk-method-name", + "is written twice in this object" + ) + ); +} + +/// `forge` reads no native key, nor any other key it knows, that is written +/// twice in an object it reads. A key of another tool is an ordinary extension +/// to it, which it takes as the last value written. +#[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-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_leaves_that_key_in_both_modes() { + // Which of the two is the native key that is beside is not for a rewrite + // to say, so the key beside them is left as it is, and `forge` says what + // it makes of a native key written twice. + 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", + ), + ] { + let document = operation_with(keys); + for options in [Options::default(), KEEP] { + let normalized = normalize(document.as_bytes(), &options).unwrap(); + + assert!(normalized.json.is_none() && !normalized.report.changed(), "{keys}"); + let [left] = &normalized.report.left[..] else { + panic!("{keys}: {:?}", normalized.report.left) + }; + assert_eq!((left.key.as_str(), left.why), (name, "is written twice in this object")); + } + } +} + +#[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_twice_is_left_with_where_a_parser_that_refused_it_would_be() { + // After the name of the second copy, in bytes, on its line. + let document = "{\"openapi\":\"3.0.3\",\"info\":{\"title\":\"t\",\"version\":\"1\"},\"paths\":{\"/a\":{\"get\":{\n \"x-fern-sdk-method-name\": \"a\",\n \"x-fern-sdk-method-name\": \"b\",\n \"x-fern-sdk-method-name\": \"c\",\n \"responses\": {\"204\": {\"description\": \"ok\"}}}}}}"; + + let normalized = normalize(document.as_bytes(), &Options::default()).unwrap(); + + let [left] = &normalized.report.left[..] else { + panic!("{:?}", normalized.report.left) + }; + assert!(left.written_twice()); + assert_eq!((left.line, left.column), (3, 26)); +} + +#[test] +fn a_value_that_cannot_be_read_is_left_as_it_is_and_does_not_stop_the_others() { + // A lone surrogate is JSON that a value cannot be made of. + let document = operation_with( + r#""x-fern-sdk-method-name": "\ud800", "x-fern-sdk-group-name": ["a"], "x-fern-ignore": false,"#, + ); + + let (text, left) = normalized_and_left(&document); + + assert!(text.contains(r#""x-fern-sdk-method-name": "\ud800""#), "{text}"); + assert!( + text.contains(r#""x-forge-group-name": "a""#) && text.contains(r#""x-forge-skip": false"#), + "{text}" + ); + assert_eq!( + left, + [( + "/paths/~1a/get".to_owned(), + "x-fern-sdk-method-name".to_owned(), + "has a value that cannot be read" + )] + ); +} + +#[test] +fn a_native_key_beside_one_that_is_rewritten_that_cannot_be_read_leaves_both() { + let document = operation_with(r#""x-forge-method-name": "\ud800", "x-fern-sdk-method-name": "get","#); + + for options in [Options::default(), KEEP] { + let normalized = normalize(document.as_bytes(), &options).unwrap(); + + assert!(normalized.json.is_none(), "{options:?}"); + let [left] = &normalized.report.left[..] else { + panic!("{:?}", normalized.report.left) + }; + assert_eq!( + (left.key.as_str(), left.why), + ("x-forge-method-name", "has a value that cannot be read") + ); + } +} + +#[test] +fn a_key_that_cannot_be_read_is_no_key_and_does_not_stop_the_others() { + let document = operation_with(r#""x\ud800": 1, "x-fern-sdk-group-name": ["a"],"#); + + let (text, left) = normalized_and_left(&document); + + assert!( + text.contains(r#""x\ud800": 1"#) && text.contains(r#""x-forge-group-name": "a""#), + "{text}" + ); + assert!(left.is_empty(), "{left:?}"); +} + +#[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 normalize aggregate -- --ignored --nocapture`. +/// +/// Both modes build the same `Forge`, apart from the extensions the old keys +/// that are left are, and it has the methods of the document. `forge` does not +/// build it from the document as it is written: the keys of other tools are not +/// read, so it is `forge normalize` that says what they mean. +#[test] +#[ignore = "needs FORGE_SPEC, the path of a large document"] +fn the_aggregate_builds_the_same_forge_in_both_modes_and_only_when_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 unread = super::dump::dump(&serde_json::from_slice(&json).unwrap()); + + let mut dumps = Vec::new(); + for options in [Options::default(), KEEP] { + let normalized = normalize(&json, &options).unwrap(); + dumps.push(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!(report.conflicts.is_empty(), "{options:?}: the document has conflicts"); + } + + // The keys that are left are extensions to `forge`, which plugins see. + let extensions = |dump: &str| { + dump.lines() + .filter(|line| line.trim_start().starts_with("extension ")) + .count() + }; + let model = |dump: &str| -> String { + dump.lines() + .filter(|line| !line.trim_start().starts_with("extension ")) + .collect::>() + .join("\n") + }; + assert!( + extensions(&dumps[1]) > extensions(&dumps[0]), + "the old keys that are left are not extensions: {} against {}", + extensions(&dumps[1]), + extensions(&dumps[0]) + ); + if model(&dumps[0]) != model(&dumps[1]) { + let (a, b) = (model(&dumps[0]), model(&dumps[1])); + let differing = a.lines().zip(b.lines()).find(|(a, b)| a != b); + panic!("the two modes build different Forges, first at:\n{differing:?}"); + } + assert!( + dumps[0].len() > 10 * unread.len(), + "normalizing did not give the document its methods: {} bytes, against {} unread", + dumps[0].len(), + unread.len() + ); +} diff --git a/tests/normalize/normalize_cli.rs b/tests/normalize/normalize_cli.rs new file mode 100644 index 000000000..3b3938dc2 --- /dev/null +++ b/tests/normalize/normalize_cli.rs @@ -0,0 +1,699 @@ +//! `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_a_warning_that_says_where_and_the_file_is_rewritten() { + 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()], ""); + + succeeded(&out); + let err = text(&out.stderr); + assert!( + err.contains( + "spec.json: warning: /paths/~1a/get: x-fern-sdk-method-name is written twice in this object, and is left as it is" + ), + "{err}" + ); + assert!(err.contains("1 file: 1 rewritten, 0 unchanged, 1 warning"), "{err}"); + let written = fs::read_to_string(&file).unwrap(); + assert_eq!(written.matches("\"x-fern-sdk-method-name\"").count(), 2, "{written}"); + assert!(written.contains("\"x-forge-group-name\""), "{written}"); +} + +#[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); +} diff --git a/tests/normalize/stainless.rs b/tests/normalize/stainless.rs new file mode 100644 index 000000000..007e0ed1e --- /dev/null +++ b/tests/normalize/stainless.rs @@ -0,0 +1,104 @@ +//! 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, normalized, normalized_and_left, operation_with, parse_err, parse_normalized, + responding_with, +}; + +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}}}}}}}"# + ), + "{}", + ) +} + +fn with_native_skip(value: &str) -> String { + responding_with( + &format!( + r#"{{"type": "object", "properties": {{"id": {{"type": "string"}}, "old_id": {{"type": "string", "x-forge-skip": {value}}}}}}}"# + ), + "{}", + ) +} + +#[test] +fn a_stainless_skip_is_read_as_a_native_one() { + assert_same(&with_stainless_skip("true"), &with_native_skip("true")); + assert!(!describe(&parse_normalized(&with_stainless_skip("true"))).contains("old_id")); +} + +#[test] +fn a_property_skipped_as_false_keeps_its_field() { + assert_same(&with_stainless_skip("false"), &with_native_skip("false")); + assert!(describe(&parse_normalized(&with_stainless_skip("false"))).contains("old_id")); +} + +#[test] +fn a_list_of_targets_is_not_a_skip() { + assert_same( + &with_stainless_skip(r#"["typescript", "go"]"#), + &with_native_skip("false"), + ); + assert!(describe(&parse_normalized(&with_stainless_skip(r#"["typescript"]"#))).contains("old_id")); + assert!(describe(&parse_normalized(&with_stainless_skip("[]"))).contains("old_id")); +} + +#[test] +fn a_skip_that_is_not_a_boolean_or_a_list_is_an_error_for_the_native_key() { + // Written as it was, which `forge` says is not a boolean. + let err = parse_err(&normalized(&with_stainless_skip(r#""yes""#))); + + assert!(err.contains("invalid type") && err.contains("a boolean"), "{err}"); +} + +#[test] +fn a_list_of_targets_is_an_error_for_the_native_key() { + let err = parse_err(&with_native_skip(r#"["typescript"]"#)); + + assert!(err.contains("invalid type"), "{err}"); +} + +#[test] +fn a_skip_written_twice_is_left_as_it_is_with_a_warning_naming_it() { + let schema = responding_with( + r#"{"type": "object", "properties": {"a": {"type": "string", "x-stainless-skip": false, "x-stainless-skip": false}}}"#, + "{}", + ); + + let (text, left) = normalized_and_left(&schema); + + assert_eq!(text, schema); + assert_eq!(left.len(), 1); + assert_eq!( + (left[0].1.as_str(), left[0].2), + ("x-stainless-skip", "is written twice in this object") + ); +} + +#[test] +fn a_skip_on_an_operation_says_nothing_and_stays_an_extension() { + let forge = parse_normalized(&operation_with( + r#""x-forge-group-name": ["a"], "x-forge-method-name": "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/parse/aliases.rs b/tests/parse/aliases.rs index eb299ef5f..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}; @@ -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-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-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-name": ["a"], "x-forge-method-name": "pause"}, + {"x-forge-group-name": ["a"], "x-forge-method-name": "terminate"} ]"#; #[test] @@ -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,"#)); @@ -77,11 +97,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-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-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-name": ["a"], "x-forge-method-name": "terminate", + "x-forge-availability": "beta", "x-forge-require-confirmation": "Sure?", "x-a": 1, "x-b": 2} ]"#, "", )); @@ -111,8 +131,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-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 +148,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-name": ["a"], "x-forge-method-name": "pause"}, + {"x-forge-group-name": ["a"]}, + {"x-forge-method-name": "resume"}, + {"x-forge-group-name": [], "x-forge-method-name": "restart"} ]"#, "", )); @@ -142,7 +162,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-name": ["a"], "x-forge-method-name": "pause"}]"#, "", )); @@ -159,7 +179,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"}"#, + r#"{"x-forge-group-name": ["a"], "x-forge-method-name": "pause"}"#, "", )); @@ -172,11 +192,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-name": ["a"], "x-forge-method-name": "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-name": ["a"], "x-forge-method-name": "resume"}, + {"x-forge-group-name": ["a"], "x-forge-method-name": "Pause"} ], "responses": {"204": {"description": "ok"}}}} } }"#, @@ -192,8 +212,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-name": ["a"], "x-forge-method-name": "pause"}, + {"x-forge-group-name": ["a"], "x-forge-method-name": "pause"} ]"#, "", )); @@ -207,12 +227,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-name": ["a", "", "b"], "x-forge-method-name": "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-name"), "{err}" ); } @@ -220,12 +240,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-name": ["a"], "x-forge-method-name": "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,26 +253,26 @@ 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-name": "pause", "x-forge-method-name": "resume"}]"#, "", )); - assert!(err.contains("duplicate field `x-fern-sdk-method-name`"), "{err}"); + assert!(err.contains("duplicate field `x-forge-method-name`"), "{err}"); } #[test] -fn ignored_operation_still_has_its_aliases() { - let forge = parse(&aliased(PAUSE_AND_TERMINATE, r#""x-fern-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-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-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 +283,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-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 +297,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-name": ["a"], "x-forge-method-name": "pause", "summary": "Pause", "deprecated": false, "path": "/b"}]"#, "", )); @@ -293,7 +313,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-name": ["a"], "x-forge-method-name": "get", "responses": {"204": {"description": "ok"}}}}} }"#, ); @@ -307,10 +327,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-name": ["a", "b"], "x-forge-method-name": "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-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 +348,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-name": ["x"], "x-forge-method-name": "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-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 4b09e2967..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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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 ed8854cd2..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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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 282c6744b..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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a..b", "x-fern-sdk-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 f626036f4..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-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "x-forge-group-name": ["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-fern-sdk-method-name"), None); + assert_eq!(get.extension("x-forge-method-name"), 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-skip": 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-name"), None); + assert_eq!(get.extension("x-forge-skip"), 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-name": ["a", "b"], "x-forge-method-name": "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-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-fern-ignore": false, "x-fern-ignore": false,"#)); + let err = parse_err(&operation_with(r#""x-forge-skip": false, "x-forge-skip": false,"#)); - assert!(err.contains("duplicate field `x-fern-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "list", + "/b": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "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-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "b", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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 18618c70e..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}; @@ -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-name": "accounts.members", "x-forge-method-name": "list", "responses": {"204": {"description": "ok"}}}}} }"#, ); @@ -16,12 +16,25 @@ 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-name": 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( 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-name": ["accounts", "members"], "x-forge-method-name": "list", "responses": {"204": {"description": "ok"}}}}} }"#, ); @@ -35,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-fern-sdk-group-name": ["zones.settings"], "x-fern-sdk-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group-name": ["zones.settings"], "x-forge-method-name": "get", "responses": {"204": {"description": "ok"}}}}} }"#, ); @@ -48,15 +61,74 @@ 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( 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-name": ["abuse-reports"], "x-forge-method-name": "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-name": ["abuse_reports"], "x-forge-method-name": "get", "responses": {"204": {"description": "ok"}}}} } }"#, @@ -72,9 +144,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-name": ["members"], "x-forge-method-name": "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-name": ["members", "delete"], "x-forge-method-name": "bulk", "responses": {"204": {"description": "ok"}}}} } }"#, @@ -89,7 +161,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-name": "get", "responses": {"204": {"description": "ok"}}}}} }"#, ); @@ -99,15 +171,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-fern-sdk-group-name": "", "x-fern-sdk-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-name": {group}, "x-forge-method-name": "get", + "responses": {{"204": {{"description": "ok"}}}}}}}}}} + }}"# + )); + + assert_eq!(forge.groups().count(), 0, "{group}"); + } } #[test] @@ -115,7 +189,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-name": ["a"], "responses": {"204": {"description": "ok"}}}}} }"#, ); @@ -128,7 +202,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-name": ["a"], "x-forge-method-name": "", "responses": {"204": {"description": "ok"}}}}} }"#, ); @@ -137,14 +211,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-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "list", + "get": {"x-forge-group-name": ["a"], "x-forge-method-name": "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-name": ["a"], "x-forge-method-name": "create", "x-forge-skip": true, "responses": {"204": {"description": "ok"}}} }} }"#, @@ -156,15 +230,26 @@ 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-fern-sdk-group-name": "accounts..members", "x-fern-sdk-method-name": "list", - "responses": {"204": {"description": "ok"}}}}} - }"#, - ); - - assert!(err.contains("GET /a"), "{err}"); + 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"}}, + "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-name has a group name"), + "{group}: {err}" + ); + } } #[test] @@ -173,9 +258,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-name": ["a"], "x-forge-method-name": "list", "responses": {"204": {"description": "ok"}}}}, - "/b": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "list", + "/b": {"get": {"x-forge-group-name": ["a"], "x-forge-method-name": "list", "responses": {"204": {"description": "ok"}}}} } }"#, @@ -190,9 +275,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-name": ["a"], "x-forge-method-name": "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-name": ["a"], "x-forge-method-name": "listAll", "responses": {"204": {"description": "ok"}}}} } }"#, @@ -207,11 +292,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-name": ["a", "b", "c"], "x-forge-method-name": "get", "responses": {"204": {"description": "ok"}}}}, - "/b": {"get": {"x-fern-sdk-group-name": "x", "x-fern-sdk-method-name": "get", + "/b": {"get": {"x-forge-group-name": ["x"], "x-forge-method-name": "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-name": ["a", "d"], "x-forge-method-name": "get", "responses": {"204": {"description": "ok"}}}} } }"#, @@ -241,7 +326,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-name": ["accounts"], "x-forge-method-name": "list", "responses": {"204": {"description": "ok"}}}}}, "x-forge-commands": {"accounts": {"description": "Accounts."}} }"#, @@ -255,7 +340,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-name": ["accounts", "members"], "x-forge-method-name": "list", "responses": {"204": {"description": "ok"}}}}}, "x-forge-commands": {"accounts": {"groups": {"members": {"description": "Members."}}}} }"#, @@ -271,8 +356,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-name": [{group}], "x-forge-method-name": "list", "responses": {{"204": {{"description": "ok"}}}}}}}}"# ) }) @@ -341,7 +431,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-name": ["abuse_reports"], "x-forge-method-name": "list", "responses": {"204": {"description": "ok"}}}}}, "x-forge-commands": {"abuse-reports": {"description": "Abuse reports."}} }"#, @@ -355,7 +445,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-name": ["accounts"], "x-forge-method-name": "list", "responses": {"204": {"description": "ok"}}}}}, "x-forge-commands": {"zones": {"description": "Zones."}} }"#, @@ -384,13 +474,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-name": ["zones"], "x-forge-method-name": "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-name": ["accounts", "members"], "x-forge-method-name": "get", "responses": {"204": {"description": "ok"}}}}, - "/c": {"get": {"x-fern-sdk-group-name": "dns", "x-fern-sdk-method-name": "get", + "/c": {"get": {"x-forge-group-name": ["dns"], "x-forge-method-name": "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-name": ["accounts", "billing"], "x-forge-method-name": "get", "responses": {"204": {"description": "ok"}}}} } }"#, @@ -412,11 +502,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-name": ["a"], "x-forge-method-name": "list", "responses": {"204": {"description": "ok"}}}, - "post": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "create", + "post": {"x-forge-group-name": ["a"], "x-forge-method-name": "create", "responses": {"204": {"description": "ok"}}}, - "delete": {"x-fern-sdk-group-name": "a", "x-fern-sdk-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 49f34a22a..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-fern-sdk-group-name": "a", "x-fern-sdk-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"}}, @@ -39,12 +39,12 @@ 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-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-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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,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-fern-sdk-group-name": "a", "x-fern-sdk-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-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-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-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a.b", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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 5daea4ea9..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-fern-sdk-group-name": "a", "x-fern-sdk-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-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-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "x", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "get": {"x-forge-group-name": ["a"], "x-forge-method-name": "get", "responses": {"204": {"description": "ok"}}}, - "additionalOperations": {"GET": {"x-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "pets", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "subscribe", + "paths": {"/subscribe": {"post": {"x-forge-group-name": ["a"], "x-forge-method-name": "subscribe", "callbacks": {"event": {"{$request.query.url}": {"post": { - "x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "event", + "x-forge-group-name": ["a"], "x-forge-method-name": "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-name": ["a"], "x-forge-method-name": "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-name": ["a"], "x-forge-method-name": "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-name": ["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-name": ["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 d6373aafe..f4bfe3b36 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-name": ["zones"], + "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-fern-sdk-group-name": "a.b.c", "x-fern-sdk-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-fern-sdk-group-name": "a.b.d", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a.b.c", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "x", "x-fern-sdk-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-fern-sdk-group-name": "a.b", "x-fern-sdk-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-fern-sdk-group-name": "a.c", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "ignored", "x-fern-ignore": true, - "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Ignored"}}}}}}}, - "/ungrouped": {"get": {"x-fern-sdk-method-name": "ungrouped", + "/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"} }} @@ -186,10 +186,10 @@ 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-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-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-fern-sdk-group-name": "a.files", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "b", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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 31ad98776..b0e9eaaae 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-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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"}} @@ -107,12 +107,12 @@ 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-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-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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,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-fern-sdk-group-name": "a", "x-fern-sdk-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-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-fern-sdk-group-name": "a", "x-fern-sdk-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-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-fern-sdk-group-name": "a", "x-fern-sdk-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": { "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-fern-sdk-group-name": "a", "x-fern-sdk-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-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-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "list", - "parameters": [{"name": "token", "in": "query", "x-sensitive": true, "schema": {"type": "string"}}], + "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-fern-sdk-group-name": "a", "x-fern-sdk-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-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-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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,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-fern-sdk-group-name": "a", "x-fern-sdk-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-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-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-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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 a34167acc..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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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 262c601e1..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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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 d2b3b497e..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-fern-sdk-group-name": "a", "x-fern-sdk-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 7c5d6564b..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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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 a3794823e..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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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 1b5139d46..4adcd338f 100644 --- a/tests/parse/types/objects.rs +++ b/tests/parse/types/objects.rs @@ -36,9 +36,9 @@ 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-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"); @@ -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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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_skipped_by_x_stainless_skip_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-stainless-skip": 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"]); @@ -297,3 +297,22 @@ 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 x_sensitive_is_not_read() { + // `forge normalize` rewrites it, to `x-forge-sensitive`, which is. + 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 sensitive_written_twice_is_an_error_naming_it() { + let err = parse_err(&responding_with( + r#"{"type": "object", "properties": {"password": {"type": "string", "x-forge-sensitive": true, "x-forge-sensitive": true}}}"#, + )); + + assert!(err.contains("duplicate field `x-forge-sensitive`"), "{err}"); +} diff --git a/tests/parse/types/refs.rs b/tests/parse/types/refs.rs index 0e39e86e3..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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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}} }}}}}}}}, @@ -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-fern-sdk-group-name": "a", "x-fern-sdk-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-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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 810e9954a..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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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 45f11f81d..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-fern-sdk-group-name": "a", "x-fern-sdk-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-fern-sdk-group-name": "a", "x-fern-sdk-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"] }}}}}}}} @@ -291,15 +291,15 @@ 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"}], "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-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 7278ead1d..18bedd8b9 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-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-fern-sdk-group-name": "pets", "x-fern-sdk-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-fern-sdk-group-name": "pets", "x-fern-sdk-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-fern-sdk-group-name": "pets.photos", "x-fern-sdk-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-fern-sdk-group-name": "owners", "x-fern-sdk-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,23 +138,23 @@ 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-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": "é 😀", "parameters": [ {"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-fern-sdk-group-name": "pets", "x-fern-sdk-method-name": "remove", "x-fern-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-fern-sdk-group-name": "admin.pets", "x-fern-sdk-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-fern-sdk-group-name": "pets.photos", "x-fern-sdk-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."}}, @@ -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}, 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 fe43db8a731c591b41ea8e2b88d7e76ae553914d Mon Sep 17 00:00:00 2001 From: Niels Swimberghe Date: Tue, 27 Oct 2026 21:05:32 -0400 Subject: [PATCH 02/20] feat: the forge command rewrites other tools' extensions in memory, for now Until the release that makes `forge normalize` a step to run first, `forge` runs the same rewrite on the bytes it reads, before it parses them, so a document that has not been through `forge normalize` builds what it did: the released aggregate writes the same 3,243 files of `--target ts`, byte for byte, without the step. FORGE-1. It is temporary, and only the binary does it: the engine in `src/` reads native keys and nothing else. - It is the default rewrite, so the keys it rewrites are not kept, as `forge` did not hand them on to plugins before. - It says nothing of what it leaves, as `forge` said nothing of the document before. A key written twice is an error in the words `forge` used when it read those keys itself, down to the line and column: "duplicate field `x-fern-sdk-method-name` at line 7 column 30". A value that cannot be read is left in place, and `forge` says it as it reads it. - An error `forge` finds later in the document gives the column of the document that was rewritten, which differs from the one that was read where a key was renamed earlier on the same line. The line is the same. Cost, on the 28 MB released aggregate, best of 30: the rewrite is 17.6 ms when it has 11,714 keys to rewrite and 14.4 ms when it has none, next to 41.6 ms to parse into a `Forge`. The whole `--target ts` run goes from about 252 ms to 262 ms for a normalized document and to 270 ms for a raw one, which is 7%. --- AGENTS.md | 2 +- packages/forge/README.md | 6 + rfcs/0001-metadata-resolution-layer.md | 5 +- src/bin/forge/main.rs | 6 + src/bin/forge/normalize.rs | 31 ++++ tests/normalize/normalize_cli.rs | 206 +++++++++++++++++++++++++ 6 files changed, 253 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 34b11c094..4c12b8ff9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,7 +15,7 @@ API documentation site for Forge OpenAPI specs, built on Astro. Compatibility with other tools' extensions (`x-fern-*`, `x-stainless-*`, and `x-sensitive`) is sectioned off into its own area, `src/normalize/vendor/`, and nowhere else in the codebase knows them. `forge` does not read them: they are ordinary extensions to it, and `forge normalize` is what rewrites them into native ones. Any new compatibility key belongs in that area. See [`src/normalize/vendor/README.md`](src/normalize/vendor/README.md). - Never name a vendor extension outside `src/normalize/vendor/` and `tests/normalize/`, comments and docs included. `tests/boundary.rs` enforces this (`just boundary`). -- Run `forge normalize --leave-old-extensions` on a vendor-keyed document before `forge` reads it. The old keys stay for tools that still read them. +- Run `forge normalize --leave-old-extensions` on a vendor-keyed document before `forge` reads it. The old keys stay for tools that still read them. Until the release that makes this step required, the `forge` command also does it in memory when it reads a document (`before_reading` in `src/bin/forge/normalize.rs`); that call is temporary, and the engine in `src/` never does it. - 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. diff --git a/packages/forge/README.md b/packages/forge/README.md index 07eecf1d1..4ef7ce345 100644 --- a/packages/forge/README.md +++ b/packages/forge/README.md @@ -31,6 +31,12 @@ npx forge normalize 'specs/**/*.json' (To generate from a document that is itself called `normalize`, write it as `./normalize`.) +For now, `forge` also does this in memory when it reads a document, so one that +has not been through `forge normalize` builds as it always did. That is +temporary: it stops in the release that makes `forge normalize` a step to run +first, and the extensions of other tools are then passed on to plugins like any +other that forge does not read. + 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 diff --git a/rfcs/0001-metadata-resolution-layer.md b/rfcs/0001-metadata-resolution-layer.md index c832192e2..ae3883104 100644 --- a/rfcs/0001-metadata-resolution-layer.md +++ b/rfcs/0001-metadata-resolution-layer.md @@ -65,12 +65,13 @@ src/ ## Migration - Run `forge normalize` before `forge` reads a document written with vendor keys, with `--leave-old-extensions` where something else still reads them. A pipeline is `cat spec.json | forge normalize - | forge - --target ts --out sdk`. -- A document that is not normalized is not an error: `forge` finds no method names in it, and so no methods. +- Until the release that makes `forge normalize` a step to run first, the `forge` command does the same rewrite in memory when it reads a document, so one that has not been through it builds as before. It costs a pass over the document, and stops in that release; the core never reads anything but native keys. +- After that release, a document that is not normalized is not an error: `forge` finds no method names in it, and so no methods. - Keys that are left are ordinary extensions, so plugins see them. ## Alternatives considered - **A runtime fallback behind a feature**, native keys first and vendor keys for what has none: it costs tiers in the reader and a vendor key that diverges from the native one beside it. Rejected for an explicit rewrite. - **Resolving after parsing, in the engine**: it must retain every extension of a large document. -- **Normalizing in memory before every `generate`**: a pass over the document on every run, to save one in CI. +- **Normalizing in memory before every `generate`, for good**: a pass over the document on every run, to save one in CI. Kept for now as a bridge, and not as the design. - **A `Vendor` trait** per tool: no primitive overlaps across vendors, so the table only grows rows. diff --git a/src/bin/forge/main.rs b/src/bin/forge/main.rs index 85b73d5b3..d5a3d30ad 100644 --- a/src/bin/forge/main.rs +++ b/src/bin/forge/main.rs @@ -20,6 +20,10 @@ mod ts; /// Generates an SDK from an OpenAPI document. /// +/// The extensions other tools write are rewritten into the native ones in +/// memory first, as `forge normalize` does to a file. That is temporary: it +/// stops in the release that makes `forge normalize` a step to run first. +/// /// A document that is named `normalize` is given as `./normalize`. #[derive(Parser)] #[command(version, args_conflicts_with_subcommands = true)] @@ -131,6 +135,8 @@ fn read(path: &Path) -> Result { } else { fs::read(path)? }; + #[cfg(feature = "normalize")] + let json = normalize::before_reading(json)?; Ok(serde_json::from_slice(&json)?) }; parse().map_err(|err| format!("{}: {err}", path.display()).into()) diff --git a/src/bin/forge/normalize.rs b/src/bin/forge/normalize.rs index f63ea322d..e2d3e8f73 100644 --- a/src/bin/forge/normalize.rs +++ b/src/bin/forge/normalize.rs @@ -316,6 +316,37 @@ fn create_private(path: &Path) -> io::Result { options.open(path) } +/// Rewrites the extensions other tools write in the document `json` into the +/// native ones, in memory, as `forge normalize` would in a file. +/// +/// A key written twice, which it has to leave, is an error here, worded as it +/// was when `forge` read those keys itself. `forge normalize` goes on and says +/// it, and this does not say anything else of the document, as `forge` has not +/// before. A value it cannot read is left for `forge` to say, as it reads it. +/// +/// This is temporary. `forge` reads native keys only, and `forge normalize` is +/// what is meant to come first. This keeps a document that has not been through +/// it building as it did, until the release that stops doing it. One thing it +/// does not keep: an error `forge` finds later in the document says the column +/// of the document that was rewritten, which is not the one that was read where +/// a key was renamed earlier on the same line. The line is the same. +pub fn before_reading(json: Vec) -> Result, Err> { + let normalized = normalize(&json, &Options::default())?; + // The first in the document, as `forge` would have come to it. + let twice = (normalized.report.left.iter()) + .filter(|left| left.written_twice()) + .min_by_key(|left| (left.line, left.column)); + if let Some(left) = twice { + return Err(format!( + "duplicate field `{}` at line {} column {}", + left.key, left.line, left.column + ) + .into()); + } + let edited = normalized.json.map(|edited| edited.to_vec()); + Ok(edited.unwrap_or(json)) +} + /// Says what in the document called `name` could not be rewritten, or was /// dropped for a native key that says something else. fn warn(name: &str, report: &Report) { diff --git a/tests/normalize/normalize_cli.rs b/tests/normalize/normalize_cli.rs index 3b3938dc2..add3b9100 100644 --- a/tests/normalize/normalize_cli.rs +++ b/tests/normalize/normalize_cli.rs @@ -697,3 +697,209 @@ fn a_file_in_a_folder_that_cannot_be_written_to_is_rewritten_in_place() { succeeded(&out); assert_eq!(fs::read_to_string(&file).unwrap(), NATIVE); } + +/// Every file below `dir`, by its path below it. +fn files(dir: &Path) -> std::collections::BTreeMap> { + let mut found = std::collections::BTreeMap::new(); + let mut folders = vec![dir.to_owned()]; + while let Some(folder) = folders.pop() { + for entry in fs::read_dir(&folder).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + folders.push(path); + } else { + found.insert(path.strip_prefix(dir).unwrap().to_owned(), fs::read(&path).unwrap()); + } + } + } + found +} + +const OF_ANOTHER_TOOL: &str = r#"{"openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": {"/a": {"get": { + "x-fern-sdk-group-name": ["things", "parts"], "x-fern-sdk-method-name": "list", "x-fern-availability": "beta", + "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"type": "object", + "properties": {"id": {"type": "string"}, "secret": {"type": "string", "x-sensitive": true}}}}}}}}}}}"#; + +const NATIVE_TWIN: &str = r#"{"openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": {"/a": {"get": { + "x-forge-group-name": "things.parts", "x-forge-method-name": "list", "x-forge-availability": "beta", + "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"type": "object", + "properties": {"id": {"type": "string"}, "secret": {"type": "string", "x-forge-sensitive": true}}}}}}}}}}}"#; + +/// Until the release that stops it, `forge` rewrites the keys of other tools in +/// memory first, so a document that has not been through `forge normalize` +/// builds what its native twin does. +#[test] +fn forge_builds_a_document_of_another_tool_as_it_builds_its_native_twin() { + let dir = dir("in-memory"); + let (other, native) = (dir.join("other.json"), dir.join("native.json")); + fs::write(&other, OF_ANOTHER_TOOL).unwrap(); + fs::write(&native, NATIVE_TWIN).unwrap(); + + for (spec, out) in [(&other, "sdk-other"), (&native, "sdk-native")] { + let out = dir.join(out); + succeeded(&forge( + &[spec.to_str().unwrap(), "--target", "ts", "--out", out.to_str().unwrap()], + "", + )); + } + + let (other, native) = (files(&dir.join("sdk-other")), files(&dir.join("sdk-native"))); + assert!( + !other.is_empty() && other == native, + "{:?} against {:?}", + other.keys(), + native.keys() + ); + let client: Vec<&Vec> = other.values().collect(); + assert!( + client + .iter() + .any(|text| text.windows(4).any(|window| window == b"list")), + "the method is built" + ); + // The file is read, and not changed: that is for `forge normalize`. + assert_eq!(fs::read_to_string(dir.join("other.json")).unwrap(), OF_ANOTHER_TOOL); +} + +#[test] +fn forge_rewrites_standard_input_in_memory_too() { + let dir = dir("in-memory-stdin"); + let out = dir.join("sdk"); + + let from_stdin = forge( + &["-", "--target", "ts", "--out", out.to_str().unwrap()], + OF_ANOTHER_TOOL, + ); + + succeeded(&from_stdin); + let native = dir.join("sdk-native"); + succeeded(&forge( + &["-", "--target", "ts", "--out", native.to_str().unwrap()], + NATIVE_TWIN, + )); + let built = files(&out); + assert!(!built.is_empty() && built == files(&native)); +} + +/// The documents and the errors are those of `forge` before it left reading +/// these keys to `forge normalize`: each message is what that `forge` said of +/// the document, word for word, down to the line and the column. +#[test] +fn forge_fails_on_a_key_written_twice_in_the_words_it_always_used() { + let dir = dir("in-memory-error"); + let base = |keys: &str| { + format!( + r#"{{"openapi":"3.0.3","info":{{"title":"t","version":"1"}},"paths":{{"/a":{{"get":{{{keys},"responses":{{"204":{{"description":"ok"}}}}}}}}}}}}"# + ) + }; + for (name, document, message) in [ + ( + "one-line", + base(r#""x-fern-sdk-group-name":["a"],"x-fern-sdk-method-name":"get","x-fern-ignore":false,"x-fern-ignore":true"#), + "duplicate field `x-fern-ignore` at line 1 column 174", + ), + ( + "several-lines", + "{\n \"openapi\": \"3.0.3\",\n \"info\": {\"title\": \"t\", \"version\": \"1\"},\n \"paths\": {\"/a\": {\"get\": {\n \"x-fern-sdk-group-name\": [\"a\"],\n \"x-fern-sdk-method-name\": \"get\",\n \"x-fern-sdk-method-name\": \"again\",\n \"responses\": {\"204\": {\"description\": \"ok\"}}}}}}\n".to_owned(), + "duplicate field `x-fern-sdk-method-name` at line 7 column 30", + ), + ( + "three-copies", + base(r#""x-fern-sdk-group-name":["a"],"x-fern-sdk-method-name":"a","x-fern-sdk-method-name":"b","x-fern-sdk-method-name":"c""#), + "duplicate field `x-fern-sdk-method-name` at line 1 column 159", + ), + ( + "not-ascii-before-it", + base(r#""description":"café 中","x-fern-sdk-group-name":["a"],"x-fern-sdk-method-name":"a","x-fern-sdk-method-name":"b""#), + "duplicate field `x-fern-sdk-method-name` at line 1 column 185", + ), + ] { + let spec = dir.join(format!("{name}.json")); + fs::write(&spec, document).unwrap(); + let out = dir.join(format!("sdk-{name}")); + + let run = forge(&[spec.to_str().unwrap(), "--target", "ts", "--out", out.to_str().unwrap()], ""); + + let err = failed(&run); + assert_eq!(err.trim_end(), format!("forge: {}: {message}", spec.display()), "{name}"); + assert!(!out.exists(), "{name}: nothing was written"); + } +} + +/// A value that cannot be read is for `forge` to say, as it reads it, in the +/// words it always used, and on the line and column it always gave. +#[test] +fn forge_says_where_a_value_cannot_be_read_as_it_always_did() { + let dir = dir("in-memory-unreadable"); + let tail = r#""responses":{"204":{"description":"ok"}}}}}}"#; + for (name, document, message) in [ + ( + "vendor-value", + format!( + "{{\"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\":\"\\ud800\",\n{tail}" + ), + "unexpected end of hex escape at line 3 column 33", + ), + ( + "native-value", + format!( + "{{\"openapi\":\"3.0.3\",\"info\":{{\"title\":\"t\",\"version\":\"1\"}},\"paths\":{{\"/a\":{{\"get\":{{\n\"x-forge-group-name\":\"a\",\n\"x-forge-method-name\":\"\\ud800\",\n{tail}" + ), + "unexpected end of hex escape at line 3 column 30", + ), + ( + "key", + format!( + "{{\"openapi\":\"3.0.3\",\"info\":{{\"title\":\"t\",\"version\":\"1\"}},\"paths\":{{\"/a\":{{\"get\":{{\n\"x-fern-sdk-group-name\":[\"a\"],\n\"x\\ud800\":1,\n\"x-fern-sdk-method-name\":\"get\",\n{tail}" + ), + "unexpected end of hex escape at line 3 column 9", + ), + ] { + let spec = dir.join(format!("{name}.json")); + fs::write(&spec, document).unwrap(); + let out = dir.join(format!("sdk-{name}")); + + let run = forge( + &[spec.to_str().unwrap(), "--target", "ts", "--out", out.to_str().unwrap()], + "", + ); + + assert_eq!( + failed(&run).trim_end(), + format!("forge: {}: {message}", spec.display()), + "{name}" + ); + } +} + +#[test] +fn forge_says_nothing_of_a_document_that_it_could_rewrite() { + let dir = dir("in-memory-quiet"); + let spec = dir.join("spec.json"); + fs::write(&spec, OF_ANOTHER_TOOL).unwrap(); + let out = dir.join("sdk"); + + let run = forge( + &[spec.to_str().unwrap(), "--target", "ts", "--out", out.to_str().unwrap()], + "", + ); + + succeeded(&run); + assert_eq!(text(&run.stderr), "", "forge says nothing of the document"); + assert!(!files(&out).is_empty()); +} + +#[test] +fn forge_names_the_file_when_the_document_is_not_json() { + let dir = dir("in-memory-not-json"); + let spec = dir.join("spec.json"); + fs::write(&spec, "openapi: 3.0.3").unwrap(); + let out = dir.join("sdk"); + + let run = forge( + &[spec.to_str().unwrap(), "--target", "ts", "--out", out.to_str().unwrap()], + "", + ); + + assert!(failed(&run).contains("spec.json: "), "{}", text(&run.stderr)); +} From d3af272614eb49851a1ae8737c438491bdc2c302 Mon Sep 17 00:00:00 2001 From: Niels Swimberghe Date: Wed, 28 Oct 2026 10:51:44 -0400 Subject: [PATCH 03/20] docs: say plainly what is left as it is written, and drop the note about a file named normalize Review feedback on the README and the RFC. - What no native key says is now explained with the case it is about: a skip on a property is rewritten to `x-forge-skip` with the same value, but `x-forge-skip` is for every target, so a skip for only some targets, written as a list, has no native form and is left as it is written, in both modes. On an operation that key is not read at all. The RFC and the vendor README say it with the keys; the package README, which does not name other tools' keys, says it without them. - The README and `--help` no longer say how to pass a document that is itself called `normalize`. --- packages/forge/README.md | 10 ++++------ rfcs/0001-metadata-resolution-layer.md | 2 +- src/bin/forge/main.rs | 2 -- src/normalize/vendor/README.md | 7 +++++-- 4 files changed, 10 insertions(+), 11 deletions(-) diff --git a/packages/forge/README.md b/packages/forge/README.md index 4ef7ce345..2c438365f 100644 --- a/packages/forge/README.md +++ b/packages/forge/README.md @@ -28,9 +28,6 @@ something else still reads the old keys: npx forge normalize 'specs/**/*.json' ``` -(To generate from a document that is itself called `normalize`, write it as -`./normalize`.) - For now, `forge` also does this in memory when it reads a document, so one that has not been through `forge normalize` builds as it always did. That is temporary: it stops in the release that makes `forge normalize` a step to run @@ -53,9 +50,10 @@ 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 +it, is kept as it was. A skip for only some targets, which another tool writes +as a list of them, has no native form, since `x-forge-skip` is for every target: +it is left exactly as it is written, whichever mode is used, while a skip that +is plainly true or false is rewritten. 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. diff --git a/rfcs/0001-metadata-resolution-layer.md b/rfcs/0001-metadata-resolution-layer.md index ae3883104..8b0847930 100644 --- a/rfcs/0001-metadata-resolution-layer.md +++ b/rfcs/0001-metadata-resolution-layer.md @@ -42,7 +42,7 @@ Not in scope: Fern's structural behavior (allOf/anyOf, inline request types), op - The document is edited as bytes, not written again: whitespace, key order and the text of every number stay as they were, so a diff is only the keys. - A native key is never replaced. When a document says the same thing twice, the native key is kept, and the other is dropped, or 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. -- What no native key says, such as a skip that is a list of targets, is left as it is written. +- A skip on a property is rewritten to `x-forge-skip` with the same value: `x-stainless-skip: true` becomes `x-forge-skip: true`. `x-forge-skip` is for every target, so a skip for only some targets, written as a list (`x-stainless-skip: ["terraform"]`), has no native form and is left as it is written, in both modes. - `--leave-old-extensions` keeps each key that is rewritten, beside the native one, for what still reads it. ## Architecture diff --git a/src/bin/forge/main.rs b/src/bin/forge/main.rs index d5a3d30ad..4e900ad99 100644 --- a/src/bin/forge/main.rs +++ b/src/bin/forge/main.rs @@ -23,8 +23,6 @@ mod ts; /// The extensions other tools write are rewritten into the native ones in /// memory first, as `forge normalize` does to a file. That is temporary: it /// stops in the release that makes `forge normalize` a step to run first. -/// -/// A document that is named `normalize` is given as `./normalize`. #[derive(Parser)] #[command(version, args_conflicts_with_subcommands = true)] struct Args { diff --git a/src/normalize/vendor/README.md b/src/normalize/vendor/README.md index ee95f849e..1d10725df 100644 --- a/src/normalize/vendor/README.md +++ b/src/normalize/vendor/README.md @@ -25,8 +25,11 @@ rewrite them into native keys, before `forge` reads the document. meaning it did not have. - **Native wins.** When a document has the native key, the vendor one is dropped, or reported if it says something else, whatever order they appear - in. When a vendor key has no native form, such as a skip that is a list of - targets, it is left as it is written. + in. When a vendor key has no native form, it is left as it is written: on a + property, `x-stainless-skip: true` and `false` become `x-forge-skip` with the + same value, but a list of targets such as `["terraform"]` says "skip for + these targets", which `x-forge-skip` cannot, as it is for every target. On an + operation, `x-stainless-skip` is not read at all. ## Adding a vendor key From 1c349ebfe4a51e86ddbcd9895beb434dd37d581b Mon Sep 17 00:00:00 2001 From: Niels Swimberghe Date: Wed, 28 Oct 2026 10:53:18 -0400 Subject: [PATCH 04/20] refactor: forge::normalize is not behind a feature Review feedback: there is no need to feature gate it. `serde_json` and `memchr` are dependencies, `forge::normalize` and `forge normalize` are always built, and the `normalize` feature, with the `cfg`s of the binary, the library, the native key tables and the test target that went with it, is gone. The `cli` feature still decides whether there is a `forge` command. --- Cargo.toml | 17 ++++------------- rfcs/0001-metadata-resolution-layer.md | 2 -- src/bin/forge/main.rs | 9 --------- src/lib.rs | 1 - src/metadata/mod.rs | 4 ---- 5 files changed, 4 insertions(+), 29 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 3ed6539c6..dcf2192b2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,30 +9,25 @@ 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 } +memchr = "2.8.3" rayon = "1.12.0" serde = { version = "1.0.229", features = ["derive"] } -serde_json = { version = "1.0.151", optional = true } +serde_json = "1.0.151" wasmtime = { version = "49.0.2", default-features = false, features = ["runtime", "cranelift", "component-model", "parallel-compilation", "std"], optional = true } wasmtime-wasi = { version = "49.0.2", default-features = false, features = ["p2"], optional = true } [dev-dependencies] divan = "0.1.21" -serde_json = "1.0.151" [[bench]] name = "parse" harness = false [features] -default = ["cli", "normalize"] -# `forge::normalize`, and `forge normalize` when the `cli` feature is on: it -# rewrites the extensions other tools' metadata is written with into the -# native `x-forge-*` ones, which are the only ones `forge` reads. -normalize = ["dep:serde_json", "dep:memchr"] +default = ["cli"] # The `forge` command. Library users can turn it off with # `default-features = false`. -cli = ["dep:clap", "dep:serde_json", "dep:glob"] +cli = ["dep:clap", "dep:glob"] # `forge::wasm`, which runs plugins compiled to WebAssembly components. wasm = ["dep:wasmtime", "dep:wasmtime-wasi"] @@ -55,10 +50,6 @@ required-features = ["cli"] name = "wasm" required-features = ["cli", "wasm"] -[[test]] -name = "normalize" -required-features = ["normalize"] - [lints.clippy] # https://rust-lang.github.io/rust-clippy/rust-1.88.0/index.html diff --git a/rfcs/0001-metadata-resolution-layer.md b/rfcs/0001-metadata-resolution-layer.md index 8b0847930..c815754ba 100644 --- a/rfcs/0001-metadata-resolution-layer.md +++ b/rfcs/0001-metadata-resolution-layer.md @@ -60,8 +60,6 @@ src/ └─ scan.rs reading JSON as bytes, and editing it where it is read. ``` -`normalize` is a default-on feature; the engine builds without it. - ## Migration - Run `forge normalize` before `forge` reads a document written with vendor keys, with `--leave-old-extensions` where something else still reads them. A pipeline is `cat spec.json | forge normalize - | forge - --target ts --out sdk`. diff --git a/src/bin/forge/main.rs b/src/bin/forge/main.rs index 4e900ad99..e0a09edb5 100644 --- a/src/bin/forge/main.rs +++ b/src/bin/forge/main.rs @@ -14,7 +14,6 @@ use std::process::ExitCode; use clap::Parser; use forge::{Err, Forge}; -#[cfg(feature = "normalize")] mod normalize; mod ts; @@ -26,7 +25,6 @@ mod ts; #[derive(Parser)] #[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. @@ -42,7 +40,6 @@ struct Args { } /// 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. @@ -94,7 +91,6 @@ fn main() -> ExitCode { /// Does what `args` ask for. fn run(args: Args) -> Result<(), Err> { - #[cfg(feature = "normalize")] if let Some(Command::Normalize(args)) = args.command { return normalize::run(args); } @@ -133,7 +129,6 @@ fn read(path: &Path) -> Result { } else { fs::read(path)? }; - #[cfg(feature = "normalize")] let json = normalize::before_reading(json)?; Ok(serde_json::from_slice(&json)?) }; @@ -143,13 +138,10 @@ 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] @@ -157,7 +149,6 @@ mod tests { 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())); diff --git a/src/lib.rs b/src/lib.rs index 5050cdac1..b62729a1a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,7 +8,6 @@ mod link; mod metadata; mod method; mod name; -#[cfg(feature = "normalize")] pub mod normalize; mod plugin; mod types; diff --git a/src/metadata/mod.rs b/src/metadata/mod.rs index cea15617b..0c13d35fb 100644 --- a/src/metadata/mod.rs +++ b/src/metadata/mod.rs @@ -112,7 +112,6 @@ pub(crate) mod definition { 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, @@ -152,7 +151,6 @@ pub(crate) mod parameter { 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, @@ -185,7 +183,6 @@ pub(crate) mod schema { pub(crate) const NAMES: [&str; 4] = [MODEL_NAME, PROPERTY_NAME, SKIP, 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, @@ -294,7 +291,6 @@ mod tests { read_as_itself(documents, &document::NAMES, 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; From b96f4057415e0369d12f72d5ceb70b84a187ef95 Mon Sep 17 00:00:00 2001 From: Niels Swimberghe Date: Wed, 28 Oct 2026 10:55:42 -0400 Subject: [PATCH 05/20] refactor: the reader does not track which key gave a value Review feedback: `forge` reads native keys only, so there is one key for each thing an extension says, and no need to remember which gave a value. `Val`, its `slot` and its `set` are gone, and the fields of a `Definition` are plain values. An error that names a key names the native key of the field, which `metadata::definition` now exposes, so the messages are the same. The released aggregate still writes the same 3,243 files of `--target ts`, byte for byte. --- src/link.rs | 16 ++++++------ src/link/extension.rs | 5 ---- src/link/openapi.rs | 57 +++++++++++-------------------------------- src/metadata/mod.rs | 6 ++--- 4 files changed, 25 insertions(+), 59 deletions(-) diff --git a/src/link.rs b/src/link.rs index 2afe77f36..685237a84 100644 --- a/src/link.rs +++ b/src/link.rs @@ -565,13 +565,13 @@ impl<'d> Builder<'d> { http_method: HttpMethod, ) { let doc = self.doc; - if definition.skip.value { + if definition.skip { return; } - if definition.method_name.value.is_empty() { + if definition.method_name.is_empty() { return; } - let group_names = doc.list(definition.group_names.value); + let group_names = doc.list(definition.group_names); if group_names.is_empty() { return; } @@ -580,27 +580,27 @@ impl<'d> Builder<'d> { if name.is_empty() { self.error(format_args!( "{} has a group name with no letters or digits", - extension::definition_name(definition.group_names.slot) + metadata::definition::GROUP_NAME )); return; } } - let name = Name::new(doc.str(definition.method_name.value)); + let name = Name::new(doc.str(definition.method_name)); if name.is_empty() { self.error(format_args!( "{} has no letters or digits", - extension::definition_name(definition.method_name.slot) + metadata::definition::METHOD_NAME )); return; } let mut availability = None; - if let Some(value) = definition.availability.value { + if let Some(value) = definition.availability { let value = doc.str(value); availability = metadata::availability(value); if availability.is_none() { self.error(format_args!( "{} {value:?} is not alpha, beta, preview, generally-available or deprecated", - extension::definition_name(definition.availability.slot) + metadata::definition::AVAILABILITY )); } } diff --git a/src/link/extension.rs b/src/link/extension.rs index 85fb3f244..9e5b5afd5 100644 --- a/src/link/extension.rs +++ b/src/link/extension.rs @@ -48,8 +48,3 @@ pub(super) fn once(hit: &Hit, seen: &mut u64) -> Resul *seen |= bit; Ok(()) } - -/// 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/link/openapi.rs b/src/link/openapi.rs index dc2341c02..d8d2ab310 100644 --- a/src/link/openapi.rs +++ b/src/link/openapi.rs @@ -238,35 +238,16 @@ 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. -#[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, -} - -impl Val { - /// Keeps `value`, which `hit` gave. No other key says the same thing, and - /// one written twice is an error before it gets here. - fn set(&mut self, hit: &Hit, value: T) { - self.value = value; - self.slot = hit.slot; - } -} - /// 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 name of the method, or empty for none. - pub(super) method_name: Val, + pub(super) method_name: Str, /// The group path, top level first, or empty for none. - pub(super) group_names: Val>, + pub(super) group_names: Span, /// Whether the definition makes no method. - pub(super) skip: Val, + pub(super) skip: bool, /// 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 @@ -274,7 +255,7 @@ pub(super) struct Definition { pub(super) confirmation: 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>, + pub(super) availability: Option, /// 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. @@ -1255,30 +1236,20 @@ impl ReadObject for PathItem { } impl Definition { - /// Reads the value of the key `hit` says, the key of the entry `map` is - /// reading. + /// Reads the value of the key that says `field`, the key of the entry `map` + /// is reading. fn read_value<'de, A: MapAccess<'de>>( &mut self, doc: &mut Doc, map: &mut A, - hit: Hit, + field: DefinitionField, ) -> Result<(), A::Error> { - match hit.field { - DefinitionField::Method => { - let name = map.next_value_seed(seed::(doc))?; - self.method_name.set(&hit, name); - } - DefinitionField::Group => { - let names = map.next_value_seed(seed::(doc))?; - self.group_names.set(&hit, names); - } - DefinitionField::Skip => { - let skip = map.next_value()?; - self.skip.set(&hit, skip); - } + match field { + DefinitionField::Method => self.method_name = map.next_value_seed(seed::(doc))?, + DefinitionField::Group => self.group_names = map.next_value_seed(seed::(doc))?, + DefinitionField::Skip => self.skip = map.next_value()?, DefinitionField::Availability => { - let availability = map.next_value_seed(seed::(doc))?; - self.availability.set(&hit, Some(availability)); + self.availability = Some(map.next_value_seed(seed::(doc))?); } DefinitionField::Hidden => self.hidden = map.next_value()?, DefinitionField::Confirmation => { @@ -1308,7 +1279,7 @@ impl ReadObject for Alias { match field { AliasField::Definition(hit) => { extension::once(&hit, &mut seen)?; - definition.read_value(doc, &mut map, hit)?; + definition.read_value(doc, &mut map, hit.field)?; } AliasField::Extension(name) => json::insert(&mut extensions, name, map.next_value()?), AliasField::Other => skip(&mut map)?, @@ -1475,7 +1446,7 @@ impl ReadObject for Operation { OperationField::Key(key) => key, OperationField::Definition(hit) => { extension::once(&hit, &mut seen_definition)?; - operation.definition.read_value(doc, &mut map, hit)?; + operation.definition.read_value(doc, &mut map, hit.field)?; continue; } OperationField::Aliases => { diff --git a/src/metadata/mod.rs b/src/metadata/mod.rs index 0c13d35fb..0d94528be 100644 --- a/src/metadata/mod.rs +++ b/src/metadata/mod.rs @@ -101,10 +101,10 @@ pub(crate) fn is_aliases(key: &str) -> bool { pub(crate) mod definition { use super::{DefinitionField, Hit, PREFIX}; - const METHOD_NAME: &str = "x-forge-method-name"; - const GROUP_NAME: &str = "x-forge-group-name"; + pub(crate) const METHOD_NAME: &str = "x-forge-method-name"; + pub(crate) const GROUP_NAME: &str = "x-forge-group-name"; const SKIP: &str = "x-forge-skip"; - const AVAILABILITY: &str = "x-forge-availability"; + pub(crate) const AVAILABILITY: &str = "x-forge-availability"; const HIDDEN: &str = "x-forge-hidden"; const CONFIRMATION: &str = "x-forge-require-confirmation"; From 5cbfa30d3bf695b3c291a8a1d4904e74c0867e68 Mon Sep 17 00:00:00 2001 From: Niels Swimberghe Date: Wed, 28 Oct 2026 11:17:35 -0400 Subject: [PATCH 06/20] refactor: forge normalize takes files, and the shell expands patterns Review feedback: expanding a glob is a shell concern, and what to skip is the caller's to say. The inputs are files, or `-` for standard input to standard output. Folder expansion, glob expansion and the exclusion of hidden directories and `node_modules` are gone, and so is the `glob` dependency. A pattern the shell did not expand is a path that is not there, and a folder is an error that says so. A file named twice, or by two names of the same file, is still rewritten once. --- Cargo.lock | 7 -- Cargo.toml | 3 +- packages/forge/README.md | 14 ++- src/bin/forge/normalize.rs | 71 ++++----------- tests/normalize/normalize_cli.rs | 144 +++++++------------------------ 5 files changed, 54 insertions(+), 185 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0dd4ee06d..78b8482ed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -574,7 +574,6 @@ version = "0.1.0" dependencies = [ "clap", "divan", - "glob", "memchr", "rayon", "serde", @@ -701,12 +700,6 @@ 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 dcf2192b2..b74890f6e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,6 @@ edition = "2024" [dependencies] clap = { version = "4.6.7", features = ["derive"], optional = true } -glob = { version = "0.3.3", optional = true } memchr = "2.8.3" rayon = "1.12.0" serde = { version = "1.0.229", features = ["derive"] } @@ -27,7 +26,7 @@ harness = false default = ["cli"] # The `forge` command. Library users can turn it off with # `default-features = false`. -cli = ["dep:clap", "dep:glob"] +cli = ["dep:clap"] # `forge::wasm`, which runs plugins compiled to WebAssembly components. wasm = ["dep:wasmtime", "dep:wasmtime-wasi"] diff --git a/packages/forge/README.md b/packages/forge/README.md index 2c438365f..418a8d9fd 100644 --- a/packages/forge/README.md +++ b/packages/forge/README.md @@ -25,7 +25,7 @@ Run it before forge reads the document, with `--leave-old-extensions` if something else still reads the old keys: ```sh -npx forge normalize 'specs/**/*.json' +npx forge normalize specs/*.json ``` For now, `forge` also does this in memory when it reads a document, so one that @@ -34,12 +34,10 @@ temporary: it stops in the release that makes `forge normalize` a step to run first, and the extensions of other tools are then passed on to plugins like any other that forge does not read. -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 +It takes paths, which the shell can expand (`specs/*.json`), 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 @@ -62,7 +60,7 @@ as a warning if it says something else. 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 +npx forge normalize --check specs/*.json # Read from standard input, write to standard output, and generate from that. cat openapi.json | npx forge normalize - | npx forge - --target ts --out sdk diff --git a/src/bin/forge/normalize.rs b/src/bin/forge/normalize.rs index e2d3e8f73..8141db7ce 100644 --- a/src/bin/forge/normalize.rs +++ b/src/bin/forge/normalize.rs @@ -13,11 +13,9 @@ 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. + /// The OpenAPI JSON documents to rewrite, in place, as paths. A pattern + /// such as `specs/*.json` is expanded by the shell. 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 @@ -64,7 +62,7 @@ pub fn run(args: Args) -> Result<(), Err> { return filter(&options, say); } - let results: Vec<(PathBuf, Result)> = expand(&args.inputs)? + let results: Vec<(PathBuf, Result)> = unique_files(&args.inputs)? .into_par_iter() .map(|input| { let result = rewrite(&input.real, &options, say).map_err(|err| format!("{}: {err}", input.shown.display())); @@ -149,37 +147,20 @@ fn filter(options: &Options, say: Say) -> Result<(), Err> { 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> { +/// Returns the files `inputs` name, each once. A path that is not there is +/// left for reading it to say so, and a pattern such as `specs/*.json` is for +/// the shell to expand, so one that was not is a path that is not there. +fn unique_files(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 }); - } + if Path::new(input).is_dir() { + return Err(format!("{input}: is a folder, not a file").into()); + } + let shown = PathBuf::from(input); + let real = fs::canonicalize(&shown).unwrap_or_else(|_| shown.clone()); + if seen.insert(Identity::of(&real)) { + files.push(Input { shown, real }); } } Ok(files) @@ -207,28 +188,6 @@ impl Identity { } } -/// 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 { diff --git a/tests/normalize/normalize_cli.rs b/tests/normalize/normalize_cli.rs index add3b9100..be9970439 100644 --- a/tests/normalize/normalize_cli.rs +++ b/tests/normalize/normalize_cli.rs @@ -103,25 +103,6 @@ fn nothing_but_the_keys_that_are_rewritten_changes() { 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"); @@ -181,16 +162,6 @@ fn standard_input_cannot_be_given_with_files() { 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"], ""); @@ -206,7 +177,9 @@ fn a_file_that_is_not_json_is_an_error_and_the_others_are_still_rewritten() { // 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 (a, b) = (dir.join("a.json"), dir.join("b.json")); + + let out = normalize(&[a.to_str().unwrap(), b.to_str().unwrap()], ""); let err = failed(&out); assert!(err.contains("b.json"), "{err}"); @@ -318,84 +291,6 @@ fn check_on_standard_input_writes_nothing_and_fails_when_there_is_something_to_r 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"); @@ -494,10 +389,7 @@ fn a_file_given_more_than_once_is_rewritten_once() { for _ in 0..5 { fs::write(&file, FERN).unwrap(); - let out = normalize( - &[file.to_str().unwrap(), &again, &format!("{}/*.json", dir.display())], - "", - ); + let out = normalize(&[file.to_str().unwrap(), &again], ""); succeeded(&out); assert!( @@ -509,6 +401,34 @@ fn a_file_given_more_than_once_is_rewritten_once() { } } +#[test] +fn a_folder_is_an_error_that_says_so() { + let dir = dir("a-folder"); + fs::write(dir.join("a.json"), FERN).unwrap(); + + let out = normalize(&[dir.to_str().unwrap()], ""); + + let err = failed(&out); + assert!(err.contains("is a folder, not a file"), "{err}"); + assert_eq!( + fs::read_to_string(dir.join("a.json")).unwrap(), + FERN, + "nothing in it is rewritten" + ); +} + +#[test] +fn a_pattern_is_for_the_shell_and_is_not_expanded() { + let dir = dir("pattern"); + fs::write(dir.join("a.json"), FERN).unwrap(); + + let out = normalize(&[&format!("{}/*.json", dir.display())], ""); + + let err = failed(&out); + assert!(err.contains("*.json: "), "{err}"); + assert_eq!(fs::read_to_string(dir.join("a.json")).unwrap(), FERN); +} + #[test] fn a_file_whose_name_has_glob_characters_is_that_file() { let dir = dir("brackets"); From 00f943d6f581d1c819c2325ecd550e5422f61134 Mon Sep 17 00:00:00 2001 From: Niels Swimberghe Date: Wed, 28 Oct 2026 11:31:06 -0400 Subject: [PATCH 07/20] feat!: forge normalize changes nothing unless it is given --write Review feedback: a command that modifies files should say so. Without `--write`, `forge normalize` prints one document to standard output, rewritten, and leaves the file as it is; with several files it fails and says to use `--write` or `--check`. `--write` rewrites each file in place, only if something in it changes, as it did before. `--check` still writes nothing and fails if anything would change, and `-` still reads standard input and prints. `--write` cannot be given with `--check`, nor with `-`, which is not a file to rewrite. What it says when it prints is that the keys are "rewritten in the output", so that it does not sound like a file was changed. --- AGENTS.md | 2 +- packages/forge/README.md | 18 ++- rfcs/0001-metadata-resolution-layer.md | 3 +- src/bin/forge/main.rs | 6 +- src/bin/forge/normalize.rs | 73 +++++++++--- tests/normalize/normalize_cli.rs | 151 ++++++++++++++++++++----- 6 files changed, 204 insertions(+), 49 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4c12b8ff9..8f0388f61 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,7 +15,7 @@ API documentation site for Forge OpenAPI specs, built on Astro. Compatibility with other tools' extensions (`x-fern-*`, `x-stainless-*`, and `x-sensitive`) is sectioned off into its own area, `src/normalize/vendor/`, and nowhere else in the codebase knows them. `forge` does not read them: they are ordinary extensions to it, and `forge normalize` is what rewrites them into native ones. Any new compatibility key belongs in that area. See [`src/normalize/vendor/README.md`](src/normalize/vendor/README.md). - Never name a vendor extension outside `src/normalize/vendor/` and `tests/normalize/`, comments and docs included. `tests/boundary.rs` enforces this (`just boundary`). -- Run `forge normalize --leave-old-extensions` on a vendor-keyed document before `forge` reads it. The old keys stay for tools that still read them. Until the release that makes this step required, the `forge` command also does it in memory when it reads a document (`before_reading` in `src/bin/forge/normalize.rs`); that call is temporary, and the engine in `src/` never does it. +- Run `forge normalize --write --leave-old-extensions` on a vendor-keyed document before `forge` reads it. The old keys stay for tools that still read them. Until the release that makes this step required, the `forge` command also does it in memory when it reads a document (`before_reading` in `src/bin/forge/normalize.rs`); that call is temporary, and the engine in `src/` never does it. - 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. diff --git a/packages/forge/README.md b/packages/forge/README.md index 418a8d9fd..dc51dd447 100644 --- a/packages/forge/README.md +++ b/packages/forge/README.md @@ -25,7 +25,11 @@ Run it before forge reads the document, with `--leave-old-extensions` if something else still reads the old keys: ```sh -npx forge normalize specs/*.json +# Print a document with its keys rewritten. Nothing is changed. +npx forge normalize openapi.json + +# Rewrite the files in place. +npx forge normalize --write specs/*.json ``` For now, `forge` also does this in memory when it reads a document, so one that @@ -34,10 +38,12 @@ temporary: it stops in the release that makes `forge normalize` a step to run first, and the extensions of other tools are then passed on to plugins like any other that forge does not read. -It takes paths, which the shell can expand (`specs/*.json`), 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 +It takes paths, which the shell can expand (`specs/*.json`). Without `--write` it +changes nothing: it prints one document, rewritten, to standard output. With +`--write` it rewrites each file in place, and only if there is something to +rewrite in it, and it prints a line for each file to standard error, and `-v` +adds how many times each key was rewritten. Several files need `--write`, or +`--check`. 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 @@ -57,7 +63,7 @@ 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 +npx forge normalize --write --leave-old-extensions openapi.json # Write nothing, and fail if anything would be rewritten: for a check in CI. npx forge normalize --check specs/*.json diff --git a/rfcs/0001-metadata-resolution-layer.md b/rfcs/0001-metadata-resolution-layer.md index c815754ba..f8f5f4cf4 100644 --- a/rfcs/0001-metadata-resolution-layer.md +++ b/rfcs/0001-metadata-resolution-layer.md @@ -38,6 +38,7 @@ Not in scope: Fern's structural behavior (allOf/anyOf, inline request types), op ## What `forge normalize` does +- It changes nothing unless it is told to: one document is printed, rewritten, to standard output, and `--write` rewrites each file in place, only if something in it changes. `-` reads standard input and prints. `--check` writes nothing and fails if anything would change. - A key is rewritten only at the kind of object `forge` reads its native key at: an operation or an alias, a parameter, or a schema. - The document is edited as bytes, not written again: whitespace, key order and the text of every number stay as they were, so a diff is only the keys. - A native key is never replaced. When a document says the same thing twice, the native key is kept, and the other is dropped, or reported if it says something else. @@ -62,7 +63,7 @@ src/ ## Migration -- Run `forge normalize` before `forge` reads a document written with vendor keys, with `--leave-old-extensions` where something else still reads them. A pipeline is `cat spec.json | forge normalize - | forge - --target ts --out sdk`. +- Run `forge normalize --write` before `forge` reads a document written with vendor keys, with `--leave-old-extensions` where something else still reads them. A pipeline is `cat spec.json | forge normalize - | forge - --target ts --out sdk`. - Until the release that makes `forge normalize` a step to run first, the `forge` command does the same rewrite in memory when it reads a document, so one that has not been through it builds as before. It costs a pass over the document, and stops in that release; the core never reads anything but native keys. - After that release, a document that is not normalized is not an error: `forge` finds no method names in it, and so no methods. - Keys that are left are ordinary extensions, so plugins see them. diff --git a/src/bin/forge/main.rs b/src/bin/forge/main.rs index e0a09edb5..b2cc7eb51 100644 --- a/src/bin/forge/main.rs +++ b/src/bin/forge/main.rs @@ -42,9 +42,11 @@ struct Args { /// What `forge` does other than generate an SDK. #[derive(clap::Subcommand)] enum Command { - /// Rewrite other tools' extensions into the native x-forge-* ones, in place. + /// Rewrite other tools' extensions into the native x-forge-* ones. /// - /// A document that has none is left as it is. A native key is never + /// One document is printed to standard output, and nothing is changed, + /// unless --write is given, which rewrites the files 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. diff --git a/src/bin/forge/normalize.rs b/src/bin/forge/normalize.rs index 8141db7ce..3f40e74b2 100644 --- a/src/bin/forge/normalize.rs +++ b/src/bin/forge/normalize.rs @@ -13,11 +13,16 @@ 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, in place, as paths. A pattern - /// such as `specs/*.json` is expanded by the shell. Or - to read one from - /// standard input and write it to standard output. + /// The OpenAPI JSON documents, as paths. A pattern such as `specs/*.json` + /// is expanded by the shell. One document is printed to standard output, + /// with nothing changed, unless `--write` is given. Or - to read one from + /// standard input and print it. #[arg(required = true)] inputs: Vec, + /// Rewrite the files in place, each only if there is something to rewrite + /// in it. Without it nothing is changed, and one document is printed. + #[arg(short, long, conflicts_with = "check")] + write: bool, /// Keep each key that is rewritten, beside the native key that replaces /// it, for what still reads the old one. #[arg(long)] @@ -45,6 +50,9 @@ struct Input { struct Say { check: bool, verbose: bool, + /// Whether the document is printed to standard output, and no file is + /// rewritten, so that is what is said. + printed: bool, } pub fn run(args: Args) -> Result<(), Err> { @@ -54,15 +62,28 @@ pub fn run(args: Args) -> Result<(), Err> { let say = Say { check: args.check, verbose: args.verbose, + printed: !args.write && !args.check, }; if args.inputs.iter().any(|input| input == "-") { if args.inputs.len() > 1 { return Err("- cannot be given with other inputs".into()); } + if args.write { + return Err("- cannot be given with --write: standard input is not a file to rewrite".into()); + } return filter(&options, say); } - let results: Vec<(PathBuf, Result)> = unique_files(&args.inputs)? + let files = unique_files(&args.inputs)?; + if !args.write && !args.check { + // Nothing is changed, so there is one document to print. + let [file] = &files[..] else { + return Err("several files are only rewritten with --write, or checked with --check".into()); + }; + return print_file(file, &options, say); + } + + let results: Vec<(PathBuf, Result)> = files .into_par_iter() .map(|input| { let result = rewrite(&input.real, &options, say).map_err(|err| format!("{}: {err}", input.shown.display())); @@ -126,9 +147,23 @@ fn plural<'a>(n: usize, one: &'a str, many: &'a str) -> &'a str { 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}"))?; + document(&json, "stdin", options, say) +} + +/// Prints the document in the file to standard output, rewritten, and leaves +/// the file as it is. +fn print_file(input: &Input, options: &Options, say: Say) -> Result<(), Err> { + let name = input.shown.display().to_string(); + reject_yaml(&input.real).map_err(|err| format!("{name}: {err}"))?; + let json = fs::read(&input.real).map_err(|err| format!("{name}: {err}"))?; + document(&json, &name, options, say) +} + +/// Rewrites `json`, the document called `name`, to standard output. +fn document(json: &[u8], name: &str, options: &Options, say: Say) -> Result<(), Err> { + let normalized = normalize(json, options).map_err(|err| format!("{name}: {err}"))?; if say.check { - print("stdin", &normalized.report, say); + print(name, &normalized.report, say); if normalized.report.changed() { return Err("the document would be rewritten".into()); } @@ -137,13 +172,13 @@ fn filter(options: &Options, say: Say) -> Result<(), Err> { let mut out = io::stdout().lock(); match &normalized.json { Some(edited) => edited.write_to(&mut out)?, - None => out.write_all(&json)?, + 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); + print(name, &normalized.report, say); Ok(()) } @@ -188,9 +223,8 @@ impl Identity { } } -/// 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 { +/// Refuses the file at `path` if it is YAML, which is not supported yet. +fn reject_yaml(path: &Path) -> Result<(), Err> { let yaml = path .extension() .and_then(|extension| extension.to_str()) @@ -198,6 +232,13 @@ fn rewrite(path: &Path, options: &Options, say: Say) -> Result { if yaml { return Err("YAML is not supported yet: convert the document to JSON first".into()); } + Ok(()) +} + +/// 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 { + reject_yaml(path)?; let json = fs::read(path)?; let Normalized { json, report } = normalize(&json, options)?; if let Some(edited) = &json @@ -327,8 +368,14 @@ fn warn(name: &str, report: &Report) { 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")); + let keys = plural(total, "key", "keys"); + if say.check { + eprintln!("{name}: would rewrite {total} {keys}"); + } else if say.printed { + eprintln!("{name}: {total} {keys} rewritten in the output"); + } else { + eprintln!("{name}: rewrote {total} {keys}"); + } if say.verbose { for (key, count) in &report.migrated { eprintln!(" {key}: {count}"); diff --git a/tests/normalize/normalize_cli.rs b/tests/normalize/normalize_cli.rs index be9970439..7118813ff 100644 --- a/tests/normalize/normalize_cli.rs +++ b/tests/normalize/normalize_cli.rs @@ -26,6 +26,11 @@ fn normalize(args: &[&str], stdin: &str) -> Output { forge(&[&["normalize"], args].concat(), stdin) } +/// Runs `forge normalize --write` with `args`, which rewrites the files in place. +fn rewrite(args: &[&str], stdin: &str) -> Output { + normalize(&[&["--write"], 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")) @@ -67,7 +72,7 @@ fn a_file_is_rewritten_in_place_as_it_was_written() { let file = dir.join("spec.json"); fs::write(&file, FERN).unwrap(); - let out = normalize(&[file.to_str().unwrap()], ""); + let out = rewrite(&[file.to_str().unwrap()], ""); succeeded(&out); assert_eq!( @@ -95,7 +100,7 @@ fn nothing_but_the_keys_that_are_rewritten_changes() { 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()], "")); + succeeded(&rewrite(&[file.to_str().unwrap()], "")); let after = before .replace("\"x-fern-sdk-group-name\": [\"a\"]", "\"x-forge-group-name\": \"a\"") @@ -110,7 +115,7 @@ fn a_file_that_needs_nothing_is_left_as_it_is() { let written = format!("{NATIVE}\n\n"); fs::write(&file, &written).unwrap(); - let out = normalize(&[file.to_str().unwrap()], ""); + let out = rewrite(&[file.to_str().unwrap()], ""); succeeded(&out); assert_eq!(fs::read_to_string(&file).unwrap(), written); @@ -127,7 +132,7 @@ fn the_old_keys_can_be_left() { let file = dir.join("spec.json"); fs::write(&file, FERN).unwrap(); - succeeded(&normalize(&["--leave-old-extensions", file.to_str().unwrap()], "")); + succeeded(&rewrite(&["--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"]; @@ -164,7 +169,7 @@ fn standard_input_cannot_be_given_with_files() { #[test] fn a_file_that_is_missing_is_an_error_that_names_it() { - let out = normalize(&["missing.json"], ""); + let out = rewrite(&["missing.json"], ""); let err = failed(&out); assert!(err.contains("forge: missing.json: "), "{err}"); @@ -179,7 +184,7 @@ fn a_file_that_is_not_json_is_an_error_and_the_others_are_still_rewritten() { let (a, b) = (dir.join("a.json"), dir.join("b.json")); - let out = normalize(&[a.to_str().unwrap(), b.to_str().unwrap()], ""); + let out = rewrite(&[a.to_str().unwrap(), b.to_str().unwrap()], ""); let err = failed(&out); assert!(err.contains("b.json"), "{err}"); @@ -198,7 +203,7 @@ fn a_native_key_wins_and_the_one_that_differs_is_reported() { ) .unwrap(); - let out = normalize(&[file.to_str().unwrap()], ""); + let out = rewrite(&[file.to_str().unwrap()], ""); succeeded(&out); let err = text(&out.stderr); @@ -218,7 +223,7 @@ fn a_group_that_cannot_be_a_dotted_string_is_kept_and_nothing_is_said_about_it() let file = dir.join("spec.json"); fs::write(&file, FERN.replace(r#"["a"]"#, r#"["a.b"]"#)).unwrap(); - let out = normalize(&[file.to_str().unwrap()], ""); + let out = rewrite(&[file.to_str().unwrap()], ""); succeeded(&out); let err = text(&out.stderr); @@ -241,8 +246,8 @@ fn what_it_did_is_a_line_for_each_file_and_the_keys_are_listed_with_v() { } 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 = rewrite(&[a.to_str().unwrap()], ""); + let verbose = rewrite(&["-v", b.to_str().unwrap()], ""); let (quiet, verbose) = (text(&quiet.stderr), text(&verbose.stderr)); assert_eq!(quiet.lines().count(), 2, "{quiet}"); @@ -298,7 +303,7 @@ fn a_yaml_file_is_an_error_and_is_not_reported_as_native() { 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 out = rewrite(&[file.to_str().unwrap()], ""); let err = failed(&out); assert!(err.contains("YAML is not supported yet"), "{name}: {err}"); @@ -319,7 +324,7 @@ fn a_file_that_is_not_an_object_is_an_error_whatever_it_is_named() { fs::write(dir.join(name), content).unwrap(); let file = dir.join(name); - let out = normalize(&[file.to_str().unwrap()], ""); + let out = rewrite(&[file.to_str().unwrap()], ""); let err = failed(&out); assert!(err.contains(name), "{name}: {err}"); @@ -362,7 +367,7 @@ fn a_link_stays_a_link_and_the_file_it_points_to_is_rewritten() { 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()], ""); + let out = rewrite(&[dir.join("links/spec.json").to_str().unwrap()], ""); succeeded(&out); assert!( @@ -389,7 +394,7 @@ fn a_file_given_more_than_once_is_rewritten_once() { for _ in 0..5 { fs::write(&file, FERN).unwrap(); - let out = normalize(&[file.to_str().unwrap(), &again], ""); + let out = rewrite(&[file.to_str().unwrap(), &again], ""); succeeded(&out); assert!( @@ -401,12 +406,106 @@ fn a_file_given_more_than_once_is_rewritten_once() { } } +/// Nothing is changed unless `--write` says so. +#[test] +fn one_file_is_printed_rewritten_and_is_not_changed_without_write() { + let dir = dir("print"); + let file = dir.join("spec.json"); + fs::write(&file, FERN).unwrap(); + + let out = normalize(&[file.to_str().unwrap()], ""); + + succeeded(&out); + assert_eq!(text(&out.stdout), NATIVE); + assert_eq!(fs::read_to_string(&file).unwrap(), FERN, "the file is as it was"); + assert!( + text(&out.stderr).contains("spec.json: 2 keys rewritten in the output"), + "{}", + text(&out.stderr) + ); + assert!(!text(&out.stderr).contains("rewrote"), "nothing was written"); +} + +#[test] +fn a_file_with_nothing_to_rewrite_is_printed_as_it_is() { + let dir = dir("print-nothing"); + let file = dir.join("spec.json"); + fs::write(&file, NATIVE).unwrap(); + + let out = normalize(&[file.to_str().unwrap()], ""); + + succeeded(&out); + assert_eq!(text(&out.stdout), NATIVE); + assert_eq!(text(&out.stderr), ""); +} + +#[test] +fn several_files_are_only_rewritten_with_write_or_checked_with_check() { + let dir = dir("several"); + let (a, b) = (dir.join("a.json"), dir.join("b.json")); + fs::write(&a, FERN).unwrap(); + fs::write(&b, FERN).unwrap(); + let both = [a.to_str().unwrap(), b.to_str().unwrap()]; + + let out = normalize(&both, ""); + + let err = failed(&out); + assert!(err.contains("--write") && err.contains("--check"), "{err}"); + assert_eq!(text(&out.stdout), "", "nothing is printed"); + assert_eq!(fs::read_to_string(&a).unwrap(), FERN); + assert_eq!(fs::read_to_string(&b).unwrap(), FERN); + + // The same file twice is one file, which is printed. + succeeded(&normalize(&[both[0], both[0]], "")); +} + +#[test] +fn write_and_check_cannot_be_given_together() { + let dir = dir("write-check"); + let file = dir.join("spec.json"); + fs::write(&file, FERN).unwrap(); + + let out = normalize(&["--write", "--check", file.to_str().unwrap()], ""); + + let err = failed(&out); + assert!(err.contains("--write") && err.contains("--check"), "{err}"); + assert_eq!(fs::read_to_string(&file).unwrap(), FERN); +} + +#[test] +fn standard_input_cannot_be_rewritten_in_place() { + let out = normalize(&["--write", "-"], FERN); + + let err = failed(&out); + assert!(err.contains("- cannot be given with --write"), "{err}"); + assert_eq!(text(&out.stdout), ""); +} + +#[test] +fn write_rewrites_each_file_in_place_and_says_so() { + let dir = dir("write"); + let (a, b) = (dir.join("a.json"), dir.join("b.json")); + fs::write(&a, FERN).unwrap(); + fs::write(&b, NATIVE).unwrap(); + + let out = rewrite(&[a.to_str().unwrap(), b.to_str().unwrap()], ""); + + succeeded(&out); + assert_eq!(text(&out.stdout), "", "what is written is the files"); + assert_eq!(fs::read_to_string(&a).unwrap(), NATIVE); + assert!( + text(&out.stderr).contains("2 files: 1 rewritten, 1 unchanged"), + "{}", + text(&out.stderr) + ); +} + #[test] fn a_folder_is_an_error_that_says_so() { let dir = dir("a-folder"); fs::write(dir.join("a.json"), FERN).unwrap(); - let out = normalize(&[dir.to_str().unwrap()], ""); + let out = rewrite(&[dir.to_str().unwrap()], ""); let err = failed(&out); assert!(err.contains("is a folder, not a file"), "{err}"); @@ -422,7 +521,7 @@ fn a_pattern_is_for_the_shell_and_is_not_expanded() { let dir = dir("pattern"); fs::write(dir.join("a.json"), FERN).unwrap(); - let out = normalize(&[&format!("{}/*.json", dir.display())], ""); + let out = rewrite(&[&format!("{}/*.json", dir.display())], ""); let err = failed(&out); assert!(err.contains("*.json: "), "{err}"); @@ -435,7 +534,7 @@ fn a_file_whose_name_has_glob_characters_is_that_file() { let file = dir.join("spec[1].json"); fs::write(&file, FERN).unwrap(); - let out = normalize(&[file.to_str().unwrap()], ""); + let out = rewrite(&[file.to_str().unwrap()], ""); succeeded(&out); assert_eq!(fs::read_to_string(&file).unwrap(), NATIVE); @@ -451,7 +550,7 @@ fn a_read_only_file_is_not_rewritten() { fs::write(&file, FERN).unwrap(); fs::set_permissions(&file, fs::Permissions::from_mode(0o444)).unwrap(); - let out = normalize(&[file.to_str().unwrap()], ""); + let out = rewrite(&[file.to_str().unwrap()], ""); let check = normalize(&["--check", file.to_str().unwrap()], ""); let err = failed(&out); @@ -474,7 +573,7 @@ fn a_byte_order_mark_is_named_and_the_file_is_left_as_it_is() { let content = format!("\u{feff}{FERN}"); fs::write(&file, &content).unwrap(); - let out = normalize(&[file.to_str().unwrap()], ""); + let out = rewrite(&[file.to_str().unwrap()], ""); let err = failed(&out); assert!( @@ -494,7 +593,7 @@ fn a_key_written_twice_is_a_warning_that_says_where_and_the_file_is_rewritten() ) .unwrap(); - let out = normalize(&[file.to_str().unwrap()], ""); + let out = rewrite(&[file.to_str().unwrap()], ""); succeeded(&out); let err = text(&out.stderr); @@ -520,7 +619,7 @@ fn the_summary_counts_the_warnings() { ) .unwrap(); - let out = normalize(&[file.to_str().unwrap()], ""); + let out = rewrite(&[file.to_str().unwrap()], ""); succeeded(&out); assert!( @@ -536,7 +635,7 @@ fn standard_input_is_called_stdin_and_a_failure_on_it_says_so() { let broken = normalize(&["-"], "{\"x-fern-ignore\": "); assert!( - text(&out.stderr).starts_with("stdin: rewrote 2 keys"), + text(&out.stderr).starts_with("stdin: 2 keys rewritten in the output"), "{}", text(&out.stderr) ); @@ -549,7 +648,7 @@ fn v_says_how_many_group_paths_were_joined() { let file = dir.join("spec.json"); fs::write(&file, FERN).unwrap(); - let out = normalize(&["-v", file.to_str().unwrap()], ""); + let out = rewrite(&["-v", file.to_str().unwrap()], ""); assert!( text(&out.stderr).contains(" 1 group path written as a dotted string"), @@ -566,7 +665,7 @@ fn a_hard_link_stays_a_link_to_the_file_that_is_rewritten() { fs::write(&a, FERN).unwrap(); fs::hard_link(&a, &b).unwrap(); - succeeded(&normalize(&[a.to_str().unwrap()], "")); + succeeded(&rewrite(&[a.to_str().unwrap()], "")); assert_eq!(fs::read_to_string(&a).unwrap(), NATIVE); assert_eq!( @@ -584,7 +683,7 @@ fn two_names_of_one_file_are_one_file_and_are_rewritten_once() { fs::write(&a, FERN).unwrap(); fs::hard_link(&a, &b).unwrap(); - let out = normalize(&[a.to_str().unwrap(), b.to_str().unwrap()], ""); + let out = rewrite(&[a.to_str().unwrap(), b.to_str().unwrap()], ""); succeeded(&out); assert!( @@ -611,7 +710,7 @@ fn a_file_in_a_folder_that_cannot_be_written_to_is_rewritten_in_place() { return; } - let out = normalize(&[file.to_str().unwrap()], ""); + let out = rewrite(&[file.to_str().unwrap()], ""); fs::set_permissions(&dir, fs::Permissions::from_mode(0o755)).unwrap(); succeeded(&out); From 55ca723421c82f8238e6f298e9d23e98c21af058 Mon Sep 17 00:00:00 2001 From: Niels Swimberghe Date: Wed, 28 Oct 2026 15:12:13 -0400 Subject: [PATCH 08/20] refactor!: forge normalize reads the document and writes it again An alternative to the byte-level edit of the branch this is cut from, for comparison: the document is read with serde_json, the keys are rewritten in the tree, and it is written again, with its key order (`preserve_order`) and the indentation of its first indented line kept. The scanner, `Edited` and the vectored writer are gone, and so is what only they could do. It is simple, and it is not a minimal edit. What it gets wrong is in the README of the package under "Known gaps", most serious first: - a key written twice loses its first copy, with no error: `forge` itself rejects one it reads, and no longer does in a document that has a key to rewrite - a number that does not fit in 64 bits is rounded, and `1E5` and `0.10` are written `100000.0` and `0.1` - a document nested deeper than 128 levels is an error - `\r\n` is written as `\n`, an array on one line is written on several, and escapes are written as the characters - a key written with an escape is not found by the step before `forge` reads a document, which only reads a document that has the text of a key to rewrite in it - JSON with comments is an error, as is a byte order mark - about 5 times the time and 6 times the memory of the byte scanner for `forge normalize` (100 ms and 185 MB, against 20 ms and 32 MB, for a document of 28 MB), and 90 ms and 120 MB more for a run of `forge` `Report::left` and `Left` are gone: nothing can be left as it is, as the document is written again, and a key written twice is not seen. Each gap is pinned by a test in `tests/normalize/known_gaps.rs`, and the tests of what was meant to be are ignored, with `known gap` as the reason. The SDK built from the 28 MB aggregate is byte for byte the one the engine built before, in 3,243 files. --- Cargo.lock | 1 + Cargo.toml | 2 +- packages/forge/README.md | 31 +- rfcs/0001-metadata-resolution-layer.md | 5 +- src/bin/forge/normalize.rs | 53 +- src/normalize/mod.rs | 200 ++++---- src/normalize/scan.rs | 678 ------------------------- src/normalize/vendor/mod.rs | 12 + src/normalize/vendor/tests.rs | 73 +-- src/normalize/walk.rs | 448 ++++++---------- tests/normalize/common.rs | 33 +- tests/normalize/fern.rs | 41 +- tests/normalize/known_gaps.rs | 149 ++++++ tests/normalize/main.rs | 6 +- tests/normalize/normalize.rs | 229 +-------- tests/normalize/normalize_cli.rs | 3 + tests/normalize/stainless.rs | 22 +- 17 files changed, 528 insertions(+), 1458 deletions(-) delete mode 100644 src/normalize/scan.rs create mode 100644 tests/normalize/known_gaps.rs diff --git a/Cargo.lock b/Cargo.lock index 78b8482ed..51d2bfcaf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1229,6 +1229,7 @@ version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ + "indexmap", "itoa", "memchr", "serde", diff --git a/Cargo.toml b/Cargo.toml index b74890f6e..49d72ab54 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ clap = { version = "4.6.7", features = ["derive"], optional = true } memchr = "2.8.3" rayon = "1.12.0" serde = { version = "1.0.229", features = ["derive"] } -serde_json = "1.0.151" +serde_json = { version = "1.0.151", features = ["preserve_order"] } wasmtime = { version = "49.0.2", default-features = false, features = ["runtime", "cranelift", "component-model", "parallel-compilation", "std"], optional = true } wasmtime-wasi = { version = "49.0.2", default-features = false, features = ["p2"], optional = true } diff --git a/packages/forge/README.md b/packages/forge/README.md index dc51dd447..7d6765b7f 100644 --- a/packages/forge/README.md +++ b/packages/forge/README.md @@ -49,18 +49,37 @@ 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 skip for only some targets, which another tool writes +The keys change, and a file that has one is written again, with the order of +its keys and the indentation of its first indented line kept: see "Known gaps" +below for what else is not as it was. A file with nothing to rewrite is never +written again. A group path 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 skip for only some targets, which another tool writes as a list of them, has no native form, since `x-forge-skip` is for every target: it is left exactly as it is written, whichever mode is used, while a skip that is plainly true or false is rewritten. 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. +### Known gaps + +The file is read as JSON and written again, which is simple, and is not a minimal +edit. These are what that gets wrong, most serious first. Each is pinned by a +test in `tests/normalize/known_gaps.rs`, and a test of what was meant to be is +ignored, with `known gap` as the reason. + +| Gap | What happens | +| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| A key written twice | The first copy is dropped, with no error and no warning. `forge` itself rejects a key it reads that is written twice, and no longer does in a document that has a key to rewrite. | +| Numbers | One that does not fit in 64 bits is rounded: `100000000000000000001` becomes `1e+20`, and a decimal of more than about 17 digits is shortened. `1E5` is written `100000.0`, and `0.10` is written `0.1`. | +| Deep documents | One nested deeper than 128 levels is an error, wherever it is nested. | +| Line endings | `\r\n` is written as `\n`, so every line of the file is in the diff. | +| Layout | An array or an object that is on one line is written on several (3.6% of the lines of a formatted document changed in a test), and the spaces of a document on one line are dropped. | +| Escapes | `\u00e9`, `\/` and `\u003c` are written as the characters. | +| Escaped keys | A key written with an escape is rewritten by `forge normalize`, and not by the step `forge` does before it reads a document, which looks for the text of a key. | +| Comments | JSON with comments is an error, and so is a byte order mark. | +| Cost | `forge normalize` takes about 5 times as long, and 6 times the memory, of an edit of bytes: 100 ms and 185 MB for a document of 28 MB, against 20 ms and 32 MB. The step in `forge` adds 90 ms and 120 MB to a run of 240 ms and 150 MB. | + ```sh # Keep the old keys beside the native ones, for what still reads them. npx forge normalize --write --leave-old-extensions openapi.json diff --git a/rfcs/0001-metadata-resolution-layer.md b/rfcs/0001-metadata-resolution-layer.md index f8f5f4cf4..b5df9182d 100644 --- a/rfcs/0001-metadata-resolution-layer.md +++ b/rfcs/0001-metadata-resolution-layer.md @@ -40,7 +40,7 @@ Not in scope: Fern's structural behavior (allOf/anyOf, inline request types), op - It changes nothing unless it is told to: one document is printed, rewritten, to standard output, and `--write` rewrites each file in place, only if something in it changes. `-` reads standard input and prints. `--check` writes nothing and fails if anything would change. - A key is rewritten only at the kind of object `forge` reads its native key at: an operation or an alias, a parameter, or a schema. -- The document is edited as bytes, not written again: whitespace, key order and the text of every number stay as they were, so a diff is only the keys. +- The document is read and written again, with its key order and the indentation of its first indented line kept. That is simple, and it is not a minimal edit: what it changes beyond the keys is listed under "Known gaps" in the README of the package. - A native key is never replaced. When a document says the same thing twice, the native key is kept, and the other is dropped, or 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. - A skip on a property is rewritten to `x-forge-skip` with the same value: `x-stainless-skip: true` becomes `x-forge-skip: true`. `x-forge-skip` is for every target, so a skip for only some targets, written as a list (`x-stainless-skip: ["terraform"]`), has no native form and is left as it is written, in both modes. @@ -57,8 +57,7 @@ src/ └─ normalize/ ├─ vendor/ the tables of other tools' keys. The only code holding │ them, sectioned off, and per level. - ├─ walk.rs where a key is to be rewritten, and to what. - └─ scan.rs reading JSON as bytes, and editing it where it is read. + └─ walk.rs where a key is to be rewritten, and to what. ``` ## Migration diff --git a/src/bin/forge/normalize.rs b/src/bin/forge/normalize.rs index 3f40e74b2..2ca57df2c 100644 --- a/src/bin/forge/normalize.rs +++ b/src/bin/forge/normalize.rs @@ -7,7 +7,7 @@ use std::io::{self, Read, Write}; use std::path::{Path, PathBuf}; use forge::Err; -use forge::normalize::{Edited, Normalized, Options, Report, normalize}; +use forge::normalize::{Normalized, Options, Report, mentions_old_keys, normalize}; use rayon::prelude::*; /// What `forge normalize` is given, which says what it does in `main.rs`. @@ -100,7 +100,7 @@ pub fn run(args: Args) -> Result<(), Err> { } else { unchanged += 1; } - warnings += report.conflicts.len() + report.left.len(); + warnings += report.conflicts.len(); print(&path.display().to_string(), &report, say); } Err(err) => { @@ -171,7 +171,7 @@ fn document(json: &[u8], name: &str, options: &Options, say: Say) -> Result<(), } let mut out = io::stdout().lock(); match &normalized.json { - Some(edited) => edited.write_to(&mut out)?, + Some(edited) => out.write_all(edited)?, None => out.write_all(json)?, } // Standard output is held until a line is whole, and what is left at the @@ -257,7 +257,7 @@ fn rewrite(path: &Path, options: &Options, say: Say) -> Result { /// 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> { +fn write(path: &Path, edited: &[u8]) -> Result<(), Err> { if linked(path) { return Ok(write_in_place(path, edited)?); } @@ -267,8 +267,8 @@ fn write(path: &Path, edited: &Edited) -> Result<(), Err> { } } -fn write_in_place(path: &Path, edited: &Edited) -> io::Result<()> { - edited.write_to(&mut fs::File::create(path)?) +fn write_in_place(path: &Path, edited: &[u8]) -> io::Result<()> { + fs::File::create(path)?.write_all(edited) } /// Returns whether the file at `path` has other names. @@ -284,7 +284,7 @@ fn linked(_: &Path) -> bool { false } -fn write_atomically(path: &Path, edited: &Edited) -> io::Result<()> { +fn write_atomically(path: &Path, edited: &[u8]) -> 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(); @@ -293,7 +293,7 @@ fn write_atomically(path: &Path, edited: &Edited) -> io::Result<()> { // 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) + file.write_all(edited) }) .and_then(|()| fs::rename(&temporary, path)); if written.is_err() { @@ -319,32 +319,21 @@ fn create_private(path: &Path) -> io::Result { /// Rewrites the extensions other tools write in the document `json` into the /// native ones, in memory, as `forge normalize` would in a file. /// -/// A key written twice, which it has to leave, is an error here, worded as it -/// was when `forge` read those keys itself. `forge normalize` goes on and says -/// it, and this does not say anything else of the document, as `forge` has not -/// before. A value it cannot read is left for `forge` to say, as it reads it. +/// A document that has none of the text of a key to rewrite is not read, and is +/// as it was. One that has is read and written again, which is not what +/// `forge` did before: see "Known gaps" in the README of the package. A key +/// written twice in it, say, is no longer an error here, as the second is the +/// one that is read. /// /// This is temporary. `forge` reads native keys only, and `forge normalize` is /// what is meant to come first. This keeps a document that has not been through -/// it building as it did, until the release that stops doing it. One thing it -/// does not keep: an error `forge` finds later in the document says the column -/// of the document that was rewritten, which is not the one that was read where -/// a key was renamed earlier on the same line. The line is the same. +/// it building as it did, until the release that stops doing it. pub fn before_reading(json: Vec) -> Result, Err> { - let normalized = normalize(&json, &Options::default())?; - // The first in the document, as `forge` would have come to it. - let twice = (normalized.report.left.iter()) - .filter(|left| left.written_twice()) - .min_by_key(|left| (left.line, left.column)); - if let Some(left) = twice { - return Err(format!( - "duplicate field `{}` at line {} column {}", - left.key, left.line, left.column - ) - .into()); + if !mentions_old_keys(&json) { + return Ok(json); } - let edited = normalized.json.map(|edited| edited.to_vec()); - Ok(edited.unwrap_or(json)) + let normalized = normalize(&json, &Options::default())?; + Ok(normalized.json.unwrap_or(json)) } /// Says what in the document called `name` could not be rewritten, or was @@ -356,12 +345,6 @@ fn warn(name: &str, report: &Report) { conflict.location, conflict.key, conflict.native ); } - for left in &report.left { - eprintln!( - "{name}: warning: {}: {} {}, and is left as it is", - left.location, left.key, left.why - ); - } } /// Says what was done to the document called `name`, and what was not. diff --git a/src/normalize/mod.rs b/src/normalize/mod.rs index 7c624a131..55275cd6d 100644 --- a/src/normalize/mod.rs +++ b/src/normalize/mod.rs @@ -13,31 +13,21 @@ //! 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; +//! The document is read with `serde_json` and written again, in the order its +//! keys were in, with the indentation of its first indented line. That is +//! simple, and it is not a minimal edit: what is lost is listed in the README +//! of the package under "Known gaps", and each one has a test that is ignored +//! with the reason, to be run again when it is fixed. + mod vendor; 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; +use serde::Serialize; +use serde_json::Value; +use serde_json::ser::PrettyFormatter; /// What [`normalize`] does. #[derive(Clone, Copy, Debug, Default)] @@ -55,11 +45,6 @@ pub struct Report { pub migrated: BTreeMap, /// The keys that say something other than the native key beside them. pub conflicts: Vec, - /// The keys that were left as they are written, because there was no - /// saying what they mean: one that is written twice, or has a value that - /// cannot be read. It is for whatever reads the document next to say if - /// that is wrong. - pub left: 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, @@ -72,36 +57,6 @@ impl Report { } } -/// A key that was left as it is written. -#[derive(Debug)] -pub struct Left { - /// Where the object is, as a JSON Pointer. - pub location: String, - /// The key. - pub key: String, - /// Why it was left, which follows its name: `is written twice`. - pub why: &'static str, - /// The line, from 1, and the column, which is how many bytes of the line - /// there are before it, of where a parser that refused it would be: after - /// the name of the second copy of a key that is written twice, and at the - /// start of a value that cannot be read. - pub line: usize, - pub column: usize, -} - -impl Left { - /// Returns whether the key is left for being written twice. - pub fn written_twice(&self) -> bool { - self.why == WRITTEN_TWICE - } -} - -/// Why a key that is written twice is left. -const WRITTEN_TWICE: &str = "is written twice in this object"; - -/// Why a key whose value cannot be read is left. -const UNREADABLE: &str = "has a value that cannot be read"; - /// A key that says something other than the native key beside it, which /// wins. #[derive(Debug)] @@ -116,9 +71,9 @@ pub struct Conflict { /// A document after [`normalize`]. #[derive(Debug)] -pub struct Normalized<'j> { +pub struct Normalized { /// The document, if it is different from what was read. - pub json: Option>, + pub json: Option>, /// What was done to it. pub report: Report, } @@ -141,8 +96,15 @@ impl From for Error { } } +/// Returns whether `json` has the text of a key that another tool writes. A +/// document that does not has nothing to rewrite, which is told without +/// reading it. A key written with an escape is not found. +pub fn mentions_old_keys(json: &[u8]) -> bool { + vendor::mentions(json) +} + /// Rewrites the keys of the document `json` that a native key replaces. -pub fn normalize<'j>(json: &'j [u8], options: &Options) -> Result, Error> { +pub fn normalize(json: &[u8], options: &Options) -> Result { 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" @@ -152,9 +114,8 @@ pub fn normalize<'j>(json: &'j [u8], options: &Options) -> Result 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. + // Without this, a document that is not JSON, YAML for one, would be + // reported as the error of a parser that expected something else. 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'{') => {} @@ -165,61 +126,94 @@ pub fn normalize<'j>(json: &'j [u8], options: &Options) -> Result } } - let (edited, report) = check_and_walk(json, options, json.len() >= PARALLEL)?; - let Some(edited) = edited else { - return Ok(Normalized { json: None, report }); + let mut document: Value = match serde_json::from_slice(json) { + Ok(document) => document, + Err(err) => { + return Err(match std::str::from_utf8(json) { + Err(utf8) => Error(format!( + "the document is not UTF-8: it is not from byte {}", + utf8.valid_up_to() + )), + Ok(_) => err.into(), + }); + } }; - debug_assert!(check(&edited.to_vec()).is_ok(), "the edits made the document not JSON"); + let report = walk::walk(&mut document, options); + if !report.changed() { + return Ok(Normalized { json: None, report }); + } Ok(Normalized { - json: Some(edited), + json: Some(write(&document, json)), 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; +/// Writes `document` as `original` was written, as far as it is known: with +/// its indentation, which is that of the first line that is indented, on one +/// line if it was, and with a newline at the end if it had one. +fn write(document: &Value, original: &[u8]) -> Vec { + let mut out = Vec::with_capacity(original.len() + original.len() / 16); + match indentation(original) { + Some(indent) => { + let mut serializer = serde_json::Serializer::with_formatter(&mut out, PrettyFormatter::with_indent(indent)); + document + .serialize(&mut serializer) + .expect("a document is written as JSON to the memory"); } + None => serde_json::to_writer(&mut out, document).expect("a document is written as JSON to the memory"), } - check(json)?; - walk::walk(json, options) + if original.ends_with(b"\n") { + out.push(b'\n'); + } + out } -/// 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() - ))); +/// Returns what `original` is indented with, or none if it is on one line. +fn indentation(original: &[u8]) -> Option<&[u8]> { + let first = memchr::memchr(b'\n', original)?; + for line in original[first + 1..].split(|byte| *byte == b'\n') { + let blanks = line.iter().take_while(|byte| matches!(byte, b' ' | b'\t')).count(); + if blanks > 0 && blanks < line.len() { + return Some(&line[..blanks]); + } } - serde_json::from_slice::(json)?; - Ok(()) + Some(b"") } -/// 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) +mod tests { + use super::*; + + #[test] + fn the_indentation_is_that_of_the_first_line_that_is_indented() { + assert_eq!(indentation(b"{\n \"a\": {\n \"b\": 1\n }\n}"), Some(&b" "[..])); + assert_eq!(indentation(b"{\n\t\"a\": 1\n}"), Some(&b"\t"[..])); + assert_eq!(indentation(b"{\n \"a\": 1\n}"), Some(&b" "[..])); + } + + #[test] + fn a_document_on_one_line_is_written_on_one_line() { + assert_eq!(indentation(br#"{"a":1}"#), None); + assert_eq!( + write(&serde_json::json!({"a": 1, "b": [1, 2]}), br#"{"a":1}"#), + br#"{"a":1,"b":[1,2]}"# + ); + } + + #[test] + fn a_document_with_no_indented_line_is_written_with_none() { + assert_eq!(indentation(b"{\n\"a\": 1\n}\n"), Some(&b""[..])); + } + + #[test] + fn a_blank_line_with_spaces_is_not_the_indentation() { + assert_eq!(indentation(b"{\n \n \"a\": 1\n}"), Some(&b" "[..])); + } + + #[test] + fn the_newline_at_the_end_is_kept_if_there_was_one() { + let document = serde_json::json!({"a": 1}); + assert!(write(&document, b"{\n \"a\": 1\n}\n").ends_with(b"}\n")); + assert!(write(&document, b"{\n \"a\": 1\n}").ends_with(b"}")); + } } diff --git a/src/normalize/scan.rs b/src/normalize/scan.rs deleted file mode 100644 index 3188b2bd3..000000000 --- a/src/normalize/scan.rs +++ /dev/null @@ -1,678 +0,0 @@ -//! 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 the line, from 1, and the column, which is how many bytes of the - /// line there are before `at`, that a parser that has read up to `at` says - /// it is at: `serde_json` says it so, and an error that names where is - /// worded the same. - pub(super) fn consumed(&self, at: usize) -> (usize, usize) { - 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); - (line, column) - } - - /// 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. - /// A key that cannot be read, an escape that is a lone surrogate, is called - /// by what it is written as, which is no name there is a use for. - pub(super) fn name(&self, member: &Member) -> Cow<'j, [u8]> { - if member.escaped - && let Ok(key) = self.key(member) - { - return Cow::Owned(key.into_owned().into_bytes()); - } - 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/vendor/mod.rs b/src/normalize/vendor/mod.rs index 0fd9725d5..79ebebdfe 100644 --- a/src/normalize/vendor/mod.rs +++ b/src/normalize/vendor/mod.rs @@ -41,6 +41,18 @@ impl Key { } } +/// The text that every key of an adapter has in it, for finding whether a +/// document has one without reading it. +const MENTIONS: [&str; 3] = ["x-fern-", "x-stainless-", "x-sensitive"]; + +/// Returns whether `json` has the text of a key that an adapter reads in it +/// somewhere, which is the least that it has to for there to be one. +pub(super) fn mentions(json: &[u8]) -> bool { + MENTIONS + .iter() + .any(|text| memchr::memmem::find(json, text.as_bytes()).is_some()) +} + /// Returns what `key` says on an operation or an alias, if an adapter reads /// it there. pub(super) fn definition(key: &str) -> Option> { diff --git a/src/normalize/vendor/tests.rs b/src/normalize/vendor/tests.rs index 1baff2a74..38a1320ef 100644 --- a/src/normalize/vendor/tests.rs +++ b/src/normalize/vendor/tests.rs @@ -4,7 +4,7 @@ use super::*; use crate::metadata; -use crate::normalize::{Options, check, check_and_walk, normalize, walk_unchecked}; +use crate::normalize::{Options, normalize}; /// A document that has what is walked in it, with keys to rewrite at /// each place, one written with an escape. @@ -40,60 +40,15 @@ fn damaged(document: &str) -> Vec> { 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() { +fn a_damaged_document_is_an_error_or_is_rewritten_as_json_and_never_panics() { 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(); + if let Ok(normalized) = normalize(&document, &options) + && let Some(json) = normalized.json + { + serde_json::from_slice::(&json).unwrap(); rewritten += 1; } } @@ -101,6 +56,22 @@ fn what_is_written_from_a_damaged_document_that_is_still_json_is_json() { assert!(rewritten > 0, "some of them were still JSON, with keys to rewrite"); } +#[test] +fn a_document_that_has_a_key_of_the_tables_is_found_to_mention_one() { + let names: Vec<&str> = [ + &fern::definition::NAMES[..], + &fern::parameter::NAMES, + &fern::schema::NAMES, + &stainless::schema::NAMES, + &unprefixed::schema::NAMES, + ] + .concat(); + for name in names { + assert!(mentions(format!(r#"{{"{name}": true}}"#).as_bytes()), "{name}"); + } + assert!(!mentions(br#"{"x-forge-skip": true, "description": "x-forge"}"#)); +} + /// The keys of each kind of object that the tables read, with what each says. fn definitions() -> Vec<(&'static str, Key)> { fern::definition::NAMES diff --git a/src/normalize/walk.rs b/src/normalize/walk.rs index c5d5747fd..b7ea20725 100644 --- a/src/normalize/walk.rs +++ b/src/normalize/walk.rs @@ -3,21 +3,15 @@ //! 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. +//! passed over. The keys of an object are rewritten once its members have been +//! walked. -use serde_json::Value; +use serde_json::{Map, Value}; -use super::scan::{Action, Edited, Member, Scan}; use super::vendor::{self, Key}; -use super::{Conflict, Error, Left, Options, Report, UNREADABLE, WRITTEN_TWICE}; +use super::{Conflict, Options, Report}; use crate::metadata::{self, DefinitionField, ParameterField, SchemaField}; -/// 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; @@ -28,9 +22,9 @@ trait Level { /// Returns the native key that says what `field` says. fn native(field: Self::Field) -> &'static str; - /// Returns the JSON that `value`, written as `field` says, is written as + /// Returns the value 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 { + fn written(_field: Self::Field, _value: &Value) -> Option { None } @@ -58,12 +52,11 @@ impl Level for Definition { /// 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 { + 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")) + dotted(names).map(Value::String) } fn same(field: DefinitionField, a: &Value, b: &Value) -> bool { @@ -123,371 +116,251 @@ impl Level for Schema { } } -/// A step on the way to an object, kept as where it is in the document: it is -/// only written out for a conflict. +/// A step on the way to an object, which is only written out for a conflict. enum Step { - /// A key, as its quotes and what is between them. - Key(usize, usize), + Key(String), 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> { +/// What is done to a member of an object. +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. + Remove, + /// The native key is written before it, with its value, or with this one, + /// and it stays. + Insert(&'static str, Option), +} + +/// Walks a document, which is changed, and returns what was done. +pub(super) fn walk(document: &mut Value, options: &Options) -> Report { 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)) + walk.document(document); + walk.report } -/// Notes that the key `key` of the object being read was left as it is -/// written, for `why`. A macro and not a method, so that what is borrowed of the -/// walk to decide it can be borrowed still. -macro_rules! leave { - ($walk:ident, $key:expr, $why:expr, $at:expr) => {{ - let location = $walk.pointer(); - let (line, column) = $walk.scan.consumed($at); - $walk.report.left.push(Left { - location, - key: $key.to_string(), - why: $why, - line, - column, - }); - }}; -} - -struct Walk<'j, 'o> { - scan: Scan<'j>, +struct Walk<'o> { 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(); +impl Walk<'_> { + /// Reads the member `key` of an object with `child`. + fn enter(&mut self, key: &str, value: &mut Value, child: fn(&mut Self, &mut Value)) { + self.path.push(Step::Key(key.to_owned())); + child(self, value); + self.path.pop(); + } + + fn document(&mut self, document: &mut Value) { + let Some(members) = document.as_object_mut() else { + return; }; - 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()?, + for (key, value) in members.iter_mut() { + match key.as_str() { + "paths" => self.enter(key, value, |walk, paths| walk.each_value(paths, Self::path_item)), + "components" => self.enter(key, value, Self::components), + _ => {} } - 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(); + /// Reads each member of an object with `child`, and skips a value that is + /// not an object. + fn each_value(&mut self, value: &mut Value, child: fn(&mut Self, &mut Value)) { + let Some(members) = value.as_object_mut() else { + return; }; - 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); + for (key, value) in members.iter_mut() { + self.enter(key, value, child); } - 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()? { + /// Reads each element of an array with `child`, and skips a value that is + /// not an array. + fn each_element(&mut self, value: &mut Value, child: fn(&mut Self, &mut Value)) { + let Some(elements) = value.as_array_mut() else { + return; + }; + for (index, element) in elements.iter_mut().enumerate() { self.path.push(Step::Index(index)); - child(self)?; + child(self, element); 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); + /// Reads the member of an object that is called `name` with `child`. + fn only(&mut self, value: &mut Value, name: &str, child: fn(&mut Self, &mut Value)) { + if let Some(member) = value.as_object_mut().and_then(|members| members.get_mut(name)) { + self.enter(name, member, child); } - Ok(()) } - fn components(&mut self) -> Result<(), Error> { - let Some(mut open) = self.scan.open_object()? else { - return self.scan.skip_value(); + fn components(&mut self, value: &mut Value) { + let Some(members) = value.as_object_mut() else { + return; }; - 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()?, + for (key, value) in members.iter_mut() { + match key.as_str() { + "schemas" => self.enter(key, value, |walk, v| walk.each_value(v, Self::schema)), + "parameters" => self.enter(key, value, |walk, v| walk.each_value(v, Self::parameter)), + "requestBodies" | "responses" => self.enter(key, value, |walk, v| walk.each_value(v, Self::content_of)), + "pathItems" => self.enter(key, value, |walk, v| walk.each_value(v, Self::path_item)), + _ => {} } - 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(); + fn path_item(&mut self, value: &mut Value) { + let Some(members) = value.as_object_mut() else { + return; }; - 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()?; + for (key, value) in members.iter_mut() { + match key.as_str() { + "parameters" => self.enter(key, value, |walk, v| walk.each_element(v, Self::parameter)), + "get" | "put" | "post" | "delete" | "options" | "head" | "patch" | "trace" | "query" | "connect" => { + self.enter(key, value, Self::operation); } - b"additionalOperations" => self.each_value(Self::operation)?, - _ => self.scan.skip_value()?, + "additionalOperations" => self.enter(key, value, |walk, v| walk.each_value(v, Self::operation)), + _ => {} } - 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 operation(&mut self, value: &mut Value) { + self.object::(value, |walk, key, child| match key { + "parameters" => walk.enter(key, child, |walk, v| walk.each_element(v, Self::parameter)), + "requestBody" => walk.enter(key, child, Self::content_of), + "responses" => walk.enter(key, child, |walk, v| walk.each_value(v, Self::content_of)), + "x-forge-aliases" => walk.enter(key, child, |walk, v| walk.each_element(v, Self::alias)), + _ => {} + }); } - fn parameter(&mut self) -> Result<(), Error> { - self.object::(Self::parameter_children) + fn alias(&mut self, value: &mut Value) { + self.object::(value, |_, _, _| {}); } - fn parameter_children(&mut self, key: &[u8]) -> Result { - match key { - b"schema" => self.schema()?, - b"content" => self.media_types()?, - _ => return Ok(false), - } - Ok(true) + fn parameter(&mut self, value: &mut Value) { + self.object::(value, |walk, key, child| match key { + "schema" => walk.enter(key, child, Self::schema), + "content" => walk.enter(key, child, Self::media_types), + _ => {} + }); } /// 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 content_of(&mut self, value: &mut Value) { + self.only(value, "content", Self::media_types); } - fn media_types(&mut self) -> Result<(), Error> { - self.each_value(Self::media) + fn media_types(&mut self, value: &mut Value) { + self.each_value(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 media(&mut self, value: &mut Value) { + self.only(value, "schema", Self::schema); } - 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) + fn schema(&mut self, value: &mut Value) { + self.object::(value, |walk, key, child| match key { + "properties" | "$defs" => walk.enter(key, child, |walk, v| walk.each_value(v, Self::schema)), + "additionalProperties" | "unevaluatedProperties" | "not" => walk.enter(key, child, Self::schema), + "items" if child.is_object() => walk.enter(key, child, Self::schema), + "items" | "prefixItems" | "allOf" | "anyOf" | "oneOf" => { + walk.enter(key, child, |walk, v| walk.each_element(v, Self::schema)); + } + _ => {} + }); } - /// 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(); + /// Reads the object, which is of the kind `L`: each member with + /// `children`, and then the keys of the object that are to be rewritten. + fn object(&mut self, value: &mut Value, children: fn(&mut Self, &str, &mut Value)) { + let Some(members) = value.as_object_mut() else { + return; }; - 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); + for (key, child) in members.iter_mut() { + extension |= key.starts_with("x-"); + children(self, key, child); } if extension { - self.rewrite::(base); + self.rewrite::(members); } - 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) { - 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; - } - // A key that cannot be read is no key there is a use for. - let Ok(key) = self.scan.key(&member) else { - continue; - }; + /// Decides what to do to the keys of the object, which is of the kind `L`, + /// that a native key replaces, and does it. + fn rewrite(&mut self, members: &mut Map) { + let mut actions: Vec<(String, Action)> = Vec::new(); + for (key, value) in members.iter() { if !key.starts_with("x-") { continue; } - let Some(hit) = L::read(&key) else { + let Some(hit) = L::read(key) else { continue; }; let native = L::native(hit.field); - let members = &self.members[base..]; - // A key written twice has no one thing to say: each is left, and - // said once, at the first. - if let Some(other) = members - .iter() - .position(|other| other.key_start != member.key_start && self.scan.key_is(other, &key)) - { - if member.key_start < members[other].key_start { - // As a parser that read it would say: after the second. - let after_the_second = members[other].key_end; - leave!(self, &key, WRITTEN_TWICE, after_the_second); - } - continue; - } - - let Ok(value) = self.scan.decode::(member.value_start, member.value_end) else { - leave!(self, &key, UNREADABLE, member.value_start); - continue; - }; // A list of targets is something no native key says: it is left // as it is written, and not for a rewrite to say some other way // or to drop. if hit.targets && value.is_array() { continue; } - // The native key that is beside is the one that decides, so it has - // to be one, and to have a value to read. If it is not, what - // `forge` makes of it is for `forge` to say. - let mut named = members.iter().filter(|other| self.scan.key_is(other, native)); - let beside: Option = match named.next() { - Some(other) => { - if let Some(second) = named.next() { - let after_the_second = second.key_end; - leave!(self, native, WRITTEN_TWICE, after_the_second); - continue; - } - match self.scan.decode(other.value_start, other.value_end) { - Ok(beside) => Some(beside), - Err(_) => { - leave!(self, native, UNREADABLE, other.value_start); - continue; - } - } - } - None => None, - }; - if beside - .as_ref() - .is_some_and(|beside| !L::same(hit.field, &value, beside)) - { + // The native key that is beside is the one that decides. + let beside = members.get(native); + if beside.is_some_and(|beside| !L::same(hit.field, value, beside)) { let location = self.pointer(); self.report.conflicts.push(Conflict { location, - key: key.to_string(), + key: key.clone(), native, }); } let writes = beside.is_none(); - let text = L::written(hit.field, &value).filter(|_| writes); - if text.is_some() { + let written = L::written(hit.field, value).filter(|_| writes); + if written.is_some() { self.report.joined += 1; } let action = match (self.options.leave_old_extensions, writes) { - (false, true) => Action::Rename(native, text), + (false, true) => Action::Rename(native, written), (false, false) => Action::Remove, - (true, true) => Action::Insert(native, text), + (true, true) => Action::Insert(native, written), (true, false) => continue, }; - match self.report.migrated.get_mut(&*key) { - Some(count) => *count += 1, + *self.report.migrated.entry(key.clone()).or_insert(0) += 1; + actions.push((key.clone(), action)); + } + if actions.is_empty() { + return; + } + + // The members are written again in the order they were in. + for (key, value) in std::mem::take(members) { + match actions.iter().position(|(name, _)| *name == key) { None => { - self.report.migrated.insert(key.to_string(), 1); + members.insert(key, value); } + Some(at) => match actions.swap_remove(at).1 { + Action::Rename(native, written) => { + members.insert(native.to_owned(), written.unwrap_or(value)); + } + Action::Remove => {} + Action::Insert(native, written) => { + members.insert(native.to_owned(), written.unwrap_or_else(|| value.clone())); + members.insert(key, value); + } + }, } - actions.push((at, action)); - } - if !actions.is_empty() { - self.scan.edit(&self.members[base..], actions); } } @@ -496,11 +369,8 @@ impl Walk<'_, '_> { 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")); - } + match step { + Step::Key(key) => out.push_str(&key.replace('~', "~0").replace('/', "~1")), Step::Index(index) => out.push_str(&index.to_string()), } } diff --git a/tests/normalize/common.rs b/tests/normalize/common.rs index 67233eff4..477d29d1a 100644 --- a/tests/normalize/common.rs +++ b/tests/normalize/common.rs @@ -30,27 +30,7 @@ pub fn normalized(json: &str) -> String { }; normalized .json - .map_or_else(|| json.to_owned(), |edited| String::from_utf8(edited.to_vec()).unwrap()) -} - -/// Runs `forge normalize` on `json`, and returns what it made of it, and the -/// keys it left as they are written, each as where, the key and why. -#[track_caller] -pub fn normalized_and_left(json: &str) -> (String, Vec<(String, String, &'static str)>) { - let normalized = match normalize(json.as_bytes(), &Options::default()) { - Ok(normalized) => normalized, - Err(err) => panic!("normalize: {err}"), - }; - let left = normalized - .report - .left - .iter() - .map(|left| (left.location.clone(), left.key.clone(), left.why)) - .collect(); - let text = normalized - .json - .map_or_else(|| json.to_owned(), |edited| String::from_utf8(edited.to_vec()).unwrap()); - (text, left) + .map_or_else(|| json.to_owned(), |edited| String::from_utf8(edited).unwrap()) } /// Parses `json`, which must fail, and returns the error message. @@ -152,3 +132,14 @@ fn shape(ty: Type<'_>) -> String { Type::Model(model) => format!("Model({})", model.name().snake()), } } + +/// Asserts that two documents are the same JSON, however each is laid out. A +/// test of what a rewrite says, and not of how it is written. +#[track_caller] +pub fn assert_json_eq(a: &str, b: &str) { + let (a, b): (serde_json::Value, serde_json::Value) = ( + serde_json::from_str(a).unwrap_or_else(|err| panic!("{err}: {a}")), + serde_json::from_str(b).unwrap_or_else(|err| panic!("{err}: {b}")), + ); + assert_eq!(a, b); +} diff --git a/tests/normalize/fern.rs b/tests/normalize/fern.rs index ccd14f62f..7e45c89d7 100644 --- a/tests/normalize/fern.rs +++ b/tests/normalize/fern.rs @@ -1,10 +1,7 @@ //! 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, normalized, normalized_and_left, operation_with, parse, parse_err, parse_normalized, - responding_with, -}; +use super::common::{assert_same, describe, normalized, operation_with, parse_err, parse_normalized, responding_with}; #[test] fn method_group_and_availability_are_read_as_native() { @@ -265,42 +262,6 @@ fn an_error_in_an_alias_names_the_native_key_the_rewrite_wrote() { ); } -#[test] -fn a_key_written_twice_is_left_as_it_is_with_a_warning_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", - ), - ] { - // `forge` reads it as an ordinary extension. There is no one thing it - // says, so the rewrite leaves each, and does not stop. - let document = operation_with(keys); - parse(&document); - - let (text, left) = normalized_and_left(&document); - - assert_eq!(text, document, "{key}"); - assert_eq!( - left, - [( - "/paths/~1a/get".to_owned(), - key.to_owned(), - "is written twice in this object" - )] - ); - } -} - #[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. diff --git a/tests/normalize/known_gaps.rs b/tests/normalize/known_gaps.rs new file mode 100644 index 000000000..f59312eba --- /dev/null +++ b/tests/normalize/known_gaps.rs @@ -0,0 +1,149 @@ +//! What `forge normalize` gets wrong because a document is read with +//! `serde_json` and written again, as it happens today. +//! +//! Each test says what a document becomes, which is not what it should be, and +//! passes. When one of them fails, a gap was fixed: move it out of here and +//! out of "Known gaps" in the README of the package. The tests that would +//! pass once a gap is fixed are the ones that are ignored, in `normalize.rs` +//! and `normalize_cli.rs`, with the reason. + +use forge::normalize::{Options, mentions_old_keys, normalize}; + +/// A document with one key to rewrite, so that it is written again, and +/// `extra` as more of the members of the operation. +fn with_a_key_to_rewrite(extra: &str) -> String { + format!( + "{{\n \"openapi\": \"3.0.3\",\n \"info\": {{\"title\": \"t\", \"version\": \"1\"}},\n \"paths\": {{\"/a\": {{\"get\": {{\n \"x-fern-sdk-method-name\": \"get\",\n {extra}\n \"responses\": {{\"204\": {{\"description\": \"ok\"}}}}}}}}}}\n}}\n" + ) +} + +#[track_caller] +fn rewritten(document: &str) -> String { + let normalized = normalize(document.as_bytes(), &Options::default()).unwrap(); + String::from_utf8(normalized.json.expect("there is a key to rewrite")).unwrap() +} + +/// GAP: a key written twice is not an error, and not left: the first of the +/// two is dropped, without a word. `forge` itself says `duplicate field`. +#[test] +fn a_key_written_twice_loses_the_first_copy_without_a_word() { + let text = rewritten(&with_a_key_to_rewrite( + r#""description": "first", "description": "second","#, + )); + + assert!(text.contains("second") && !text.contains("first"), "{text}"); +} + +/// GAP: a number is read as a 64-bit float, or an integer that fits in 64 +/// bits, so a longer one is rounded, and one written as `1E5` is written as +/// `100000.0`. `forge` reads them as floats too, but the document is what is +/// rewritten, and what is read by every other tool. +#[test] +fn a_number_that_does_not_fit_is_rounded_and_the_others_are_written_their_own_way() { + let text = rewritten(&with_a_key_to_rewrite( + r#""x-ids": [100000000000000000001, 3.14159265358979323846, 1E5, 0.10, 2.50],"#, + )); + + assert!(text.contains("1e+20"), "{text}"); + assert!( + text.contains("3.141592653589793") && !text.contains("3.1415926535897932"), + "{text}" + ); + assert!(text.contains("100000.0") && !text.contains("1E5"), "{text}"); + assert!(text.contains("0.1") && !text.contains("0.10"), "{text}"); + assert!(text.contains("2.5") && !text.contains("2.50"), "{text}"); +} + +/// GAP: line endings are not kept: a document with `\r\n` is written with +/// `\n`, so every line of it is in the diff. +#[test] +fn windows_line_endings_are_written_as_unix_ones() { + let text = rewritten(&with_a_key_to_rewrite("").replace('\n', "\r\n")); + + assert!(!text.contains('\r'), "{text:?}"); +} + +/// GAP: an array or an object that is written on one line is written on +/// several, which is a diff of every one of them in a document that is +/// formatted that way, as most formatters of JSON do. +#[test] +fn an_array_on_one_line_is_written_on_several() { + let text = rewritten(&with_a_key_to_rewrite(r#""tags": ["a", "b"],"#)); + + assert!(!text.contains(r#"["a", "b"]"#), "{text}"); + assert!(text.contains("\"tags\": [\n"), "{text}"); +} + +/// GAP: a document on one line is written on one line, with no space after a +/// comma or a colon, which is not how it was written if it had them. +#[test] +fn spaces_in_a_document_on_one_line_are_not_kept() { + let document = r#"{"openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": {"/a": {"get": {"x-fern-ignore": true, "responses": {"204": {"description": "ok"}}}}}}"#; + + let text = rewritten(document); + + assert!(!text.contains(", ") && !text.contains(": "), "{text}"); +} + +/// GAP: an escape is written as the character, so `\u00e9`, `\/` and `\u003c` +/// are not what they were, and a document with them is a diff of every line +/// that has one. +#[test] +fn escapes_in_strings_are_written_as_the_characters() { + let text = rewritten(&with_a_key_to_rewrite( + r#""description": "caf\u00e9 a\/b \u003ctag\u003e","#, + )); + + assert!(text.contains("café a/b "), "{text}"); +} + +/// GAP: a key written with an escape is rewritten by `forge normalize`, which +/// reads the document, and not by the step `forge` does before it reads one, +/// which only reads a document that has the text of a key to rewrite in it. +#[test] +fn a_key_written_with_an_escape_is_not_found_by_the_step_before_forge_reads() { + let document = r#"{"x\u002dfern-ignore": true}"#; + + assert!(!mentions_old_keys(document.as_bytes())); + assert!(mentions_old_keys(br#"{"x-fern-ignore": true}"#)); +} + +/// GAP: JSON with comments, which some call JSONC, is not JSON, and is an +/// error that says only what is at the start of the document. +#[test] +fn json_with_comments_is_an_error() { + let err = normalize(b"// api\n{\"openapi\": \"3.0.3\"}", &Options::default()).unwrap_err(); + + assert!(err.to_string().contains("does not start with `{`"), "{err}"); +} + +/// GAP: a document nested deeper than `serde_json` allows, 128 levels, is an +/// error, wherever it is nested. +#[test] +fn a_document_nested_deeper_than_128_levels_is_an_error() { + let document = format!( + r#"{{"x-fern-ignore": true, "x-other": {}{}}}"#, + "[".repeat(200), + "]".repeat(200) + ); + + let err = normalize(document.as_bytes(), &Options::default()).unwrap_err(); + + assert!(err.to_string().contains("recursion limit"), "{err}"); +} + +/// What is not a gap: a document without a key to rewrite is not read, and not +/// written, by the step before `forge` reads it, so none of the above is true +/// of it. +#[test] +fn a_document_with_no_key_to_rewrite_is_left_exactly_as_it_is() { + let document = "{\"openapi\": \"3.0.3\",\r\n \"x\": 1E5, \"y\": [1,2]}"; + + assert!(!mentions_old_keys(document.as_bytes())); + assert!( + normalize(document.as_bytes(), &Options::default()) + .unwrap() + .json + .is_none() + ); +} diff --git a/tests/normalize/main.rs b/tests/normalize/main.rs index eacb117ae..44b540e62 100644 --- a/tests/normalize/main.rs +++ b/tests/normalize/main.rs @@ -4,8 +4,9 @@ //! `forge` reads the native keys and no others, so each primitive is tested //! the same way: a document written with a tool's keys, after `forge //! normalize`, must build the same `Forge` as its native twin. The tests of -//! what a rewrite does to the document itself, byte for byte, and of the -//! command, are written out. +//! what a rewrite does to the document itself, and of the command, are +//! written out. What it gets wrong, because a document is read and written +//! again, is in `known_gaps`. //! //! The suite of `tests/parse`, which has no vendor names in it, runs without //! the `normalize` feature. @@ -14,6 +15,7 @@ mod common; #[path = "../wasm/dump.rs"] mod dump; mod fern; +mod known_gaps; mod native_first; mod normalize; #[cfg(feature = "cli")] diff --git a/tests/normalize/normalize.rs b/tests/normalize/normalize.rs index f0a4f5544..ddad46cdd 100644 --- a/tests/normalize/normalize.rs +++ b/tests/normalize/normalize.rs @@ -4,7 +4,7 @@ use forge::normalize::{Options, Report, normalize}; use serde_json::{Value, json}; -use super::common::{assert_same, describe, normalized_and_left, operation_with, parse, parse_err, responding_with}; +use super::common::{assert_json_eq, assert_same, describe, operation_with, parse, parse_err, responding_with}; const KEEP: Options = Options { leave_old_extensions: true, @@ -17,7 +17,7 @@ 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()); + .map_or_else(|| document.to_owned(), |json| String::from_utf8(json).unwrap()); (text, normalized.report) } @@ -335,6 +335,7 @@ const AWKWARD: &str = concat!( ); #[test] +#[ignore = "known gap: layout, escapes and line endings are not kept"] fn nothing_but_the_keys_that_are_rewritten_changes() { let (text, report) = edit(AWKWARD, &Options::default()); @@ -407,6 +408,7 @@ fn a_removed_key_takes_its_comma_and_leaves_the_others_as_they_were() { } #[test] +#[ignore = "known gap: whitespace is not kept"] fn a_removed_key_leaves_the_whitespace_of_the_others() { let pretty = |members: &str| with_property(&format!("{{{members}}}")); for (before, after) in [ @@ -430,6 +432,7 @@ fn a_removed_key_leaves_the_whitespace_of_the_others() { } #[test] +#[ignore = "known gap: spacing around a key is not kept"] fn a_key_that_is_left_is_preceded_by_the_native_key_written_the_same_way() { for (before, after) in [ ( @@ -483,7 +486,7 @@ fn a_group_path_written_over_several_lines_becomes_one_string() { let (text, _) = edit(&before, &Options::default()); - assert_eq!(text, after); + assert_json_eq(&text, &after); } #[test] @@ -492,9 +495,9 @@ fn a_group_path_that_is_a_string_is_written_as_it_was() { let (text, _) = edit(&document, &Options::default()); - assert_eq!( - text, - operation_with(r#""x-forge-group-name": "a.b", "x-forge-method-name": "get","#) + assert_json_eq( + &text, + &operation_with(r#""x-forge-group-name": "a.b", "x-forge-method-name": "get","#), ); } @@ -517,17 +520,17 @@ fn a_group_path_a_dotted_string_cannot_say_is_kept_as_an_array() { let (text, _) = edit(&document, &Options::default()); - assert_eq!( - text, - operation_with(&format!( + assert_json_eq( + &text, + &operation_with(&format!( r#""x-forge-group-name": {names}, "x-forge-method-name": "get","# )), - "{names}" ); } } #[test] +#[ignore = "known gap: layout is not kept: an array written on one line is written on several"] 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( @@ -602,7 +605,7 @@ fn leaving_the_old_key_copies_a_group_with_a_dot_as_an_array() { let (text, _) = edit(&before, &KEEP); - assert_eq!(text, after); + assert_json_eq(&text, &after); } #[test] @@ -669,7 +672,7 @@ fn leaving_the_old_key_writes_the_native_one_as_a_string_and_keeps_the_array() { let (text, _) = edit(&before, &KEEP); - assert_eq!(text, after); + assert_json_eq(&text, &after); } #[test] @@ -697,32 +700,6 @@ fn a_native_group_that_says_something_else_is_a_conflict() { assert_eq!(report.conflicts.len(), 1); } -#[test] -fn a_key_written_twice_is_left_as_it_is_and_the_others_are_rewritten() { - let document = operation_with( - r#""x-fern-sdk-method-name": "a", "x-fern-sdk-method-name": "b", "x-fern-sdk-group-name": ["a"],"#, - ); - - let (text, report) = edit(&document, &Options::default()); - - assert_eq!( - text, - operation_with(r#""x-fern-sdk-method-name": "a", "x-fern-sdk-method-name": "b", "x-forge-group-name": "a","#) - ); - assert_eq!(report.migrated.len(), 1); - let [left] = &report.left[..] else { - panic!("{:?}", report.left) - }; - assert_eq!( - (left.location.as_str(), left.key.as_str(), left.why), - ( - "/paths/~1a/get", - "x-fern-sdk-method-name", - "is written twice in this object" - ) - ); -} - /// `forge` reads no native key, nor any other key it knows, that is written /// twice in an object it reads. A key of another tool is an ordinary extension /// to it, which it takes as the last value written. @@ -739,39 +716,6 @@ fn forge_rejects_a_key_written_twice_in_an_object() { } } -#[test] -fn a_native_key_written_twice_beside_a_key_that_is_rewritten_leaves_that_key_in_both_modes() { - // Which of the two is the native key that is beside is not for a rewrite - // to say, so the key beside them is left as it is, and `forge` says what - // it makes of a native key written twice. - 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", - ), - ] { - let document = operation_with(keys); - for options in [Options::default(), KEEP] { - let normalized = normalize(document.as_bytes(), &options).unwrap(); - - assert!(normalized.json.is_none() && !normalized.report.changed(), "{keys}"); - let [left] = &normalized.report.left[..] else { - panic!("{keys}: {:?}", normalized.report.left) - }; - assert_eq!((left.key.as_str(), left.why), (name, "is written twice in this object")); - } - } -} - #[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 @@ -788,6 +732,7 @@ fn a_native_key_written_twice_with_no_key_to_rewrite_beside_it_is_left_as_it_is( } #[test] +#[ignore = "known gap: a key written twice is dropped, the first of the two, without a word"] 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": { @@ -804,75 +749,6 @@ fn a_native_key_written_twice_in_one_object_does_not_stop_the_others_being_rewri assert_eq!(report.migrated["x-sensitive"], 1); } -#[test] -fn a_key_written_twice_is_left_with_where_a_parser_that_refused_it_would_be() { - // After the name of the second copy, in bytes, on its line. - let document = "{\"openapi\":\"3.0.3\",\"info\":{\"title\":\"t\",\"version\":\"1\"},\"paths\":{\"/a\":{\"get\":{\n \"x-fern-sdk-method-name\": \"a\",\n \"x-fern-sdk-method-name\": \"b\",\n \"x-fern-sdk-method-name\": \"c\",\n \"responses\": {\"204\": {\"description\": \"ok\"}}}}}}"; - - let normalized = normalize(document.as_bytes(), &Options::default()).unwrap(); - - let [left] = &normalized.report.left[..] else { - panic!("{:?}", normalized.report.left) - }; - assert!(left.written_twice()); - assert_eq!((left.line, left.column), (3, 26)); -} - -#[test] -fn a_value_that_cannot_be_read_is_left_as_it_is_and_does_not_stop_the_others() { - // A lone surrogate is JSON that a value cannot be made of. - let document = operation_with( - r#""x-fern-sdk-method-name": "\ud800", "x-fern-sdk-group-name": ["a"], "x-fern-ignore": false,"#, - ); - - let (text, left) = normalized_and_left(&document); - - assert!(text.contains(r#""x-fern-sdk-method-name": "\ud800""#), "{text}"); - assert!( - text.contains(r#""x-forge-group-name": "a""#) && text.contains(r#""x-forge-skip": false"#), - "{text}" - ); - assert_eq!( - left, - [( - "/paths/~1a/get".to_owned(), - "x-fern-sdk-method-name".to_owned(), - "has a value that cannot be read" - )] - ); -} - -#[test] -fn a_native_key_beside_one_that_is_rewritten_that_cannot_be_read_leaves_both() { - let document = operation_with(r#""x-forge-method-name": "\ud800", "x-fern-sdk-method-name": "get","#); - - for options in [Options::default(), KEEP] { - let normalized = normalize(document.as_bytes(), &options).unwrap(); - - assert!(normalized.json.is_none(), "{options:?}"); - let [left] = &normalized.report.left[..] else { - panic!("{:?}", normalized.report.left) - }; - assert_eq!( - (left.key.as_str(), left.why), - ("x-forge-method-name", "has a value that cannot be read") - ); - } -} - -#[test] -fn a_key_that_cannot_be_read_is_no_key_and_does_not_stop_the_others() { - let document = operation_with(r#""x\ud800": 1, "x-fern-sdk-group-name": ["a"],"#); - - let (text, left) = normalized_and_left(&document); - - assert!( - text.contains(r#""x\ud800": 1"#) && text.contains(r#""x-forge-group-name": "a""#), - "{text}" - ); - assert!(left.is_empty(), "{left:?}"); -} - #[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","#); @@ -880,7 +756,7 @@ fn a_key_written_with_an_escape_is_rewritten_as_the_key_it_is() { let (text, _) = edit(&before, &Options::default()); - assert_eq!(text, after); + assert_json_eq(&text, &after); assert_same(&before, &text); } @@ -893,7 +769,7 @@ fn a_container_written_with_an_escape_is_followed() { let (text, report) = edit(&before, &Options::default()); - assert_eq!(text, before.replace("\"x-sensitive\"", "\"x-forge-sensitive\"")); + assert_json_eq(&text, &before.replace("\"x-sensitive\"", "\"x-forge-sensitive\"")); assert_eq!(report.migrated["x-sensitive"], 1); } @@ -921,7 +797,7 @@ fn mangled_documents_do_not_panic_and_what_is_written_is_json() { && let Some(json) = normalized.json { assert!( - serde_json::from_slice::(&json.to_vec()).is_ok(), + serde_json::from_slice::(&json).is_ok(), "{}", String::from_utf8_lossy(document) ); @@ -930,70 +806,6 @@ fn mangled_documents_do_not_panic_and_what_is_written_is_json() { } } -/// 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. @@ -1025,6 +837,7 @@ fn schemas_nested_without_end_are_an_error_and_not_a_stack_overflow() { } #[test] +#[ignore = "known gap: a document nested deeper than 128 levels is an error"] 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)); @@ -1122,7 +935,7 @@ fn the_aggregate_builds_the_same_forge_in_both_modes_and_only_when_normalized() for options in [Options::default(), KEEP] { let normalized = normalize(&json, &options).unwrap(); dumps.push(super::dump::dump( - &serde_json::from_slice(&normalized.json.unwrap().to_vec()).unwrap(), + &serde_json::from_slice(&normalized.json.unwrap()).unwrap(), )); let report = normalized.report; diff --git a/tests/normalize/normalize_cli.rs b/tests/normalize/normalize_cli.rs index 7118813ff..ee43b690a 100644 --- a/tests/normalize/normalize_cli.rs +++ b/tests/normalize/normalize_cli.rs @@ -92,6 +92,7 @@ fn a_file_is_rewritten_in_place_as_it_was_written() { } #[test] +#[ignore = "known gap: layout, escapes and line endings are not kept"] fn nothing_but_the_keys_that_are_rewritten_changes() { let dir = dir("untouched"); let file = dir.join("spec.json"); @@ -584,6 +585,7 @@ fn a_byte_order_mark_is_named_and_the_file_is_left_as_it_is() { } #[test] +#[ignore = "known gap: a key written twice is dropped, the first of the two, without a word"] fn a_key_written_twice_is_a_warning_that_says_where_and_the_file_is_rewritten() { let dir = dir("duplicate"); let file = dir.join("spec.json"); @@ -804,6 +806,7 @@ fn forge_rewrites_standard_input_in_memory_too() { /// these keys to `forge normalize`: each message is what that `forge` said of /// the document, word for word, down to the line and the column. #[test] +#[ignore = "known gap: forge no longer fails on a key written twice in a document that has keys to rewrite"] fn forge_fails_on_a_key_written_twice_in_the_words_it_always_used() { let dir = dir("in-memory-error"); let base = |keys: &str| { diff --git a/tests/normalize/stainless.rs b/tests/normalize/stainless.rs index 007e0ed1e..0eee599df 100644 --- a/tests/normalize/stainless.rs +++ b/tests/normalize/stainless.rs @@ -1,10 +1,7 @@ //! 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, normalized, normalized_and_left, operation_with, parse_err, parse_normalized, - responding_with, -}; +use super::common::{assert_same, describe, normalized, operation_with, parse_err, parse_normalized, responding_with}; fn with_stainless_skip(value: &str) -> String { responding_with( @@ -61,23 +58,6 @@ fn a_list_of_targets_is_an_error_for_the_native_key() { assert!(err.contains("invalid type"), "{err}"); } -#[test] -fn a_skip_written_twice_is_left_as_it_is_with_a_warning_naming_it() { - let schema = responding_with( - r#"{"type": "object", "properties": {"a": {"type": "string", "x-stainless-skip": false, "x-stainless-skip": false}}}"#, - "{}", - ); - - let (text, left) = normalized_and_left(&schema); - - assert_eq!(text, schema); - assert_eq!(left.len(), 1); - assert_eq!( - (left[0].1.as_str(), left[0].2), - ("x-stainless-skip", "is written twice in this object") - ); -} - #[test] fn a_skip_on_an_operation_says_nothing_and_stays_an_extension() { let forge = parse_normalized(&operation_with( From 5c1dac276e0fa0a9f44adafa2385d1224580f5e2 Mon Sep 17 00:00:00 2001 From: Niels Swimberghe Date: Wed, 28 Oct 2026 15:24:43 -0400 Subject: [PATCH 09/20] feat!: the step before forge reads a document leaves the old keys Downstream code reads the extensions that other tools write directly, so the in-memory rewrite that `forge` does before it reads a document now does what `forge normalize --leave-old-extensions` does: each key that is rewritten stays, beside the native key, as an ordinary extension that plugins are given. It was dropping them, which is what the engine did before this series: the keys it read were left out of the extensions of a method, and are now four that plugins are given (`group-name`, `method-name`, `availability` and `ignore` of the old keys). That is a change, and it is meant: it is what plugins are given once the step is gone, when the keys of other tools are ordinary extensions, and what is built on them needs them now. The step moves into the library as `normalize::before_reading`, so that `tests/normalize/` can test what a plugin is given, which the binary may not, as it may not name the keys of other tools. The binary calls it. The SDK built from the 28 MB aggregate is still byte for byte the one the engine built before, in 3,243 files, and the step costs the same: 330 ms and 284 MB for a run that takes 240 ms and 153 MB without it. `arbitrary_precision`, to keep the text of every number, was tried and is not turned on: with it, the engine's own number visitors are given a map and not a number, so `maxItems: 2.0` is an error, a number enum is not read, a default is a map, and the extensions plugins are given have the map in them. The engine has to read it first. --- packages/forge/README.md | 10 ++-- rfcs/0001-metadata-resolution-layer.md | 2 +- src/bin/forge/main.rs | 5 +- src/bin/forge/normalize.rs | 21 ++----- src/normalize/mod.rs | 25 +++++++++ tests/normalize/normalize.rs | 76 +++++++++++++++++++++++++- 6 files changed, 113 insertions(+), 26 deletions(-) diff --git a/packages/forge/README.md b/packages/forge/README.md index 7d6765b7f..56aefb2ab 100644 --- a/packages/forge/README.md +++ b/packages/forge/README.md @@ -33,10 +33,12 @@ npx forge normalize --write specs/*.json ``` For now, `forge` also does this in memory when it reads a document, so one that -has not been through `forge normalize` builds as it always did. That is -temporary: it stops in the release that makes `forge normalize` a step to run +has not been through `forge normalize` builds as it always did. It keeps the +old keys, as `--leave-old-extensions` does, so plugins, and what is built on +them, read them as they read any other extension that forge does not read. That +is temporary: it stops in the release that makes `forge normalize` a step to run first, and the extensions of other tools are then passed on to plugins like any -other that forge does not read. +other. It takes paths, which the shell can expand (`specs/*.json`). Without `--write` it changes nothing: it prints one document, rewritten, to standard output. With @@ -78,7 +80,7 @@ ignored, with `known gap` as the reason. | Escapes | `\u00e9`, `\/` and `\u003c` are written as the characters. | | Escaped keys | A key written with an escape is rewritten by `forge normalize`, and not by the step `forge` does before it reads a document, which looks for the text of a key. | | Comments | JSON with comments is an error, and so is a byte order mark. | -| Cost | `forge normalize` takes about 5 times as long, and 6 times the memory, of an edit of bytes: 100 ms and 185 MB for a document of 28 MB, against 20 ms and 32 MB. The step in `forge` adds 90 ms and 120 MB to a run of 240 ms and 150 MB. | +| Cost | `forge normalize` takes about 5 times as long, and 6 times the memory, of an edit of bytes: 100 ms and 185 MB for a document of 28 MB, against 20 ms and 32 MB. The step in `forge` adds 90 ms and 130 MB to a run of 240 ms and 150 MB. | ```sh # Keep the old keys beside the native ones, for what still reads them. diff --git a/rfcs/0001-metadata-resolution-layer.md b/rfcs/0001-metadata-resolution-layer.md index b5df9182d..f2ecc54d2 100644 --- a/rfcs/0001-metadata-resolution-layer.md +++ b/rfcs/0001-metadata-resolution-layer.md @@ -63,7 +63,7 @@ src/ ## Migration - Run `forge normalize --write` before `forge` reads a document written with vendor keys, with `--leave-old-extensions` where something else still reads them. A pipeline is `cat spec.json | forge normalize - | forge - --target ts --out sdk`. -- Until the release that makes `forge normalize` a step to run first, the `forge` command does the same rewrite in memory when it reads a document, so one that has not been through it builds as before. It costs a pass over the document, and stops in that release; the core never reads anything but native keys. +- Until the release that makes `forge normalize` a step to run first, the `forge` command does the same rewrite in memory when it reads a document, so one that has not been through it builds as before. It leaves the old keys, as `--leave-old-extensions` does, so plugins read them as the ordinary extensions they become in that release. It costs a pass over the document, and stops in that release; the core never reads anything but native keys. - After that release, a document that is not normalized is not an error: `forge` finds no method names in it, and so no methods. - Keys that are left are ordinary extensions, so plugins see them. diff --git a/src/bin/forge/main.rs b/src/bin/forge/main.rs index b2cc7eb51..84260a8c4 100644 --- a/src/bin/forge/main.rs +++ b/src/bin/forge/main.rs @@ -20,8 +20,9 @@ mod ts; /// Generates an SDK from an OpenAPI document. /// /// The extensions other tools write are rewritten into the native ones in -/// memory first, as `forge normalize` does to a file. That is temporary: it -/// stops in the release that makes `forge normalize` a step to run first. +/// memory first, as `forge normalize --leave-old-extensions` does to a file, +/// so the old keys stay as ordinary extensions for plugins. That is temporary: +/// it stops in the release that makes `forge normalize` a step to run first. #[derive(Parser)] #[command(version, args_conflicts_with_subcommands = true)] struct Args { diff --git a/src/bin/forge/normalize.rs b/src/bin/forge/normalize.rs index 2ca57df2c..981101386 100644 --- a/src/bin/forge/normalize.rs +++ b/src/bin/forge/normalize.rs @@ -7,7 +7,7 @@ use std::io::{self, Read, Write}; use std::path::{Path, PathBuf}; use forge::Err; -use forge::normalize::{Normalized, Options, Report, mentions_old_keys, normalize}; +use forge::normalize::{Normalized, Options, Report, normalize}; use rayon::prelude::*; /// What `forge normalize` is given, which says what it does in `main.rs`. @@ -317,23 +317,10 @@ fn create_private(path: &Path) -> io::Result { } /// Rewrites the extensions other tools write in the document `json` into the -/// native ones, in memory, as `forge normalize` would in a file. -/// -/// A document that has none of the text of a key to rewrite is not read, and is -/// as it was. One that has is read and written again, which is not what -/// `forge` did before: see "Known gaps" in the README of the package. A key -/// written twice in it, say, is no longer an error here, as the second is the -/// one that is read. -/// -/// This is temporary. `forge` reads native keys only, and `forge normalize` is -/// what is meant to come first. This keeps a document that has not been through -/// it building as it did, until the release that stops doing it. +/// native ones, in memory, before `forge` reads it: see +/// [`forge::normalize::before_reading`]. pub fn before_reading(json: Vec) -> Result, Err> { - if !mentions_old_keys(&json) { - return Ok(json); - } - let normalized = normalize(&json, &Options::default())?; - Ok(normalized.json.unwrap_or(json)) + Ok(forge::normalize::before_reading(json)?) } /// Says what in the document called `name` could not be rewritten, or was diff --git a/src/normalize/mod.rs b/src/normalize/mod.rs index 55275cd6d..d7c63d973 100644 --- a/src/normalize/mod.rs +++ b/src/normalize/mod.rs @@ -103,6 +103,31 @@ pub fn mentions_old_keys(json: &[u8]) -> bool { vendor::mentions(json) } +/// Rewrites the extensions other tools write in the document `json` into the +/// native ones, in memory, as `normalize` would with `leave_old_extensions` +/// in a file: the keys that are rewritten stay, beside the native ones, +/// as ordinary extensions, which plugins, and what is built on them, can read. +/// +/// A document that has none of the text of a key to rewrite is not read, and is +/// as it was. One that has is read and written again, which is not what +/// `forge` did before: see "Known gaps" in the README of the package. A key +/// written twice in it, say, is no longer an error here, as the second is the +/// one that is read. +/// +/// This is temporary. `forge` reads native keys only, and `forge normalize` is +/// what is meant to come first. This keeps a document that has not been through +/// it building as it did, until the release that stops doing it. +pub fn before_reading(json: Vec) -> Result, Error> { + if !mentions_old_keys(&json) { + return Ok(json); + } + let options = Options { + leave_old_extensions: true, + }; + let normalized = normalize(&json, &options)?; + Ok(normalized.json.unwrap_or(json)) +} + /// Rewrites the keys of the document `json` that a native key replaces. pub fn normalize(json: &[u8], options: &Options) -> Result { if json.starts_with(b"\xEF\xBB\xBF") { diff --git a/tests/normalize/normalize.rs b/tests/normalize/normalize.rs index ddad46cdd..4c838c518 100644 --- a/tests/normalize/normalize.rs +++ b/tests/normalize/normalize.rs @@ -1,10 +1,12 @@ //! `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 forge::normalize::{Options, Report, before_reading, normalize}; use serde_json::{Value, json}; -use super::common::{assert_json_eq, assert_same, describe, operation_with, parse, parse_err, responding_with}; +use super::common::{ + assert_json_eq, assert_same, describe, normalized, operation_with, parse, parse_err, responding_with, +}; const KEEP: Options = Options { leave_old_extensions: true, @@ -980,3 +982,73 @@ fn the_aggregate_builds_the_same_forge_in_both_modes_and_only_when_normalized() unread.len() ); } + +/// What the step before `forge` reads a document makes of it, which is what +/// `forge` is given. +#[track_caller] +fn read_in_memory(document: &str) -> String { + String::from_utf8(before_reading(document.as_bytes().to_vec()).unwrap()).unwrap() +} + +/// The extensions of the first method of a document, as a plugin is given them. +#[track_caller] +fn extensions_of_the_method(document: &str) -> Vec { + let forge = parse(document); + let method = forge + .groups() + .flat_map(|group| group.methods()) + .next() + .expect("a method"); + method.extensions().map(|(name, _)| name.to_owned()).collect() +} + +/// The keys that are rewritten stay, as ordinary extensions, for what is built +/// on a plugin to read, and the keys `forge` does not read at all, with them. +#[test] +fn the_step_before_forge_reads_leaves_the_old_keys_as_extensions() { + let document = operation_with( + r#""x-fern-sdk-group-name": ["a"], "x-fern-sdk-method-name": "get", "x-fern-availability": "beta", + "x-fern-other": 1, "x-custom": 2,"#, + ); + + let text = read_in_memory(&document); + + assert_eq!( + extensions_of_the_method(&text), + [ + "x-fern-sdk-group-name", + "x-fern-sdk-method-name", + "x-fern-availability", + "x-fern-other", + "x-custom" + ] + ); +} + +/// Rewriting only for the file, and not for what forge is given, leaves them +/// out, which is what `forge normalize` does without `--leave-old-extensions`. +#[test] +fn a_rewrite_that_does_not_leave_the_old_keys_hides_them_from_plugins() { + let document = operation_with(r#""x-fern-sdk-group-name": ["a"], "x-fern-sdk-method-name": "get", "x-custom": 2,"#); + + let text = normalized(&document); + + assert_eq!(extensions_of_the_method(&text), ["x-custom"]); +} + +#[test] +fn the_step_before_forge_reads_builds_what_the_native_twin_builds() { + let other = operation_with( + r#""x-fern-sdk-group-name": ["a", "b"], "x-fern-sdk-method-name": "get", "x-fern-ignore": false,"#, + ); + let native = operation_with(r#""x-forge-group-name": "a.b", "x-forge-method-name": "get", "x-forge-skip": false,"#); + + assert_eq!(describe(&parse(&read_in_memory(&other))), describe(&parse(&native))); +} + +#[test] +fn the_step_before_forge_reads_leaves_a_document_with_no_key_to_rewrite_as_it_is() { + let document = operation_with(r#""x-forge-group-name": "a", "x-forge-method-name": "get", "x-custom": 2,"#); + + assert_eq!(read_in_memory(&document), document); +} From 75aa19835289f478ec3d80acc394061fff7cb61b Mon Sep 17 00:00:00 2001 From: Niels Swimberghe Date: Wed, 28 Oct 2026 15:35:59 -0400 Subject: [PATCH 10/20] fix: say which document an error position is of, and test key order Found in a review of the branch. An error that `forge` finds in a document that has a key to rewrite is at a line and a column of the document as it is written again. For a file with arrays or objects on one line that is a line past the end of the file: an error `forge` gave at line 9 of an 11-line file, on main, was given at line 27. The existing test of positions only used documents on one line, which stay on one line, so it did not see it. `before_reading` now says whether it rewrote the document, and `forge` says, after the position, that it is of the document with the keys rewritten in memory, which `forge normalize --leave-old-extensions` prints. A test checks that the line it gives is a line of that document that has the key in it, and that a document that was not rewritten has no such note. The README says that the key order is kept, and no test did: the tests that were changed to compare documents as JSON do not care about order. Two tests do now, for a key that is rewritten and for one that is left, and each fails if the keys are sorted. A value that cannot be read is written as the native key, and `forge` says so: it was left as it was before. A test for that, as the ones of the old behavior were removed. Two more gaps are in the README and pinned by a test: the positions, and the memory of files that are read at once, which are held as trees: 6 files of 28 MB took 1.1 GB. A stale mention of the `normalize` feature is removed from the tests. --- packages/forge/README.md | 24 +++++----- src/bin/forge/main.rs | 14 +++++- src/bin/forge/normalize.rs | 2 +- src/normalize/mod.rs | 15 +++++-- tests/normalize/known_gaps.rs | 17 ++++++- tests/normalize/main.rs | 3 +- tests/normalize/normalize.rs | 72 ++++++++++++++++++++++++++++- tests/normalize/normalize_cli.rs | 77 ++++++++++++++++++++++++++++++++ 8 files changed, 202 insertions(+), 22 deletions(-) diff --git a/packages/forge/README.md b/packages/forge/README.md index 56aefb2ab..ed1b73092 100644 --- a/packages/forge/README.md +++ b/packages/forge/README.md @@ -70,17 +70,19 @@ edit. These are what that gets wrong, most serious first. Each is pinned by a test in `tests/normalize/known_gaps.rs`, and a test of what was meant to be is ignored, with `known gap` as the reason. -| Gap | What happens | -| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| A key written twice | The first copy is dropped, with no error and no warning. `forge` itself rejects a key it reads that is written twice, and no longer does in a document that has a key to rewrite. | -| Numbers | One that does not fit in 64 bits is rounded: `100000000000000000001` becomes `1e+20`, and a decimal of more than about 17 digits is shortened. `1E5` is written `100000.0`, and `0.10` is written `0.1`. | -| Deep documents | One nested deeper than 128 levels is an error, wherever it is nested. | -| Line endings | `\r\n` is written as `\n`, so every line of the file is in the diff. | -| Layout | An array or an object that is on one line is written on several (3.6% of the lines of a formatted document changed in a test), and the spaces of a document on one line are dropped. | -| Escapes | `\u00e9`, `\/` and `\u003c` are written as the characters. | -| Escaped keys | A key written with an escape is rewritten by `forge normalize`, and not by the step `forge` does before it reads a document, which looks for the text of a key. | -| Comments | JSON with comments is an error, and so is a byte order mark. | -| Cost | `forge normalize` takes about 5 times as long, and 6 times the memory, of an edit of bytes: 100 ms and 185 MB for a document of 28 MB, against 20 ms and 32 MB. The step in `forge` adds 90 ms and 130 MB to a run of 240 ms and 150 MB. | +| Gap | What happens | +| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| A key written twice | The first copy is dropped, with no error and no warning. `forge` itself rejects a key it reads that is written twice, and no longer does in a document that has a key to rewrite. | +| Numbers | One that does not fit in 64 bits is rounded: `100000000000000000001` becomes `1e+20`, and a decimal of more than about 17 digits is shortened. `1E5` is written `100000.0`, and `0.10` is written `0.1`. | +| Deep documents | One nested deeper than 128 levels is an error, wherever it is nested. | +| Line endings | `\r\n` is written as `\n`, so every line of the file is in the diff. | +| Layout | An array or an object that is on one line is written on several (3.6% of the lines of a formatted document changed in a test), and the spaces of a document on one line are dropped. | +| Error positions | An error that `forge` finds in a document that has a key to rewrite is at a line and a column of the document as it is written again, which can be past the end of the file. `forge` says so, and `forge normalize --leave-old-extensions` prints that document. | +| Escapes | `\u00e9`, `\/` and `\u003c` are written as the characters. | +| Escaped keys | A key written with an escape is rewritten by `forge normalize`, and not by the step `forge` does before it reads a document, which looks for the text of a key. | +| Comments | JSON with comments is an error, and so is a byte order mark. | +| Cost | `forge normalize` takes about 5 times as long, and 6 times the memory, of an edit of bytes: 100 ms and 185 MB for a document of 28 MB, against 20 ms and 32 MB. The step in `forge` adds 90 ms and 130 MB to a run of 240 ms and 150 MB. | +| Memory of many files | Each file that is rewritten at once is held as a tree of about 6.5 times its size, and files are read in parallel: 6 files of 28 MB took 1.1 GB, against 170 MB. | ```sh # Keep the old keys beside the native ones, for what still reads them. diff --git a/src/bin/forge/main.rs b/src/bin/forge/main.rs index 84260a8c4..b69b3fb29 100644 --- a/src/bin/forge/main.rs +++ b/src/bin/forge/main.rs @@ -123,6 +123,10 @@ fn generate(spec: &Path, target: Target, out: &Path) -> Result<(), Err> { } /// Reads the document at `path`, or from standard input when `path` is `-`. +/// What is said of a line and a column when the keys of other tools were +/// rewritten in memory, which lays the document out as it is written. +const REWRITTEN: &str = "of the document with the keys of other tools rewritten in memory, which `forge normalize --leave-old-extensions` prints"; + fn read(path: &Path) -> Result { let parse = || -> Result { let json = if path == Path::new("-") { @@ -132,8 +136,14 @@ fn read(path: &Path) -> Result { } else { fs::read(path)? }; - let json = normalize::before_reading(json)?; - Ok(serde_json::from_slice(&json)?) + let (json, rewritten) = normalize::before_reading(json)?; + serde_json::from_slice(&json).map_err(|err| -> Err { + if rewritten { + format!("{err} ({REWRITTEN})").into() + } else { + err.into() + } + }) }; parse().map_err(|err| format!("{}: {err}", path.display()).into()) } diff --git a/src/bin/forge/normalize.rs b/src/bin/forge/normalize.rs index 981101386..2ab7696e8 100644 --- a/src/bin/forge/normalize.rs +++ b/src/bin/forge/normalize.rs @@ -319,7 +319,7 @@ fn create_private(path: &Path) -> io::Result { /// Rewrites the extensions other tools write in the document `json` into the /// native ones, in memory, before `forge` reads it: see /// [`forge::normalize::before_reading`]. -pub fn before_reading(json: Vec) -> Result, Err> { +pub fn before_reading(json: Vec) -> Result<(Vec, bool), Err> { Ok(forge::normalize::before_reading(json)?) } diff --git a/src/normalize/mod.rs b/src/normalize/mod.rs index d7c63d973..080c44f5b 100644 --- a/src/normalize/mod.rs +++ b/src/normalize/mod.rs @@ -114,18 +114,25 @@ pub fn mentions_old_keys(json: &[u8]) -> bool { /// written twice in it, say, is no longer an error here, as the second is the /// one that is read. /// +/// It returns the document, and whether it is a rewritten one: a line and a +/// column that `forge` finds an error at are then those of the rewritten +/// document, which is laid out as [`write`] lays it out, and not those of the +/// file. +/// /// This is temporary. `forge` reads native keys only, and `forge normalize` is /// what is meant to come first. This keeps a document that has not been through /// it building as it did, until the release that stops doing it. -pub fn before_reading(json: Vec) -> Result, Error> { +pub fn before_reading(json: Vec) -> Result<(Vec, bool), Error> { if !mentions_old_keys(&json) { - return Ok(json); + return Ok((json, false)); } let options = Options { leave_old_extensions: true, }; - let normalized = normalize(&json, &options)?; - Ok(normalized.json.unwrap_or(json)) + match normalize(&json, &options)?.json { + Some(rewritten) => Ok((rewritten, true)), + None => Ok((json, false)), + } } /// Rewrites the keys of the document `json` that a native key replaces. diff --git a/tests/normalize/known_gaps.rs b/tests/normalize/known_gaps.rs index f59312eba..fcd96a6f3 100644 --- a/tests/normalize/known_gaps.rs +++ b/tests/normalize/known_gaps.rs @@ -7,7 +7,7 @@ //! pass once a gap is fixed are the ones that are ignored, in `normalize.rs` //! and `normalize_cli.rs`, with the reason. -use forge::normalize::{Options, mentions_old_keys, normalize}; +use forge::normalize::{Options, before_reading, mentions_old_keys, normalize}; /// A document with one key to rewrite, so that it is written again, and /// `extra` as more of the members of the operation. @@ -132,6 +132,21 @@ fn a_document_nested_deeper_than_128_levels_is_an_error() { assert!(err.to_string().contains("recursion limit"), "{err}"); } +/// GAP: an error that `forge` finds in a document that has a key to rewrite +/// is at a line and a column of the document as it is written again, and not +/// of the file, which is shorter when it has arrays or objects on one line. +/// `forge` says so, in the words of `REWRITTEN` in `main.rs`. +#[test] +fn a_position_in_an_error_is_of_the_rewritten_document() { + let document = with_a_key_to_rewrite(r#""tags": ["a", "b"], "x-fern-availability": 5,"#); + let line_of = |text: &str| text.lines().position(|line| line.contains("availability")).unwrap() + 1; + + let (rewritten, changed) = before_reading(document.clone().into_bytes()).unwrap(); + + assert!(changed); + assert!(line_of(&String::from_utf8(rewritten).unwrap()) > line_of(&document)); +} + /// What is not a gap: a document without a key to rewrite is not read, and not /// written, by the step before `forge` reads it, so none of the above is true /// of it. diff --git a/tests/normalize/main.rs b/tests/normalize/main.rs index 44b540e62..5780f22eb 100644 --- a/tests/normalize/main.rs +++ b/tests/normalize/main.rs @@ -8,8 +8,7 @@ //! written out. What it gets wrong, because a document is read and written //! again, is in `known_gaps`. //! -//! The suite of `tests/parse`, which has no vendor names in it, runs without -//! the `normalize` feature. +//! The suite of `tests/parse` has no vendor names in it. mod common; #[path = "../wasm/dump.rs"] diff --git a/tests/normalize/normalize.rs b/tests/normalize/normalize.rs index 4c838c518..b214b5830 100644 --- a/tests/normalize/normalize.rs +++ b/tests/normalize/normalize.rs @@ -987,7 +987,7 @@ fn the_aggregate_builds_the_same_forge_in_both_modes_and_only_when_normalized() /// `forge` is given. #[track_caller] fn read_in_memory(document: &str) -> String { - String::from_utf8(before_reading(document.as_bytes().to_vec()).unwrap()).unwrap() + String::from_utf8(before_reading(document.as_bytes().to_vec()).unwrap().0).unwrap() } /// The extensions of the first method of a document, as a plugin is given them. @@ -1052,3 +1052,73 @@ fn the_step_before_forge_reads_leaves_a_document_with_no_key_to_rewrite_as_it_is assert_eq!(read_in_memory(&document), document); } + +/// The keys of the object at `pointer`, in the order they are written. +#[track_caller] +fn keys_at(text: &str, pointer: &str) -> Vec { + let document: Value = serde_json::from_str(text).unwrap(); + let object = document + .pointer(pointer) + .unwrap_or_else(|| panic!("no {pointer} in {text}")); + object.as_object().unwrap().keys().cloned().collect() +} + +/// What the README says is kept, and the tests that compare documents as +/// JSON, which does not care about order, do not say. +#[test] +fn a_key_that_is_rewritten_keeps_its_place_among_the_others() { + let document = operation_with( + r#""tags": [], "x-fern-sdk-group-name": ["a"], "description": "d", "x-fern-sdk-method-name": "get", "summary": "s","#, + ); + + let (text, _) = edit(&document, &Options::default()); + + assert_eq!( + keys_at(&text, "/paths/~1a/get"), + [ + "tags", + "x-forge-group-name", + "description", + "x-forge-method-name", + "summary", + "responses" + ] + ); + assert_eq!(keys_at(&text, ""), ["openapi", "info", "paths"]); +} + +#[test] +fn a_key_that_is_left_is_written_right_after_the_native_key_that_replaces_it() { + let document = operation_with( + r#""tags": [], "x-fern-sdk-group-name": ["a"], "description": "d", "x-fern-sdk-method-name": "get","#, + ); + + let (text, _) = edit(&document, &KEEP); + + assert_eq!( + keys_at(&text, "/paths/~1a/get"), + [ + "tags", + "x-forge-group-name", + "x-fern-sdk-group-name", + "description", + "x-forge-method-name", + "x-fern-sdk-method-name", + "responses" + ] + ); +} + +/// A value that cannot be read is no longer left as it is, as there is no +/// leaving anything as it is: it is written as the native key, and `forge` +/// says that it cannot read it, as it does for the key it reads. +#[test] +fn a_value_that_cannot_be_read_is_rewritten_and_forge_says_so() { + let document = operation_with(r#""x-fern-sdk-group-name": 5, "x-fern-sdk-method-name": "get","#); + + let (text, report) = edit(&document, &Options::default()); + + assert_eq!(report.migrated["x-fern-sdk-group-name"], 1); + assert!(text.contains(r#""x-forge-group-name": 5"#), "{text}"); + assert!(parse_err(&text).contains("expected a string or an array of strings")); +} diff --git a/tests/normalize/normalize_cli.rs b/tests/normalize/normalize_cli.rs index ee43b690a..ce24d7e7c 100644 --- a/tests/normalize/normalize_cli.rs +++ b/tests/normalize/normalize_cli.rs @@ -848,6 +848,83 @@ fn forge_fails_on_a_key_written_twice_in_the_words_it_always_used() { } } +/// A document that has a key to rewrite is laid out as it is written, so an +/// error that `forge` finds in it is at a line of that, and not of the file. +/// `forge` says so, and `forge normalize --leave-old-extensions` prints the +/// document that the position is of. +#[test] +fn an_error_in_a_rewritten_document_says_which_document_the_position_is_of() { + let dir = dir("in-memory-position"); + let layout = |keys: [&str; 3]| { + format!( + "{{\n \"openapi\": \"3.0.3\",\n \"info\": {{\"title\": \"t\", \"version\": \"1\"}},\n \"paths\": {{\"/a\": {{\"get\": {{\n \"{}\": [\"a\", \"b\"],\n \"{}\": \"get\",\n \"tags\": [\"one\", \"two\"],\n \"responses\": {{\"204\": {{\"description\": \"ok\"}}}},\n \"{}\": 5\n }}}}}}\n}}\n", + keys[0], keys[1], keys[2] + ) + }; + let other = dir.join("other.json"); + let native = dir.join("native.json"); + fs::write( + &other, + layout(["x-fern-sdk-group-name", "x-fern-sdk-method-name", "x-fern-availability"]), + ) + .unwrap(); + fs::write( + &native, + layout(["x-forge-group-name", "x-forge-method-name", "x-forge-availability"]), + ) + .unwrap(); + let sdk = dir.join("sdk"); + + let out = forge( + &[ + other.to_str().unwrap(), + "--target", + "ts", + "--out", + sdk.to_str().unwrap(), + ], + "", + ); + let err = failed(&out).to_owned(); + let at_native = forge( + &[ + native.to_str().unwrap(), + "--target", + "ts", + "--out", + sdk.to_str().unwrap(), + ], + "", + ); + + assert!(err.contains("rewritten in memory"), "{err}"); + assert!( + !failed(&at_native).contains("rewritten in memory"), + "a document that is read as it is written has no such note: {}", + text(&at_native.stderr) + ); + // The line it says is a line of the document that the note says it is of. + let line: usize = err + .split("at line ") + .nth(1) + .and_then(|rest| rest.split(' ').next()) + .and_then(|number| number.parse().ok()) + .unwrap_or_else(|| panic!("no line in {err}")); + let printed = normalize(&["--leave-old-extensions", other.to_str().unwrap()], ""); + let printed = text(&printed.stdout); + assert!( + printed + .lines() + .nth(line - 1) + .is_some_and(|text| text.contains("x-forge-availability")), + "line {line} of:\n{printed}" + ); + assert!( + line > fs::read_to_string(&other).unwrap().lines().count(), + "the position is past the end of the file: that is the point of the note" + ); +} + /// A value that cannot be read is for `forge` to say, as it reads it, in the /// words it always used, and on the line and column it always gave. #[test] From f5a80b2f182f8bf283c5ba9bd6fdf65f52f18dc7 Mon Sep 17 00:00:00 2001 From: Niels Swimberghe Date: Wed, 28 Oct 2026 15:41:28 -0400 Subject: [PATCH 11/20] docs: document the rest of what a rewrite changes, and pin it An audit of what is known against what is written down found gaps that were only in conversation: - whitespace: the indentation of the first indented line is used for the whole file, blank lines, spaces at the end of a line and what is before the first `{` are dropped, and the newlines at the end become one - `-0` is written `-0.0`, which is a float and not an integer - why the number gaps are not closed: `arbitrary_precision` makes the engine read a number as a map - the step before `forge` reads a document reads one that has the text of a key of another tool that no table reads, and changes nothing - plugins are given four extensions that the engine left out before it read native keys only Each one that is a gap has a test in `tests/normalize/known_gaps.rs` that passes now, and fails when the gap is fixed. A comment says that two keys of other tools for one native field would be won by the last, as no table has two today. --- packages/forge/README.md | 32 +++++++++++++++------------- src/normalize/walk.rs | 5 ++++- tests/normalize/known_gaps.rs | 39 +++++++++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 15 deletions(-) diff --git a/packages/forge/README.md b/packages/forge/README.md index ed1b73092..0bc14919a 100644 --- a/packages/forge/README.md +++ b/packages/forge/README.md @@ -35,7 +35,9 @@ npx forge normalize --write specs/*.json For now, `forge` also does this in memory when it reads a document, so one that has not been through `forge normalize` builds as it always did. It keeps the old keys, as `--leave-old-extensions` does, so plugins, and what is built on -them, read them as they read any other extension that forge does not read. That +them, read them as they read any other extension that forge does not read. +That is a change from before `forge` read native keys only, when the keys it +read were left out of a method's extensions: plugins are given four more. That is temporary: it stops in the release that makes `forge normalize` a step to run first, and the extensions of other tools are then passed on to plugins like any other. @@ -70,19 +72,21 @@ edit. These are what that gets wrong, most serious first. Each is pinned by a test in `tests/normalize/known_gaps.rs`, and a test of what was meant to be is ignored, with `known gap` as the reason. -| Gap | What happens | -| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| A key written twice | The first copy is dropped, with no error and no warning. `forge` itself rejects a key it reads that is written twice, and no longer does in a document that has a key to rewrite. | -| Numbers | One that does not fit in 64 bits is rounded: `100000000000000000001` becomes `1e+20`, and a decimal of more than about 17 digits is shortened. `1E5` is written `100000.0`, and `0.10` is written `0.1`. | -| Deep documents | One nested deeper than 128 levels is an error, wherever it is nested. | -| Line endings | `\r\n` is written as `\n`, so every line of the file is in the diff. | -| Layout | An array or an object that is on one line is written on several (3.6% of the lines of a formatted document changed in a test), and the spaces of a document on one line are dropped. | -| Error positions | An error that `forge` finds in a document that has a key to rewrite is at a line and a column of the document as it is written again, which can be past the end of the file. `forge` says so, and `forge normalize --leave-old-extensions` prints that document. | -| Escapes | `\u00e9`, `\/` and `\u003c` are written as the characters. | -| Escaped keys | A key written with an escape is rewritten by `forge normalize`, and not by the step `forge` does before it reads a document, which looks for the text of a key. | -| Comments | JSON with comments is an error, and so is a byte order mark. | -| Cost | `forge normalize` takes about 5 times as long, and 6 times the memory, of an edit of bytes: 100 ms and 185 MB for a document of 28 MB, against 20 ms and 32 MB. The step in `forge` adds 90 ms and 130 MB to a run of 240 ms and 150 MB. | -| Memory of many files | Each file that is rewritten at once is held as a tree of about 6.5 times its size, and files are read in parallel: 6 files of 28 MB took 1.1 GB, against 170 MB. | +| Gap | What happens | +| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| A key written twice | The first copy is dropped, with no error and no warning. `forge` itself rejects a key it reads that is written twice, and no longer does in a document that has a key to rewrite. | +| Numbers | One that does not fit in 64 bits is rounded: `100000000000000000001` becomes `1e+20`, and a decimal of more than about 17 digits is shortened. `1E5` is written `100000.0`, and `0.10` is written `0.1`, and `-0` is written `-0.0`, which is a float and not an integer. Keeping the text of every number needs `serde_json`'s `arbitrary_precision`, which is not on: the engine reads a number from it as a map, so `maxItems: 2.0` is an error, and the engine has to read that first. | +| Deep documents | One nested deeper than 128 levels is an error, wherever it is nested. | +| Line endings | `\r\n` is written as `\n`, so every line of the file is in the diff. | +| Layout | An array or an object that is on one line is written on several (3.6% of the lines of a formatted document changed in a test), and the spaces of a document on one line are dropped. | +| Whitespace | The indentation of the first indented line is used for the whole file, so mixed indentation becomes one. Blank lines between members, spaces at the end of a line, and anything before the first `{` are dropped, and any newlines at the end become one. | +| Error positions | An error that `forge` finds in a document that has a key to rewrite is at a line and a column of the document as it is written again, which can be past the end of the file. `forge` says so, and `forge normalize --leave-old-extensions` prints that document. | +| Escapes | `\u00e9`, `\/` and `\u003c` are written as the characters. | +| Escaped keys | A key written with an escape is rewritten by `forge normalize`, and not by the step `forge` does before it reads a document, which looks for the text of a key. | +| Reads that change nothing | The step `forge` does before it reads a document looks for the text of a key of another tool, not for a key the tables read: a document with a key that begins as one of another tool's does, and is not one the tables read, is read for nothing, and is not changed. | +| Comments | JSON with comments is an error, and so is a byte order mark. | +| Cost | `forge normalize` takes about 5 times as long, and 6 times the memory, of an edit of bytes: 100 ms and 185 MB for a document of 28 MB, against 20 ms and 32 MB. The step in `forge` adds 90 ms and 130 MB to a run of 240 ms and 150 MB. | +| Memory of many files | Each file that is rewritten at once is held as a tree of about 6.5 times its size, and files are read in parallel: 6 files of 28 MB took 1.1 GB, against 170 MB. | ```sh # Keep the old keys beside the native ones, for what still reads them. diff --git a/src/normalize/walk.rs b/src/normalize/walk.rs index b7ea20725..42c5dc7a7 100644 --- a/src/normalize/walk.rs +++ b/src/normalize/walk.rs @@ -344,7 +344,10 @@ impl Walk<'_> { return; } - // The members are written again in the order they were in. + // The members are written again in the order they were in. If two keys + // of other tools said the same native field, the last would win here, + // where a rewrite of bytes would have written the native key twice, for + // `forge` to reject. No table has two keys for a field today. for (key, value) in std::mem::take(members) { match actions.iter().position(|(name, _)| *name == key) { None => { diff --git a/tests/normalize/known_gaps.rs b/tests/normalize/known_gaps.rs index fcd96a6f3..a582b47e0 100644 --- a/tests/normalize/known_gaps.rs +++ b/tests/normalize/known_gaps.rs @@ -147,6 +147,45 @@ fn a_position_in_an_error_is_of_the_rewritten_document() { assert!(line_of(&String::from_utf8(rewritten).unwrap()) > line_of(&document)); } +/// GAP: the whitespace outside strings is the writer's: one indentation for +/// the file, no blank lines, no spaces at the end of a line, nothing before the +/// first `{`, and one newline at the end. +#[test] +fn whitespace_is_the_writers() { + let document = "\n\n {\n \"openapi\": \"3.0.3\", \n\t\"paths\": {\"/a\": {\"get\": {\n\n \"x-fern-ignore\": true}}}\n} \n\n\n"; + + let text = rewritten(document); + + assert_eq!( + text, + "{\n \"openapi\": \"3.0.3\",\n \"paths\": {\n \"/a\": {\n \"get\": {\n \"x-forge-skip\": true\n }\n }\n }\n}\n" + ); +} + +/// GAP: `-0` is read as a float, and written as one. +#[test] +fn minus_zero_is_written_as_a_float() { + let text = rewritten(&with_a_key_to_rewrite(r#""x-n": -0,"#)); + + assert!(text.contains("-0.0"), "{text}"); +} + +/// GAP: the step before `forge` reads a document is given a document that has +/// the text of a key of another tool that no table reads, reads it, and does +/// nothing to it. +#[test] +fn a_key_of_another_tool_that_is_not_read_is_found_and_changes_nothing() { + let document = r#"{"openapi": "3.0.3", "x-fern-unknown": 1}"#; + + assert!(mentions_old_keys(document.as_bytes())); + assert!( + normalize(document.as_bytes(), &Options::default()) + .unwrap() + .json + .is_none() + ); +} + /// What is not a gap: a document without a key to rewrite is not read, and not /// written, by the step before `forge` reads it, so none of the above is true /// of it. From d1aaa51d91089db149b9d2027996ea4937596a8c Mon Sep 17 00:00:00 2001 From: Niels Swimberghe Date: Wed, 28 Oct 2026 15:55:02 -0400 Subject: [PATCH 12/20] refactor: remove what the byte scanner left behind Found in a review of what the branch still carries from the version it replaced: - the binary had a function that only called `before_reading` of the library: it calls it - the text that the step before `forge` reads a document looks for was written out a second time, apart from the prefixes that the adapters have: it is now made from them, so a new adapter is looked for - `before_reading` and `mentions_old_keys` are only for a step that is temporary, and are not in the documentation of the library --- src/bin/forge/main.rs | 2 +- src/bin/forge/normalize.rs | 7 ------- src/normalize/mod.rs | 2 ++ src/normalize/vendor/fern.rs | 2 +- src/normalize/vendor/mod.rs | 2 +- src/normalize/vendor/stainless.rs | 2 +- src/normalize/vendor/unprefixed.rs | 2 +- 7 files changed, 7 insertions(+), 12 deletions(-) diff --git a/src/bin/forge/main.rs b/src/bin/forge/main.rs index b69b3fb29..2b59e689a 100644 --- a/src/bin/forge/main.rs +++ b/src/bin/forge/main.rs @@ -136,7 +136,7 @@ fn read(path: &Path) -> Result { } else { fs::read(path)? }; - let (json, rewritten) = normalize::before_reading(json)?; + let (json, rewritten) = forge::normalize::before_reading(json)?; serde_json::from_slice(&json).map_err(|err| -> Err { if rewritten { format!("{err} ({REWRITTEN})").into() diff --git a/src/bin/forge/normalize.rs b/src/bin/forge/normalize.rs index 2ab7696e8..477ed4b5c 100644 --- a/src/bin/forge/normalize.rs +++ b/src/bin/forge/normalize.rs @@ -316,13 +316,6 @@ fn create_private(path: &Path) -> io::Result { options.open(path) } -/// Rewrites the extensions other tools write in the document `json` into the -/// native ones, in memory, before `forge` reads it: see -/// [`forge::normalize::before_reading`]. -pub fn before_reading(json: Vec) -> Result<(Vec, bool), Err> { - Ok(forge::normalize::before_reading(json)?) -} - /// Says what in the document called `name` could not be rewritten, or was /// dropped for a native key that says something else. fn warn(name: &str, report: &Report) { diff --git a/src/normalize/mod.rs b/src/normalize/mod.rs index 080c44f5b..2e52a4dc0 100644 --- a/src/normalize/mod.rs +++ b/src/normalize/mod.rs @@ -99,6 +99,7 @@ impl From for Error { /// Returns whether `json` has the text of a key that another tool writes. A /// document that does not has nothing to rewrite, which is told without /// reading it. A key written with an escape is not found. +#[doc(hidden)] pub fn mentions_old_keys(json: &[u8]) -> bool { vendor::mentions(json) } @@ -122,6 +123,7 @@ pub fn mentions_old_keys(json: &[u8]) -> bool { /// This is temporary. `forge` reads native keys only, and `forge normalize` is /// what is meant to come first. This keeps a document that has not been through /// it building as it did, until the release that stops doing it. +#[doc(hidden)] pub fn before_reading(json: Vec) -> Result<(Vec, bool), Error> { if !mentions_old_keys(&json) { return Ok((json, false)); diff --git a/src/normalize/vendor/fern.rs b/src/normalize/vendor/fern.rs index edb222e63..1f6f42ce3 100644 --- a/src/normalize/vendor/fern.rs +++ b/src/normalize/vendor/fern.rs @@ -7,7 +7,7 @@ /// 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-"; +pub(super) const PREFIX: &str = "x-fern-"; /// The keys of an operation or an alias. pub(super) mod definition { diff --git a/src/normalize/vendor/mod.rs b/src/normalize/vendor/mod.rs index 79ebebdfe..c783968a5 100644 --- a/src/normalize/vendor/mod.rs +++ b/src/normalize/vendor/mod.rs @@ -43,7 +43,7 @@ impl Key { /// The text that every key of an adapter has in it, for finding whether a /// document has one without reading it. -const MENTIONS: [&str; 3] = ["x-fern-", "x-stainless-", "x-sensitive"]; +const MENTIONS: [&str; 3] = [fern::PREFIX, stainless::PREFIX, unprefixed::schema::SENSITIVE]; /// Returns whether `json` has the text of a key that an adapter reads in it /// somewhere, which is the least that it has to for there to be one. diff --git a/src/normalize/vendor/stainless.rs b/src/normalize/vendor/stainless.rs index 54c8d909c..79f9e3317 100644 --- a/src/normalize/vendor/stainless.rs +++ b/src/normalize/vendor/stainless.rs @@ -5,7 +5,7 @@ /// 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-"; +pub(super) const PREFIX: &str = "x-stainless-"; /// The keys of a Schema Object. pub(super) mod schema { diff --git a/src/normalize/vendor/unprefixed.rs b/src/normalize/vendor/unprefixed.rs index 54b5e5536..f3e11cbd9 100644 --- a/src/normalize/vendor/unprefixed.rs +++ b/src/normalize/vendor/unprefixed.rs @@ -7,7 +7,7 @@ pub(super) mod schema { use crate::normalize::vendor::Key; /// What other systems write for `x-forge-sensitive`. - const SENSITIVE: &str = "x-sensitive"; + pub(in crate::normalize::vendor) const SENSITIVE: &str = "x-sensitive"; /// The names of the keys of a Schema Object. #[cfg(test)] From 26d4ddd9ac2a7a9fcd6ca8d9c1e17c1ae888d035 Mon Sep 17 00:00:00 2001 From: Niels Swimberghe Date: Wed, 28 Oct 2026 16:11:59 -0400 Subject: [PATCH 13/20] refactor: walk by the kind of object, and keep one buffer for the pointer The walk was written for a scanner that read a document once, and so had to walk what was in an object before it could decide what to do to the object's keys. A tree has no such need. It is now one function over the kind of object, `Kind`, and one place, `child`, that says what is read below each kind by each key: the shapes that `forge` reads are in a table and not in nine functions that call each other through function pointers. An object's own keys are rewritten before what is in it is walked. The JSON Pointer that a conflict says is kept in one buffer that is added to and cut back, and not a string made for each member that is visited, which was kept for the one conflict in a document. What `forge normalize` writes for the 28 MB aggregate is the same, byte for byte, in both modes, and so is the SDK built from it. It takes as long and as much memory. --- src/normalize/walk.rs | 272 ++++++++++++++++++------------------------ 1 file changed, 114 insertions(+), 158 deletions(-) diff --git a/src/normalize/walk.rs b/src/normalize/walk.rs index 42c5dc7a7..f2da5a050 100644 --- a/src/normalize/walk.rs +++ b/src/normalize/walk.rs @@ -3,8 +3,11 @@ //! 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. The keys of an object are rewritten once its members have been -//! walked. +//! passed over. What is below an object is found by what kind of object it is +//! and what the key is, in [`child`], which is the one place that says where +//! `forge` reads. + +use std::fmt::Write; use serde_json::{Map, Value}; @@ -116,10 +119,66 @@ impl Level for Schema { } } -/// A step on the way to an object, which is only written out for a conflict. -enum Step { - Key(String), - Index(usize), +/// A kind of object that is read. +#[derive(Clone, Copy)] +enum Kind { + Document, + Components, + PathItem, + Operation, + /// An alias of an operation, which has the keys of one. + Alias, + Parameter, + /// A request body or a response, which has nothing but a `content` to read. + Body, + /// A media type, which has nothing but a `schema` to read. + Media, + Schema, +} + +/// How what is read is held by the key that has it. +#[derive(Clone, Copy)] +enum Shape { + /// The value is one object. + One(Kind), + /// The value is an object, and each of its members is. + Values(Kind), + /// The value is an array, and each of its elements is. + Elements(Kind), +} + +/// Returns what the member `key` of an object of the kind `kind` holds, if +/// `forge` reads it. +fn child(kind: Kind, key: &str, value: &Value) -> Option { + use Kind::*; + use Shape::*; + Some(match (kind, key) { + (Document, "paths") => Values(PathItem), + (Document, "components") => One(Components), + (Components, "schemas") => Values(Schema), + (Components, "parameters") => Values(Parameter), + (Components, "requestBodies" | "responses") => Values(Body), + (Components, "pathItems") => Values(PathItem), + (PathItem, "parameters") => Elements(Parameter), + ( + PathItem, + "get" | "put" | "post" | "delete" | "options" | "head" | "patch" | "trace" | "query" | "connect", + ) => One(Operation), + (PathItem, "additionalOperations") => Values(Operation), + (Operation, "parameters") => Elements(Parameter), + (Operation, "requestBody") => One(Body), + (Operation, "responses") => Values(Body), + (Operation, "x-forge-aliases") => Elements(Alias), + (Parameter, "schema") => One(Schema), + (Parameter, "content") => Values(Media), + (Body, "content") => Values(Media), + (Media, "schema") => One(Schema), + (Schema, "properties" | "$defs") => Values(Schema), + (Schema, "additionalProperties" | "unevaluatedProperties" | "not") => One(Schema), + (Schema, "items") if value.is_object() => One(Schema), + (Schema, "items" | "prefixItems" | "allOf" | "anyOf" | "oneOf") => Elements(Schema), + _ => return None, + }) } /// What is done to a member of an object. @@ -138,163 +197,74 @@ pub(super) fn walk(document: &mut Value, options: &Options) -> Report { let mut walk = Walk { options, report: Report::default(), - path: Vec::new(), + pointer: String::new(), }; - walk.document(document); + walk.visit(Kind::Document, document); walk.report } struct Walk<'o> { options: &'o Options, report: Report, - path: Vec, + /// Where the object being read is, as a JSON Pointer, which is only + /// wanted for a conflict. + pointer: String, } impl Walk<'_> { - /// Reads the member `key` of an object with `child`. - fn enter(&mut self, key: &str, value: &mut Value, child: fn(&mut Self, &mut Value)) { - self.path.push(Step::Key(key.to_owned())); - child(self, value); - self.path.pop(); - } - - fn document(&mut self, document: &mut Value) { - let Some(members) = document.as_object_mut() else { - return; - }; - for (key, value) in members.iter_mut() { - match key.as_str() { - "paths" => self.enter(key, value, |walk, paths| walk.each_value(paths, Self::path_item)), - "components" => self.enter(key, value, Self::components), - _ => {} - } - } - } - - /// Reads each member of an object with `child`, and skips a value that is - /// not an object. - fn each_value(&mut self, value: &mut Value, child: fn(&mut Self, &mut Value)) { + /// Reads an object of the kind `kind`: its keys that are to be rewritten + /// are, and then what is in it that is read. + fn visit(&mut self, kind: Kind, value: &mut Value) { let Some(members) = value.as_object_mut() else { return; }; - for (key, value) in members.iter_mut() { - self.enter(key, value, child); + match kind { + Kind::Operation | Kind::Alias => self.rewrite::(members), + Kind::Parameter => self.rewrite::(members), + Kind::Schema => self.rewrite::(members), + Kind::Document | Kind::Components | Kind::PathItem | Kind::Body | Kind::Media => {} } - } - - /// Reads each element of an array with `child`, and skips a value that is - /// not an array. - fn each_element(&mut self, value: &mut Value, child: fn(&mut Self, &mut Value)) { - let Some(elements) = value.as_array_mut() else { - return; - }; - for (index, element) in elements.iter_mut().enumerate() { - self.path.push(Step::Index(index)); - child(self, element); - self.path.pop(); - } - } - - /// Reads the member of an object that is called `name` with `child`. - fn only(&mut self, value: &mut Value, name: &str, child: fn(&mut Self, &mut Value)) { - if let Some(member) = value.as_object_mut().and_then(|members| members.get_mut(name)) { - self.enter(name, member, child); - } - } - - fn components(&mut self, value: &mut Value) { - let Some(members) = value.as_object_mut() else { - return; - }; - for (key, value) in members.iter_mut() { - match key.as_str() { - "schemas" => self.enter(key, value, |walk, v| walk.each_value(v, Self::schema)), - "parameters" => self.enter(key, value, |walk, v| walk.each_value(v, Self::parameter)), - "requestBodies" | "responses" => self.enter(key, value, |walk, v| walk.each_value(v, Self::content_of)), - "pathItems" => self.enter(key, value, |walk, v| walk.each_value(v, Self::path_item)), - _ => {} - } - } - } - - fn path_item(&mut self, value: &mut Value) { - let Some(members) = value.as_object_mut() else { - return; - }; - for (key, value) in members.iter_mut() { - match key.as_str() { - "parameters" => self.enter(key, value, |walk, v| walk.each_element(v, Self::parameter)), - "get" | "put" | "post" | "delete" | "options" | "head" | "patch" | "trace" | "query" | "connect" => { - self.enter(key, value, Self::operation); + for (key, member) in members.iter_mut() { + let Some(shape) = child(kind, key, member) else { + continue; + }; + let at = self.push_key(key); + match shape { + Shape::One(kind) => self.visit(kind, member), + Shape::Values(kind) => { + if let Some(members) = member.as_object_mut() { + for (key, member) in members.iter_mut() { + let at = self.push_key(key); + self.visit(kind, member); + self.pointer.truncate(at); + } + } + } + Shape::Elements(kind) => { + if let Some(elements) = member.as_array_mut() { + for (index, element) in elements.iter_mut().enumerate() { + let at = self.pointer.len(); + let _ = write!(self.pointer, "/{index}"); + self.visit(kind, element); + self.pointer.truncate(at); + } + } } - "additionalOperations" => self.enter(key, value, |walk, v| walk.each_value(v, Self::operation)), - _ => {} } + self.pointer.truncate(at); } } - fn operation(&mut self, value: &mut Value) { - self.object::(value, |walk, key, child| match key { - "parameters" => walk.enter(key, child, |walk, v| walk.each_element(v, Self::parameter)), - "requestBody" => walk.enter(key, child, Self::content_of), - "responses" => walk.enter(key, child, |walk, v| walk.each_value(v, Self::content_of)), - "x-forge-aliases" => walk.enter(key, child, |walk, v| walk.each_element(v, Self::alias)), - _ => {} - }); - } - - fn alias(&mut self, value: &mut Value) { - self.object::(value, |_, _, _| {}); - } - - fn parameter(&mut self, value: &mut Value) { - self.object::(value, |walk, key, child| match key { - "schema" => walk.enter(key, child, Self::schema), - "content" => walk.enter(key, child, Self::media_types), - _ => {} - }); - } - - /// Reads a request body or a response, which have nothing but a `content` - /// to read. - fn content_of(&mut self, value: &mut Value) { - self.only(value, "content", Self::media_types); - } - - fn media_types(&mut self, value: &mut Value) { - self.each_value(value, Self::media); - } - - fn media(&mut self, value: &mut Value) { - self.only(value, "schema", Self::schema); - } - - fn schema(&mut self, value: &mut Value) { - self.object::(value, |walk, key, child| match key { - "properties" | "$defs" => walk.enter(key, child, |walk, v| walk.each_value(v, Self::schema)), - "additionalProperties" | "unevaluatedProperties" | "not" => walk.enter(key, child, Self::schema), - "items" if child.is_object() => walk.enter(key, child, Self::schema), - "items" | "prefixItems" | "allOf" | "anyOf" | "oneOf" => { - walk.enter(key, child, |walk, v| walk.each_element(v, Self::schema)); - } - _ => {} - }); - } - - /// Reads the object, which is of the kind `L`: each member with - /// `children`, and then the keys of the object that are to be rewritten. - fn object(&mut self, value: &mut Value, children: fn(&mut Self, &str, &mut Value)) { - let Some(members) = value.as_object_mut() else { - return; - }; - let mut extension = false; - for (key, child) in members.iter_mut() { - extension |= key.starts_with("x-"); - children(self, key, child); - } - if extension { - self.rewrite::(members); + /// Adds the key to the pointer, and returns how long it was before. + fn push_key(&mut self, key: &str) -> usize { + let at = self.pointer.len(); + self.pointer.push('/'); + if key.contains(['~', '/']) { + self.pointer.push_str(&key.replace('~', "~0").replace('/', "~1")); + } else { + self.pointer.push_str(key); } + at } /// Decides what to do to the keys of the object, which is of the kind `L`, @@ -318,9 +288,8 @@ impl Walk<'_> { // The native key that is beside is the one that decides. let beside = members.get(native); if beside.is_some_and(|beside| !L::same(hit.field, value, beside)) { - let location = self.pointer(); self.report.conflicts.push(Conflict { - location, + location: self.pointer.clone(), key: key.clone(), native, }); @@ -366,17 +335,4 @@ impl Walk<'_> { } } } - - /// 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(key) => out.push_str(&key.replace('~', "~0").replace('/', "~1")), - Step::Index(index) => out.push_str(&index.to_string()), - } - } - out - } } From dbc13ffc3e7f7a7db2f09606764f21197fb73132 Mon Sep 17 00:00:00 2001 From: Niels Swimberghe Date: Wed, 28 Oct 2026 16:12:00 -0400 Subject: [PATCH 14/20] refactor: remove more of what the byte scanner left behind - the nine tests of what the byte edit did, which were ignored with a reason, are removed: each is pinned by a test in `known_gaps.rs` that passes now and fails when the gap is fixed, and one more, for `forge` not rejecting a key written twice, was missing and is added - the test that fed damaged documents to the walk, written to find a scanner that panicked, is removed: what it tests now is `serde_json` - `forge normalize` reads one file at a time, as it was read many at a time because reading was cheap, and is not now: 6 files of 28 MB took 1.1 GB and now take 184 MB, in 1.1 s and not 0.3 s --- packages/forge/README.md | 5 +- src/bin/forge/normalize.rs | 4 +- src/normalize/vendor/tests.rs | 57 +------------- tests/normalize/known_gaps.rs | 21 ++++- tests/normalize/normalize.rs | 130 ------------------------------- tests/normalize/normalize_cli.rs | 91 ---------------------- 6 files changed, 25 insertions(+), 283 deletions(-) diff --git a/packages/forge/README.md b/packages/forge/README.md index 0bc14919a..661d9a3d3 100644 --- a/packages/forge/README.md +++ b/packages/forge/README.md @@ -69,8 +69,8 @@ as a warning if it says something else. The file is read as JSON and written again, which is simple, and is not a minimal edit. These are what that gets wrong, most serious first. Each is pinned by a -test in `tests/normalize/known_gaps.rs`, and a test of what was meant to be is -ignored, with `known gap` as the reason. +test in `tests/normalize/known_gaps.rs`, which passes now and fails when the gap +is fixed. | Gap | What happens | | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | @@ -86,7 +86,6 @@ ignored, with `known gap` as the reason. | Reads that change nothing | The step `forge` does before it reads a document looks for the text of a key of another tool, not for a key the tables read: a document with a key that begins as one of another tool's does, and is not one the tables read, is read for nothing, and is not changed. | | Comments | JSON with comments is an error, and so is a byte order mark. | | Cost | `forge normalize` takes about 5 times as long, and 6 times the memory, of an edit of bytes: 100 ms and 185 MB for a document of 28 MB, against 20 ms and 32 MB. The step in `forge` adds 90 ms and 130 MB to a run of 240 ms and 150 MB. | -| Memory of many files | Each file that is rewritten at once is held as a tree of about 6.5 times its size, and files are read in parallel: 6 files of 28 MB took 1.1 GB, against 170 MB. | ```sh # Keep the old keys beside the native ones, for what still reads them. diff --git a/src/bin/forge/normalize.rs b/src/bin/forge/normalize.rs index 477ed4b5c..8619df8d3 100644 --- a/src/bin/forge/normalize.rs +++ b/src/bin/forge/normalize.rs @@ -8,7 +8,6 @@ use std::path::{Path, PathBuf}; use forge::Err; use forge::normalize::{Normalized, Options, Report, normalize}; -use rayon::prelude::*; /// What `forge normalize` is given, which says what it does in `main.rs`. #[derive(clap::Args)] @@ -83,8 +82,9 @@ pub fn run(args: Args) -> Result<(), Err> { return print_file(file, &options, say); } + // One file at a time: each is held as a tree of several times its size. let results: Vec<(PathBuf, Result)> = files - .into_par_iter() + .into_iter() .map(|input| { let result = rewrite(&input.real, &options, say).map_err(|err| format!("{}: {err}", input.shown.display())); (input.shown, result) diff --git a/src/normalize/vendor/tests.rs b/src/normalize/vendor/tests.rs index 38a1320ef..120589129 100644 --- a/src/normalize/vendor/tests.rs +++ b/src/normalize/vendor/tests.rs @@ -1,60 +1,9 @@ -//! The vendor tables, and `normalize` on documents that are damaged. The second -//! is here, and not in `tests/normalize/`, as it is of what is private to -//! `normalize`, and names the keys of other tools, which only this module may. +//! The vendor tables. They are here, and not in `tests/normalize/`, as they are +//! of what is private to `normalize`, and name the keys of other tools, which +//! only this module may. use super::*; use crate::metadata; -use crate::normalize::{Options, normalize}; - -/// 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 -} - -#[test] -fn a_damaged_document_is_an_error_or_is_rewritten_as_json_and_never_panics() { - let mut rewritten = 0; - for document in damaged(DOCUMENT) { - for options in [Options::default(), KEEP] { - if let Ok(normalized) = normalize(&document, &options) - && let Some(json) = normalized.json - { - serde_json::from_slice::(&json).unwrap(); - rewritten += 1; - } - } - } - assert!(rewritten > 0, "some of them were still JSON, with keys to rewrite"); -} #[test] fn a_document_that_has_a_key_of_the_tables_is_found_to_mention_one() { diff --git a/tests/normalize/known_gaps.rs b/tests/normalize/known_gaps.rs index a582b47e0..8c0bc1e22 100644 --- a/tests/normalize/known_gaps.rs +++ b/tests/normalize/known_gaps.rs @@ -3,12 +3,12 @@ //! //! Each test says what a document becomes, which is not what it should be, and //! passes. When one of them fails, a gap was fixed: move it out of here and -//! out of "Known gaps" in the README of the package. The tests that would -//! pass once a gap is fixed are the ones that are ignored, in `normalize.rs` -//! and `normalize_cli.rs`, with the reason. +//! out of "Known gaps" in the README of the package. use forge::normalize::{Options, before_reading, mentions_old_keys, normalize}; +use super::common::{parse, parse_err}; + /// A document with one key to rewrite, so that it is written again, and /// `extra` as more of the members of the operation. fn with_a_key_to_rewrite(extra: &str) -> String { @@ -97,6 +97,21 @@ fn escapes_in_strings_are_written_as_the_characters() { assert!(text.contains("café a/b "), "{text}"); } +/// GAP: `forge` rejects a key that it reads that is written twice, and does not +/// in a document that has a key to rewrite: the step before it reads the +/// document drops the first copy, and so there is nothing to reject. +#[test] +fn forge_does_not_reject_a_key_written_twice_in_a_document_that_has_a_key_to_rewrite() { + let twice = r#""x-forge-skip": true, "x-forge-skip": false,"#; + let native = with_a_key_to_rewrite(twice).replace("x-fern-sdk-method-name", "x-forge-method-name"); + let other = with_a_key_to_rewrite(twice); + + assert!(parse_err(&native).contains("duplicate field `x-forge-skip`")); + let (rewritten, changed) = before_reading(other.into_bytes()).unwrap(); + assert!(changed); + parse(&String::from_utf8(rewritten).unwrap()); +} + /// GAP: a key written with an escape is rewritten by `forge normalize`, which /// reads the document, and not by the step `forge` does before it reads one, /// which only reads a document that has the text of a key to rewrite in it. diff --git a/tests/normalize/normalize.rs b/tests/normalize/normalize.rs index b214b5830..a9ba20bc7 100644 --- a/tests/normalize/normalize.rs +++ b/tests/normalize/normalize.rs @@ -320,47 +320,6 @@ fn a_conflict_says_where_it_is_as_a_json_pointer() { 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] -#[ignore = "known gap: layout, escapes and line endings are not kept"] -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 { @@ -409,49 +368,6 @@ fn a_removed_key_takes_its_comma_and_leaves_the_others_as_they_were() { } } -#[test] -#[ignore = "known gap: whitespace is not kept"] -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] -#[ignore = "known gap: spacing around a key is not kept"] -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(); @@ -531,23 +447,6 @@ fn a_group_path_a_dotted_string_cannot_say_is_kept_as_an_array() { } } -#[test] -#[ignore = "known gap: layout is not kept: an array written on one line is written on several"] -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 [ @@ -733,24 +632,6 @@ fn a_native_key_written_twice_with_no_key_to_rewrite_beside_it_is_left_as_it_is( } } -#[test] -#[ignore = "known gap: a key written twice is dropped, the first of the two, without a word"] -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","#); @@ -838,17 +719,6 @@ fn schemas_nested_without_end_are_an_error_and_not_a_stack_overflow() { assert!(normalize(document.as_bytes(), &Options::default()).is_err()); } -#[test] -#[ignore = "known gap: a document nested deeper than 128 levels is an error"] -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 diff --git a/tests/normalize/normalize_cli.rs b/tests/normalize/normalize_cli.rs index ce24d7e7c..3df77a45d 100644 --- a/tests/normalize/normalize_cli.rs +++ b/tests/normalize/normalize_cli.rs @@ -91,24 +91,6 @@ fn a_file_is_rewritten_in_place_as_it_was_written() { assert_eq!(leftovers.len(), 1, "no file is left beside it"); } -#[test] -#[ignore = "known gap: layout, escapes and line endings are not kept"] -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(&rewrite(&[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_file_that_needs_nothing_is_left_as_it_is() { let dir = dir("native"); @@ -584,33 +566,6 @@ fn a_byte_order_mark_is_named_and_the_file_is_left_as_it_is() { assert_eq!(fs::read_to_string(&file).unwrap(), content); } -#[test] -#[ignore = "known gap: a key written twice is dropped, the first of the two, without a word"] -fn a_key_written_twice_is_a_warning_that_says_where_and_the_file_is_rewritten() { - 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 = rewrite(&[file.to_str().unwrap()], ""); - - succeeded(&out); - let err = text(&out.stderr); - assert!( - err.contains( - "spec.json: warning: /paths/~1a/get: x-fern-sdk-method-name is written twice in this object, and is left as it is" - ), - "{err}" - ); - assert!(err.contains("1 file: 1 rewritten, 0 unchanged, 1 warning"), "{err}"); - let written = fs::read_to_string(&file).unwrap(); - assert_eq!(written.matches("\"x-fern-sdk-method-name\"").count(), 2, "{written}"); - assert!(written.contains("\"x-forge-group-name\""), "{written}"); -} - #[test] fn the_summary_counts_the_warnings() { let dir = dir("warnings"); @@ -802,52 +757,6 @@ fn forge_rewrites_standard_input_in_memory_too() { assert!(!built.is_empty() && built == files(&native)); } -/// The documents and the errors are those of `forge` before it left reading -/// these keys to `forge normalize`: each message is what that `forge` said of -/// the document, word for word, down to the line and the column. -#[test] -#[ignore = "known gap: forge no longer fails on a key written twice in a document that has keys to rewrite"] -fn forge_fails_on_a_key_written_twice_in_the_words_it_always_used() { - let dir = dir("in-memory-error"); - let base = |keys: &str| { - format!( - r#"{{"openapi":"3.0.3","info":{{"title":"t","version":"1"}},"paths":{{"/a":{{"get":{{{keys},"responses":{{"204":{{"description":"ok"}}}}}}}}}}}}"# - ) - }; - for (name, document, message) in [ - ( - "one-line", - base(r#""x-fern-sdk-group-name":["a"],"x-fern-sdk-method-name":"get","x-fern-ignore":false,"x-fern-ignore":true"#), - "duplicate field `x-fern-ignore` at line 1 column 174", - ), - ( - "several-lines", - "{\n \"openapi\": \"3.0.3\",\n \"info\": {\"title\": \"t\", \"version\": \"1\"},\n \"paths\": {\"/a\": {\"get\": {\n \"x-fern-sdk-group-name\": [\"a\"],\n \"x-fern-sdk-method-name\": \"get\",\n \"x-fern-sdk-method-name\": \"again\",\n \"responses\": {\"204\": {\"description\": \"ok\"}}}}}}\n".to_owned(), - "duplicate field `x-fern-sdk-method-name` at line 7 column 30", - ), - ( - "three-copies", - base(r#""x-fern-sdk-group-name":["a"],"x-fern-sdk-method-name":"a","x-fern-sdk-method-name":"b","x-fern-sdk-method-name":"c""#), - "duplicate field `x-fern-sdk-method-name` at line 1 column 159", - ), - ( - "not-ascii-before-it", - base(r#""description":"café 中","x-fern-sdk-group-name":["a"],"x-fern-sdk-method-name":"a","x-fern-sdk-method-name":"b""#), - "duplicate field `x-fern-sdk-method-name` at line 1 column 185", - ), - ] { - let spec = dir.join(format!("{name}.json")); - fs::write(&spec, document).unwrap(); - let out = dir.join(format!("sdk-{name}")); - - let run = forge(&[spec.to_str().unwrap(), "--target", "ts", "--out", out.to_str().unwrap()], ""); - - let err = failed(&run); - assert_eq!(err.trim_end(), format!("forge: {}: {message}", spec.display()), "{name}"); - assert!(!out.exists(), "{name}: nothing was written"); - } -} - /// A document that has a key to rewrite is laid out as it is written, so an /// error that `forge` finds in it is at a line of that, and not of the file. /// `forge` says so, and `forge normalize --leave-old-extensions` prints the From a0d279c6f519cf170e877f21bc88956584ae3434 Mon Sep 17 00:00:00 2001 From: Niels Swimberghe Date: Wed, 28 Oct 2026 16:48:36 -0400 Subject: [PATCH 15/20] docs: keep what a user needs in the README, and fold the limitations The section on `forge normalize` had become a log of how it was built: a table of twelve gaps with the test that pins each, the cost against a scanner that is no longer in the tree, and a read that changes nothing. It now has the commands first, what is rewritten, and the step `forge` does in memory, and the known limitations are one list that is folded away, of what a user can run into: a key written twice, numbers, formatting, error positions, what is not read, and memory. The tests that pin the limitations, and the cost, are in the code and the pull request, where they belong. --- packages/forge/README.md | 118 ++++++++++++------------- rfcs/0001-metadata-resolution-layer.md | 2 +- src/normalize/mod.rs | 6 +- tests/normalize/known_gaps.rs | 2 +- 4 files changed, 60 insertions(+), 68 deletions(-) diff --git a/packages/forge/README.md b/packages/forge/README.md index 661d9a3d3..f5499fb14 100644 --- a/packages/forge/README.md +++ b/packages/forge/README.md @@ -18,76 +18,18 @@ Pass `-` instead of a path to read the document from standard input. Run ## Normalizing a document -Forge's own metadata is the `x-forge-*` extensions, and they are the only -ones it reads. Documents that were written for other tools carry those tools' -extensions instead, and `forge normalize` rewrites them into the native ones. -Run it before forge reads the document, with `--leave-old-extensions` if -something else still reads the old keys: +Forge's own metadata is the `x-forge-*` extensions, and they are the only ones +it reads. Documents written for other tools carry those tools' extensions +instead, and `forge normalize` rewrites them into the native ones. Run it +before forge reads the document: ```sh -# Print a document with its keys rewritten. Nothing is changed. +# Print the document with its keys rewritten. Nothing is changed. npx forge normalize openapi.json # Rewrite the files in place. npx forge normalize --write specs/*.json -``` - -For now, `forge` also does this in memory when it reads a document, so one that -has not been through `forge normalize` builds as it always did. It keeps the -old keys, as `--leave-old-extensions` does, so plugins, and what is built on -them, read them as they read any other extension that forge does not read. -That is a change from before `forge` read native keys only, when the keys it -read were left out of a method's extensions: plugins are given four more. That -is temporary: it stops in the release that makes `forge normalize` a step to run -first, and the extensions of other tools are then passed on to plugins like any -other. - -It takes paths, which the shell can expand (`specs/*.json`). Without `--write` it -changes nothing: it prints one document, rewritten, to standard output. With -`--write` it rewrites each file in place, and only if there is something to -rewrite in it, and it prints a line for each file to standard error, and `-v` -adds how many times each key was rewritten. Several files need `--write`, or -`--check`. 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. - -The keys change, and a file that has one is written again, with the order of -its keys and the indentation of its first indented line kept: see "Known gaps" -below for what else is not as it was. A file with nothing to rewrite is never -written again. A group path 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 skip for only some targets, which another tool writes -as a list of them, has no native form, since `x-forge-skip` is for every target: -it is left exactly as it is written, whichever mode is used, while a skip that -is plainly true or false is rewritten. 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. - -### Known gaps - -The file is read as JSON and written again, which is simple, and is not a minimal -edit. These are what that gets wrong, most serious first. Each is pinned by a -test in `tests/normalize/known_gaps.rs`, which passes now and fails when the gap -is fixed. - -| Gap | What happens | -| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| A key written twice | The first copy is dropped, with no error and no warning. `forge` itself rejects a key it reads that is written twice, and no longer does in a document that has a key to rewrite. | -| Numbers | One that does not fit in 64 bits is rounded: `100000000000000000001` becomes `1e+20`, and a decimal of more than about 17 digits is shortened. `1E5` is written `100000.0`, and `0.10` is written `0.1`, and `-0` is written `-0.0`, which is a float and not an integer. Keeping the text of every number needs `serde_json`'s `arbitrary_precision`, which is not on: the engine reads a number from it as a map, so `maxItems: 2.0` is an error, and the engine has to read that first. | -| Deep documents | One nested deeper than 128 levels is an error, wherever it is nested. | -| Line endings | `\r\n` is written as `\n`, so every line of the file is in the diff. | -| Layout | An array or an object that is on one line is written on several (3.6% of the lines of a formatted document changed in a test), and the spaces of a document on one line are dropped. | -| Whitespace | The indentation of the first indented line is used for the whole file, so mixed indentation becomes one. Blank lines between members, spaces at the end of a line, and anything before the first `{` are dropped, and any newlines at the end become one. | -| Error positions | An error that `forge` finds in a document that has a key to rewrite is at a line and a column of the document as it is written again, which can be past the end of the file. `forge` says so, and `forge normalize --leave-old-extensions` prints that document. | -| Escapes | `\u00e9`, `\/` and `\u003c` are written as the characters. | -| Escaped keys | A key written with an escape is rewritten by `forge normalize`, and not by the step `forge` does before it reads a document, which looks for the text of a key. | -| Reads that change nothing | The step `forge` does before it reads a document looks for the text of a key of another tool, not for a key the tables read: a document with a key that begins as one of another tool's does, and is not one the tables read, is read for nothing, and is not changed. | -| Comments | JSON with comments is an error, and so is a byte order mark. | -| Cost | `forge normalize` takes about 5 times as long, and 6 times the memory, of an edit of bytes: 100 ms and 185 MB for a document of 28 MB, against 20 ms and 32 MB. The step in `forge` adds 90 ms and 130 MB to a run of 240 ms and 150 MB. | -```sh # Keep the old keys beside the native ones, for what still reads them. npx forge normalize --write --leave-old-extensions openapi.json @@ -98,6 +40,56 @@ npx forge normalize --check specs/*.json cat openapi.json | npx forge normalize - | npx forge - --target ts --out sdk ``` +Without `--write` it changes nothing and prints one document to standard +output, so several files need `--write` or `--check`. A file with nothing to +rewrite is left as it is, a link is followed and stays a link, and a read-only +file is left as it is, with an error. `-v` says how many times each key was +rewritten. Documents have to be JSON: YAML is not supported yet. + +What is rewritten: + +- A group path written as an array becomes the names joined by dots + (`["a", "b"]` becomes `"a.b"`), unless a name has a dot in it. +- A skip for only some targets, written as a list, has no native form, since + `x-forge-skip` is for every target: it is left as it is. A skip that is plainly + true or false is rewritten. +- 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. + +For now, `forge` also does this in memory when it reads a document, so one that +has not been through `forge normalize` still builds. It keeps the old keys, as +`--leave-old-extensions` does, so plugins read them as ordinary extensions. That +stops in the release that makes `forge normalize` a step to run first. + +
+Known limitations + +`forge normalize` reads a document as JSON and writes it again, so a file that +has a key to rewrite is not a minimal edit. The order of its keys is kept. + +- **A key written twice** keeps only its last copy, with no error or warning. + `forge` itself rejects such a key, and no longer does in a document that has a + key to rewrite. +- **Numbers** that do not fit in 64 bits are rounded (`100000000000000000001` + becomes `1e+20`), a decimal of more than about 17 digits is shortened, and + `1E5`, `0.10` and `-0` are written `100000.0`, `0.1` and `-0.0`. +- **Formatting** is the writer's: the indentation of the first indented line is + used for the whole file, arrays and objects on one line are written on + several, blank lines, spaces at the end of a line and anything before the + first `{` are dropped, `\r\n` becomes `\n`, and escapes such as `\u00e9` and + `\/` are written as the characters. +- **Error positions**: an error `forge` finds later in a document that has a key + to rewrite is at a line and column of the rewritten document, which can be past + the end of the file. `forge` says so, and `forge normalize +--leave-old-extensions` prints that document. +- **Errors**: JSON with comments, a byte order mark, and a document nested + deeper than 128 levels are not read. A key written with an escape is rewritten + by `forge normalize`, and not by the step `forge` does in memory. +- **Memory**: a document is held in memory at about 6.5 times its size, one file + at a time. + +
+ ## Plugins A plugin generates an SDK, or anything else, from the document: forge parses diff --git a/rfcs/0001-metadata-resolution-layer.md b/rfcs/0001-metadata-resolution-layer.md index f2ecc54d2..1cc3b3bb6 100644 --- a/rfcs/0001-metadata-resolution-layer.md +++ b/rfcs/0001-metadata-resolution-layer.md @@ -40,7 +40,7 @@ Not in scope: Fern's structural behavior (allOf/anyOf, inline request types), op - It changes nothing unless it is told to: one document is printed, rewritten, to standard output, and `--write` rewrites each file in place, only if something in it changes. `-` reads standard input and prints. `--check` writes nothing and fails if anything would change. - A key is rewritten only at the kind of object `forge` reads its native key at: an operation or an alias, a parameter, or a schema. -- The document is read and written again, with its key order and the indentation of its first indented line kept. That is simple, and it is not a minimal edit: what it changes beyond the keys is listed under "Known gaps" in the README of the package. +- The document is read and written again, with its key order and the indentation of its first indented line kept. That is simple, and it is not a minimal edit: what it changes beyond the keys is listed under "Known limitations" in the README of the package. - A native key is never replaced. When a document says the same thing twice, the native key is kept, and the other is dropped, or 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. - A skip on a property is rewritten to `x-forge-skip` with the same value: `x-stainless-skip: true` becomes `x-forge-skip: true`. `x-forge-skip` is for every target, so a skip for only some targets, written as a list (`x-stainless-skip: ["terraform"]`), has no native form and is left as it is written, in both modes. diff --git a/src/normalize/mod.rs b/src/normalize/mod.rs index 2e52a4dc0..6155e963c 100644 --- a/src/normalize/mod.rs +++ b/src/normalize/mod.rs @@ -16,8 +16,8 @@ //! The document is read with `serde_json` and written again, in the order its //! keys were in, with the indentation of its first indented line. That is //! simple, and it is not a minimal edit: what is lost is listed in the README -//! of the package under "Known gaps", and each one has a test that is ignored -//! with the reason, to be run again when it is fixed. +//! of the package under "Known limitations", and each one is pinned by a test in +//! `tests/normalize/known_gaps.rs`. mod vendor; mod walk; @@ -111,7 +111,7 @@ pub fn mentions_old_keys(json: &[u8]) -> bool { /// /// A document that has none of the text of a key to rewrite is not read, and is /// as it was. One that has is read and written again, which is not what -/// `forge` did before: see "Known gaps" in the README of the package. A key +/// `forge` did before: see "Known limitations" in the README of the package. A key /// written twice in it, say, is no longer an error here, as the second is the /// one that is read. /// diff --git a/tests/normalize/known_gaps.rs b/tests/normalize/known_gaps.rs index 8c0bc1e22..75ea2e2f7 100644 --- a/tests/normalize/known_gaps.rs +++ b/tests/normalize/known_gaps.rs @@ -3,7 +3,7 @@ //! //! Each test says what a document becomes, which is not what it should be, and //! passes. When one of them fails, a gap was fixed: move it out of here and -//! out of "Known gaps" in the README of the package. +//! out of "Known limitations" in the README of the package, if it is in it. use forge::normalize::{Options, before_reading, mentions_old_keys, normalize}; From b84345b0abdc43fb395c6622bc1532bf022b56cb Mon Sep 17 00:00:00 2001 From: Niels Swimberghe Date: Thu, 29 Oct 2026 16:20:20 -0400 Subject: [PATCH 16/20] refactor: remove conflict reporting and the input pre-checks Review of the pull request: - `forge` reads the native keys and no others, so what an old key says beside a native one does not matter: the native key wins, and the other is dropped, or kept with `--leave-old-extensions`, without a word. `Conflict`, `Report::conflicts`, the warning that said it, the count of warnings in the summary and the comparison of group paths that decided it are removed. - The JSON Pointer that said where a conflict was was the only use of the path that the walk kept, so the walk no longer keeps one. - `normalize` checked a document for a byte order mark, UTF-16, being empty and not starting with `{` before it read it, and named what was wrong in its own words. `serde_json` refuses all of them, and says where, so it is the only one that checks. A document that is JSON and not an object, an array, is not refused: there is nothing in it to rewrite. - The tests that were only of those are removed with them: the conflicts, the pointer, the dotted group that is not a dotted string, the long document that was checked on another thread, which does not exist, and the count of warnings. --- packages/forge/README.md | 2 +- rfcs/0001-metadata-resolution-layer.md | 2 +- src/bin/forge/main.rs | 7 +- src/bin/forge/normalize.rs | 25 +----- src/normalize/mod.rs | 51 +---------- src/normalize/walk.rs | 61 +------------- tests/normalize/known_gaps.rs | 5 +- tests/normalize/normalize.rs | 112 +------------------------ tests/normalize/normalize_cli.rs | 38 +-------- 9 files changed, 24 insertions(+), 279 deletions(-) diff --git a/packages/forge/README.md b/packages/forge/README.md index f5499fb14..18c305ca3 100644 --- a/packages/forge/README.md +++ b/packages/forge/README.md @@ -54,7 +54,7 @@ What is rewritten: `x-forge-skip` is for every target: it is left as it is. A skip that is plainly true or false is rewritten. - 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. + key is kept. For now, `forge` also does this in memory when it reads a document, so one that has not been through `forge normalize` still builds. It keeps the old keys, as diff --git a/rfcs/0001-metadata-resolution-layer.md b/rfcs/0001-metadata-resolution-layer.md index 1cc3b3bb6..2f3d6250e 100644 --- a/rfcs/0001-metadata-resolution-layer.md +++ b/rfcs/0001-metadata-resolution-layer.md @@ -41,7 +41,7 @@ Not in scope: Fern's structural behavior (allOf/anyOf, inline request types), op - It changes nothing unless it is told to: one document is printed, rewritten, to standard output, and `--write` rewrites each file in place, only if something in it changes. `-` reads standard input and prints. `--check` writes nothing and fails if anything would change. - A key is rewritten only at the kind of object `forge` reads its native key at: an operation or an alias, a parameter, or a schema. - The document is read and written again, with its key order and the indentation of its first indented line kept. That is simple, and it is not a minimal edit: what it changes beyond the keys is listed under "Known limitations" in the README of the package. -- A native key is never replaced. When a document says the same thing twice, the native key is kept, and the other is dropped, or reported if it says something else. +- A native key is never replaced. When a document says the same thing twice, the native key is kept, and the other is dropped. - A group path that is an array becomes the names joined by dots, unless a name has a dot in it. - A skip on a property is rewritten to `x-forge-skip` with the same value: `x-stainless-skip: true` becomes `x-forge-skip: true`. `x-forge-skip` is for every target, so a skip for only some targets, written as a list (`x-stainless-skip: ["terraform"]`), has no native form and is left as it is written, in both modes. - `--leave-old-extensions` keeps each key that is rewritten, beside the native one, for what still reads it. diff --git a/src/bin/forge/main.rs b/src/bin/forge/main.rs index 2b59e689a..617d0e82d 100644 --- a/src/bin/forge/main.rs +++ b/src/bin/forge/main.rs @@ -47,10 +47,9 @@ enum Command { /// /// One document is printed to standard output, and nothing is changed, /// unless --write is given, which rewrites the files 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. + /// 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. Normalize(normalize::Args), } diff --git a/src/bin/forge/normalize.rs b/src/bin/forge/normalize.rs index 8619df8d3..fb1a0c590 100644 --- a/src/bin/forge/normalize.rs +++ b/src/bin/forge/normalize.rs @@ -91,7 +91,7 @@ pub fn run(args: Args) -> Result<(), Err> { }) .collect(); - let (mut rewritten, mut unchanged, mut failed, mut warnings) = (0, 0, 0, 0); + let (mut rewritten, mut unchanged, mut failed) = (0, 0, 0); for (path, result) in results { match result { Ok(report) => { @@ -100,7 +100,6 @@ pub fn run(args: Args) -> Result<(), Err> { } else { unchanged += 1; } - warnings += report.conflicts.len(); print(&path.display().to_string(), &report, say); } Err(err) => { @@ -110,28 +109,24 @@ pub fn run(args: Args) -> Result<(), 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}", + "{rewritten} of {total} {} would be rewritten", plural(total, "file", "files") ) .into()); } eprintln!( - "forge: {total} {} checked, none would be rewritten{warned}", + "forge: {total} {} checked, none would be rewritten", plural(total, "file", "files") ); } else { eprintln!( - "forge: {total} {}: {rewritten} rewritten, {unchanged} unchanged{warned}", + "forge: {total} {}: {rewritten} rewritten, {unchanged} unchanged", plural(total, "file", "files") ); } @@ -316,17 +311,6 @@ fn create_private(path: &Path) -> io::Result { options.open(path) } -/// Says what in the document called `name` could not be rewritten, or was -/// dropped for a native key that says something else. -fn warn(name: &str, report: &Report) { - for conflict in &report.conflicts { - eprintln!( - "{name}: warning: {}: {} says something other than {}, which is kept", - conflict.location, conflict.key, conflict.native - ); - } -} - /// Says what was done to the document called `name`, and what was not. fn print(name: &str, report: &Report, say: Say) { if report.changed() { @@ -352,5 +336,4 @@ fn print(name: &str, report: &Report, say: Say) { } } } - warn(name, report); } diff --git a/src/normalize/mod.rs b/src/normalize/mod.rs index 6155e963c..db8f13bfa 100644 --- a/src/normalize/mod.rs +++ b/src/normalize/mod.rs @@ -10,8 +10,7 @@ //! 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. +//! native key and another, the native one is kept, as `forge` would read it. //! //! The document is read with `serde_json` and written again, in the order its //! keys were in, with the indentation of its first indented line. That is @@ -43,8 +42,6 @@ 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, @@ -57,18 +54,6 @@ impl Report { } } -/// 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 { @@ -139,39 +124,7 @@ pub fn before_reading(json: Vec) -> Result<(Vec, bool), Error> { /// Rewrites the keys of the document `json` that a native key replaces. pub fn normalize(json: &[u8], options: &Options) -> Result { - 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 - // reported as the error of a parser that expected something else. - 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 mut document: Value = match serde_json::from_slice(json) { - Ok(document) => document, - Err(err) => { - return Err(match std::str::from_utf8(json) { - Err(utf8) => Error(format!( - "the document is not UTF-8: it is not from byte {}", - utf8.valid_up_to() - )), - Ok(_) => err.into(), - }); - } - }; + let mut document: Value = serde_json::from_slice(json)?; let report = walk::walk(&mut document, options); if !report.changed() { return Ok(Normalized { json: None, report }); diff --git a/src/normalize/walk.rs b/src/normalize/walk.rs index f2da5a050..58a9d095a 100644 --- a/src/normalize/walk.rs +++ b/src/normalize/walk.rs @@ -7,12 +7,10 @@ //! and what the key is, in [`child`], which is the one place that says where //! `forge` reads. -use std::fmt::Write; - use serde_json::{Map, Value}; use super::vendor::{self, Key}; -use super::{Conflict, Options, Report}; +use super::{Options, Report}; use crate::metadata::{self, DefinitionField, ParameterField, SchemaField}; /// A kind of object with extensions. @@ -30,11 +28,6 @@ trait Level { 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; @@ -61,13 +54,6 @@ impl Level for Definition { }; dotted(names).map(Value::String) } - - 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 @@ -84,17 +70,6 @@ fn dotted(names: &[Value]) -> Option { 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; @@ -197,7 +172,6 @@ pub(super) fn walk(document: &mut Value, options: &Options) -> Report { let mut walk = Walk { options, report: Report::default(), - pointer: String::new(), }; walk.visit(Kind::Document, document); walk.report @@ -206,9 +180,6 @@ pub(super) fn walk(document: &mut Value, options: &Options) -> Report { struct Walk<'o> { options: &'o Options, report: Report, - /// Where the object being read is, as a JSON Pointer, which is only - /// wanted for a conflict. - pointer: String, } impl Walk<'_> { @@ -228,43 +199,24 @@ impl Walk<'_> { let Some(shape) = child(kind, key, member) else { continue; }; - let at = self.push_key(key); match shape { Shape::One(kind) => self.visit(kind, member), Shape::Values(kind) => { if let Some(members) = member.as_object_mut() { - for (key, member) in members.iter_mut() { - let at = self.push_key(key); + for member in members.values_mut() { self.visit(kind, member); - self.pointer.truncate(at); } } } Shape::Elements(kind) => { if let Some(elements) = member.as_array_mut() { - for (index, element) in elements.iter_mut().enumerate() { - let at = self.pointer.len(); - let _ = write!(self.pointer, "/{index}"); + for element in elements.iter_mut() { self.visit(kind, element); - self.pointer.truncate(at); } } } } - self.pointer.truncate(at); - } - } - - /// Adds the key to the pointer, and returns how long it was before. - fn push_key(&mut self, key: &str) -> usize { - let at = self.pointer.len(); - self.pointer.push('/'); - if key.contains(['~', '/']) { - self.pointer.push_str(&key.replace('~', "~0").replace('/', "~1")); - } else { - self.pointer.push_str(key); } - at } /// Decides what to do to the keys of the object, which is of the kind `L`, @@ -287,13 +239,6 @@ impl Walk<'_> { } // The native key that is beside is the one that decides. let beside = members.get(native); - if beside.is_some_and(|beside| !L::same(hit.field, value, beside)) { - self.report.conflicts.push(Conflict { - location: self.pointer.clone(), - key: key.clone(), - native, - }); - } let writes = beside.is_none(); let written = L::written(hit.field, value).filter(|_| writes); diff --git a/tests/normalize/known_gaps.rs b/tests/normalize/known_gaps.rs index 75ea2e2f7..a403ac516 100644 --- a/tests/normalize/known_gaps.rs +++ b/tests/normalize/known_gaps.rs @@ -123,13 +123,12 @@ fn a_key_written_with_an_escape_is_not_found_by_the_step_before_forge_reads() { assert!(mentions_old_keys(br#"{"x-fern-ignore": true}"#)); } -/// GAP: JSON with comments, which some call JSONC, is not JSON, and is an -/// error that says only what is at the start of the document. +/// GAP: JSON with comments, which some call JSONC, is not JSON, and is an error. #[test] fn json_with_comments_is_an_error() { let err = normalize(b"// api\n{\"openapi\": \"3.0.3\"}", &Options::default()).unwrap_err(); - assert!(err.to_string().contains("does not start with `{`"), "{err}"); + assert!(!err.to_string().is_empty()); } /// GAP: a document nested deeper than `serde_json` allows, 128 levels, is an diff --git a/tests/normalize/normalize.rs b/tests/normalize/normalize.rs index a9ba20bc7..36999d9ba 100644 --- a/tests/normalize/normalize.rs +++ b/tests/normalize/normalize.rs @@ -93,7 +93,6 @@ fn every_key_is_rewritten_as_its_native_key() { } assert_eq!(report.migrated.values().sum::(), 11); assert_eq!(report.migrated["x-fern-sdk-method-name"], 2); - assert!(report.conflicts.is_empty()); } #[test] @@ -146,30 +145,11 @@ 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 (normalized, _) = 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] @@ -231,14 +211,13 @@ fn a_list_of_targets_to_skip_is_left_as_it_is_in_both_modes() { } #[test] -fn a_list_of_targets_is_left_beside_a_native_skip_and_is_not_a_conflict() { +fn a_list_of_targets_is_left_beside_a_native_skip() { 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()); } } @@ -308,18 +287,6 @@ fn schemas_are_found_wherever_forge_reads_them() { 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"); -} - /// Returns a document with one schema in `components.schemas` that has the /// property `p` written as `property`. fn with_property(property: &str) -> String { @@ -549,21 +516,6 @@ fn an_alias_with_a_dot_in_a_group_name_stays_an_array() { 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","#); @@ -576,31 +528,6 @@ fn leaving_the_old_key_writes_the_native_one_as_a_string_and_keeps_the_array() { assert_json_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); -} - /// `forge` reads no native key, nor any other key it knows, that is written /// twice in an object it reads. A key of another tool is an ordinary extension /// to it, which it takes as the last value written. @@ -744,30 +671,6 @@ fn a_document_that_is_not_json_is_an_error_where_there_is_nothing_to_rewrite_in_ 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()) @@ -784,9 +687,7 @@ fn a_document_that_is_not_utf8_is_an_error_when_there_is_something_to_rewrite_in 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}"); + assert!(normalize(&document, &Options::default()).is_err()); } /// Needs the released aggregate: `FORGE_SPEC=openapi.forge.json cargo test @@ -811,15 +712,10 @@ fn the_aggregate_builds_the_same_forge_in_both_modes_and_only_when_normalized() )); let report = normalized.report; - eprintln!( - "{options:?}: {} keys, {} conflicts", - report.migrated.values().sum::(), - report.conflicts.len() - ); + eprintln!("{options:?}: {} keys", report.migrated.values().sum::()); for (key, count) in &report.migrated { eprintln!(" {key}: {count}"); } - assert!(report.conflicts.is_empty(), "{options:?}: the document has conflicts"); } // The keys that are left are extensions to `forge`, which plugins see. diff --git a/tests/normalize/normalize_cli.rs b/tests/normalize/normalize_cli.rs index 3df77a45d..c2f2624e7 100644 --- a/tests/normalize/normalize_cli.rs +++ b/tests/normalize/normalize_cli.rs @@ -177,7 +177,7 @@ fn a_file_that_is_not_json_is_an_error_and_the_others_are_still_rewritten() { } #[test] -fn a_native_key_wins_and_the_one_that_differs_is_reported() { +fn a_native_key_wins_and_the_other_is_dropped() { let dir = dir("conflict"); let file = dir.join("spec.json"); fs::write( @@ -189,12 +189,6 @@ fn a_native_key_wins_and_the_one_that_differs_is_reported() { let out = rewrite(&[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}"); @@ -296,11 +290,10 @@ fn a_yaml_file_is_an_error_and_is_not_reported_as_native() { } #[test] -fn a_file_that_is_not_an_object_is_an_error_whatever_it_is_named() { +fn a_file_that_is_not_json_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"), ] { @@ -550,7 +543,7 @@ fn a_read_only_file_is_not_rewritten() { } #[test] -fn a_byte_order_mark_is_named_and_the_file_is_left_as_it_is() { +fn a_byte_order_mark_is_an_error_and_the_file_is_left_as_it_is() { let dir = dir("bom"); let file = dir.join("spec.json"); let content = format!("\u{feff}{FERN}"); @@ -559,33 +552,10 @@ fn a_byte_order_mark_is_named_and_the_file_is_left_as_it_is() { let out = rewrite(&[file.to_str().unwrap()], ""); let err = failed(&out); - assert!( - err.contains("spec.json: the document starts with a byte order mark"), - "{err}" - ); + assert!(err.contains("spec.json: expected value at line 1 column 1"), "{err}"); assert_eq!(fs::read_to_string(&file).unwrap(), content); } -#[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 = rewrite(&[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); From 089ad07da9e6a61b817b81ce15d5a6c110708596 Mon Sep 17 00:00:00 2001 From: Niels Swimberghe Date: Thu, 29 Oct 2026 16:48:05 -0400 Subject: [PATCH 17/20] refactor: the engine is main's reader with the keys renamed This pull request grew out of three that had other aims, and it still carried what they needed: a table of native keys in `src/metadata/` with a slot for each, so that a key written under two spellings could be told apart, a bit set of the keys read from an object, an `entry!` macro to read an extension and a key apart, and a field type for each kind of object that `normalize` and the reader had to agree on. All of it was for ranking a native key against another tool's, which `forge` no longer does: it reads native keys and no others. The reviewer asked why this was not a simple rename, and there was no reason for it not to be. It is now one. `src/link.rs` and `src/link/openapi.rs` are what `main` has, with the keys renamed: the literal of each key in the `keys!` block of its kind of object, and nothing else but `x-forge-skip` being a boolean for a property, as it was for an operation, where `main` took a list of targets and ignored it. `src/metadata/` and `src/link/extension.rs` are gone, and about 680 lines with them. `normalize` follows. A table of another tool's keys says which native key each becomes, and how its value is written, as data, and the walk asks it through a function and not through a trait with a type for each kind of object. A test fails if a table names a key that `forge` does not read at that kind of object, which is what the shared table was for: a document that writes the native key twice has to be refused as one that writes a key it reads twice is. Also: the RFC is named for what it says, and its architecture, the README of the vendor tables and AGENTS.md no longer describe a module that is not there. The SDK from the 28 MB aggregate is byte for byte what the original engine builds, in 3,243 files; `--target ts` gives the same files as `main` for it and for the cf spec; the cf CLI generator gives the same 7,225 files. The whole run takes as long as before. --- AGENTS.md | 2 +- ...ution-layer.md => 0001-native-metadata.md} | 10 +- src/lib.rs | 1 - src/link.rs | 38 +- src/link/extension.rs | 50 --- src/link/openapi.rs | 243 ++++------- src/metadata/mod.rs | 395 ------------------ src/normalize/vendor/README.md | 22 +- src/normalize/vendor/fern.rs | 86 +--- src/normalize/vendor/mod.rs | 101 +++-- src/normalize/vendor/stainless.rs | 26 +- src/normalize/vendor/tests.rs | 226 +++++----- src/normalize/vendor/unprefixed.rs | 21 +- src/normalize/walk.rs | 100 +---- 14 files changed, 320 insertions(+), 1001 deletions(-) rename rfcs/{0001-metadata-resolution-layer.md => 0001-native-metadata.md} (94%) delete mode 100644 src/link/extension.rs delete mode 100644 src/metadata/mod.rs diff --git a/AGENTS.md b/AGENTS.md index 8f0388f61..02319754a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,7 @@ 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 rewritten into them by `forge normalize` (`src/normalize/`). +- **`src/`**: The Rust engine: OpenAPI to linked model to plugin host. It reads only native `x-forge-*` extensions (in `src/link/`); other tools' extensions are rewritten into them by `forge normalize` (`src/normalize/`). ## Vendor extensions diff --git a/rfcs/0001-metadata-resolution-layer.md b/rfcs/0001-native-metadata.md similarity index 94% rename from rfcs/0001-metadata-resolution-layer.md rename to rfcs/0001-native-metadata.md index 2f3d6250e..ce5927a84 100644 --- a/rfcs/0001-metadata-resolution-layer.md +++ b/rfcs/0001-native-metadata.md @@ -50,13 +50,11 @@ Not in scope: Fern's structural behavior (allOf/anyOf, inline request types), op ``` src/ -├─ metadata/ the native table, per kind of object: definition, -│ parameter, schema, document. Pure: `&str` in, small -│ values out. -├─ link/ reads native keys through `metadata`. +├─ link/ reads the native keys, and no others. └─ normalize/ - ├─ vendor/ the tables of other tools' keys. The only code holding - │ them, sectioned off, and per level. + ├─ vendor/ the tables of other tools' keys, per kind of object, each + │ with the native key it becomes. The only code holding + │ them, sectioned off. └─ walk.rs where a key is to be rewritten, and to what. ``` diff --git a/src/lib.rs b/src/lib.rs index b62729a1a..f4ed2b159 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,7 +5,6 @@ use std::{iter, ptr}; mod arena; mod json; mod link; -mod metadata; mod method; mod name; pub mod normalize; diff --git a/src/link.rs b/src/link.rs index 685237a84..9760ff814 100644 --- a/src/link.rs +++ b/src/link.rs @@ -6,7 +6,6 @@ //! `$ref`s, builds the types of each method's parameters, body and //! responses, and makes the schemas they use models as they are reached. -mod extension; mod openapi; mod read; @@ -21,14 +20,13 @@ 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::{ - EnumValue, Forge, GroupData, GroupId, HttpMethod, Location, MethodData, MethodId, ModelData, ModelId, Name, Number, - Scalar, + Availability, 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, @@ -545,8 +543,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 being - // ignored does not stop its aliases. + // The operation's own definition makes no method, so its + // `x-forge-skip` 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()); @@ -578,29 +576,22 @@ impl<'d> Builder<'d> { 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(format_args!( - "{} has a group name with no letters or digits", - metadata::definition::GROUP_NAME - )); + self.error("x-forge-group-name has a group name with no letters or digits"); return; } } let name = Name::new(doc.str(definition.method_name)); if name.is_empty() { - self.error(format_args!( - "{} has no letters or digits", - metadata::definition::METHOD_NAME - )); + self.error("x-forge-method-name has no letters or digits"); return; } let mut availability = None; if let Some(value) = definition.availability { let value = doc.str(value); - availability = metadata::availability(value); + availability = parse_availability(value); if availability.is_none() { self.error(format_args!( - "{} {value:?} is not alpha, beta, preview, generally-available or deprecated", - metadata::definition::AVAILABILITY + "x-forge-availability {value:?} is not alpha, beta, preview, generally-available or deprecated" )); } } @@ -1276,6 +1267,19 @@ fn fixed_method(method: &str) -> Option { } } +/// Returns the availability `value` is as `x-forge-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 diff --git a/src/link/extension.rs b/src/link/extension.rs deleted file mode 100644 index 9e5b5afd5..000000000 --- a/src/link/extension.rs +++ /dev/null @@ -1,50 +0,0 @@ -//! The native extension keys of the objects in [`openapi`](super::openapi): -//! how to name one from its slot, and how to find one written twice. -//! -//! What a key says is [`metadata`]'s to say. 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}; - -/// 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::definition::NAMES[usize::from(slot)] - } -} - -impl Kind for ParameterField { - fn name(slot: u8) -> &'static str { - metadata::parameter::NAMES[usize::from(slot)] - } -} - -impl Kind for SchemaField { - fn name(slot: u8) -> &'static str { - metadata::schema::NAMES[usize::from(slot)] - } -} - -impl Kind for DocumentField { - fn name(slot: u8) -> &'static str { - metadata::document::NAMES[usize::from(slot)] - } -} - -/// 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. -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(F::name(hit.slot))); - } - *seen |= bit; - Ok(()) -} diff --git a/src/link/openapi.rs b/src/link/openapi.rs index d8d2ab310..7b8af55de 100644 --- a/src/link/openapi.rs +++ b/src/link/openapi.rs @@ -23,12 +23,10 @@ use std::marker::PhantomData; use serde::Deserialize; use serde::de::{self, DeserializeSeed, Deserializer, IgnoredAny, MapAccess, SeqAccess, Visitor}; -use super::extension; use super::read::{first, keys}; use crate::Name; use crate::arena::{Id, List, Span, Str, Strings}; use crate::json::{self, Json}; -use crate::metadata::{self, DefinitionField, DocumentField, Hit, ParameterField, SchemaField}; /// An OpenAPI document: the fields of the OpenAPI Object at its root, and /// the arenas of the objects in it. @@ -242,19 +240,21 @@ pub(super) struct Operation { /// `x-forge-aliases`, a method. #[derive(Default)] pub(super) struct Definition { - /// The name of the method, or empty for none. + /// The `x-forge-method-name`, or empty for none. pub(super) method_name: Str, - /// The group path, top level first, or empty for none. + /// The `x-forge-group-name`: the group path, top level first, or + /// empty for none. pub(super) group_names: Span, - /// Whether the definition makes no method. + /// `x-forge-skip`. pub(super) skip: bool, - /// Whether the method is left out of listings. + /// `x-forge-hidden`. pub(super) hidden: bool, - /// The sentence to confirm with, or `None` for none. An empty one is - /// `Some`. + /// The `x-forge-require-confirmation`, or `None` for none. An empty + /// one is `Some`. pub(super) confirmation: 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. + /// The `x-forge-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, /// Each other extension, which is not read, in `Doc::extensions`, in /// the order they are written. One written more than once has the last @@ -280,7 +280,7 @@ 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 name of the parameter's field, or empty for none. + /// The `x-forge-parameter-name`, or empty for none. pub(super) field_name: Str, } @@ -396,15 +396,16 @@ 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-forge-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. + /// The `x-forge-model-name`: the name of the model, for a schema in + /// `components.schemas`, or empty for none. pub(super) model_name: Str, - /// The name of the field, for a property's schema, or empty for none. + /// The `x-forge-property-name`: 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 to be left out: it makes no + /// Whether a property's schema is marked `x-forge-skip: true`: it makes no /// field. pub(super) skip: bool, } @@ -850,8 +851,8 @@ impl Read for Types { } } -/// Reads a group path: a string split on dots, or an array of strings that -/// are not split. The empty string is no group. +/// Reads an `x-forge-group-name`: a string split on dots, or an array of +/// strings that are not split. The empty string is no group. struct GroupNames; impl Read for GroupNames { @@ -1035,49 +1036,6 @@ 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. 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 if v.starts_with("x-") => 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", @@ -1085,11 +1043,10 @@ keys! { Servers = "servers", Paths = "paths", Components = "components", + Commands = "x-forge-commands", } } -entry!(DocumentEntry, DocumentKey, DocumentField, metadata::document::read); - struct DocumentVisitor<'a>(&'a mut Doc); impl<'de> Visitor<'de> for DocumentVisitor<'_> { @@ -1102,18 +1059,7 @@ impl<'de> Visitor<'de> for DocumentVisitor<'_> { fn visit_map>(self, mut map: A) -> Result<(), A::Error> { let doc = self.0; let mut seen = 0; - let mut seen_extensions = 0; - while let Some(entry) = map.next_key::()? { - let key = match entry { - DocumentEntry::Extension(hit) => { - extension::once(&hit, &mut seen_extensions)?; - match hit.field { - DocumentField::Commands => doc.commands = map.next_value()?, - } - continue; - } - DocumentEntry::Key(key) => key, - }; + while let Some(key) = map.next_key::()? { key.once(&mut seen)?; match key { DocumentKey::Openapi => doc.openapi = map.next_value()?, @@ -1125,6 +1071,7 @@ 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)?, } } @@ -1235,26 +1182,38 @@ impl ReadObject for PathItem { } } +keys! { + /// The keys of a [`Definition`], in an Operation Object or an alias. + DefinitionKey { + MethodName = "x-forge-method-name", + GroupNames = "x-forge-group-name", + Skip = "x-forge-skip", + Hidden = "x-forge-hidden", + Confirmation = "x-forge-require-confirmation", + Availability = "x-forge-availability", + } +} + impl Definition { - /// Reads the value of the key that says `field`, the key of the entry `map` - /// is reading. + /// Reads the value of `key`, the key of the entry `map` is reading. fn read_value<'de, A: MapAccess<'de>>( &mut self, doc: &mut Doc, map: &mut A, - field: DefinitionField, + key: DefinitionKey, ) -> Result<(), A::Error> { - match field { - DefinitionField::Method => self.method_name = map.next_value_seed(seed::(doc))?, - DefinitionField::Group => self.group_names = map.next_value_seed(seed::(doc))?, - DefinitionField::Skip => self.skip = map.next_value()?, - DefinitionField::Availability => { - self.availability = Some(map.next_value_seed(seed::(doc))?); - } - DefinitionField::Hidden => self.hidden = map.next_value()?, - DefinitionField::Confirmation => { + 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::Skip => self.skip = map.next_value()?, + DefinitionKey::Hidden => self.hidden = map.next_value()?, + DefinitionKey::Confirmation => { self.confirmation = Some(map.next_value_seed(seed::(doc))?); } + DefinitionKey::Availability => { + self.availability = Some(map.next_value_seed(seed::(doc))?); + } + DefinitionKey::Other => skip(map)?, } Ok(()) } @@ -1277,9 +1236,9 @@ impl ReadObject for Alias { let mut extensions = Vec::new(); while let Some(field) = map.next_key::()? { match field { - AliasField::Definition(hit) => { - extension::once(&hit, &mut seen)?; - definition.read_value(doc, &mut map, hit.field)?; + AliasField::Definition(key) => { + key.once(&mut seen)?; + definition.read_value(doc, &mut map, key)?; } AliasField::Extension(name) => json::insert(&mut extensions, name, map.next_value()?), AliasField::Other => skip(&mut map)?, @@ -1387,7 +1346,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(Hit), + Definition(DefinitionKey), Extension(String), Other, } @@ -1404,13 +1363,13 @@ impl<'de> Deserialize<'de> for AliasField { } fn visit_str(self, v: &str) -> Result { - if !v.starts_with("x-") { - return Ok(AliasField::Other); - } - Ok(match metadata::definition::read(v) { - Some(hit) => AliasField::Definition(hit), - None if metadata::is_aliases(v) => AliasField::Other, - None => AliasField::Extension(v.to_owned()), + 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), }) } } @@ -1421,6 +1380,7 @@ impl<'de> Deserialize<'de> for AliasField { keys! { OperationKey { + Aliases = "x-forge-aliases", Summary = "summary", Description = "description", Deprecated = "deprecated", @@ -1444,16 +1404,9 @@ impl ReadObject for Operation { while let Some(field) = map.next_key::()? { let key = match field { OperationField::Key(key) => key, - OperationField::Definition(hit) => { - extension::once(&hit, &mut seen_definition)?; - operation.definition.read_value(doc, &mut map, hit.field)?; - 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))?); + OperationField::Definition(key) => { + key.once(&mut seen_definition)?; + operation.definition.read_value(doc, &mut map, key)?; continue; } OperationField::Extension(name) => { @@ -1463,6 +1416,9 @@ 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))?; @@ -1485,12 +1441,11 @@ impl ReadObject for Operation { } } -/// 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`. +/// 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`. enum OperationField { - Definition(Hit), - Aliases, + Definition(DefinitionKey), Key(OperationKey), Extension(String), } @@ -1507,13 +1462,12 @@ impl<'de> Deserialize<'de> for OperationField { } fn visit_str(self, v: &str) -> Result { - // The OpenAPI keys first, as for any other object. + match DefinitionKey::from_key(v) { + DefinitionKey::Other => {} + key => return Ok(OperationField::Definition(key)), + } Ok(match OperationKey::from_key(v) { - OperationKey::Other if v.starts_with("x-") => match metadata::definition::read(v) { - Some(hit) => OperationField::Definition(hit), - None if metadata::is_aliases(v) => OperationField::Aliases, - None => OperationField::Extension(v.to_owned()), - }, + OperationKey::Other if v.starts_with("x-") => OperationField::Extension(v.to_owned()), key => OperationField::Key(key), }) } @@ -1535,11 +1489,10 @@ keys! { Explode = "explode", Schema = "schema", Content = "content", + FieldName = "x-forge-parameter-name", } } -entry!(ParameterEntry, ParameterKey, ParameterField, metadata::parameter::read); - impl ReadObject for Parameter { type Out = Id; const EXPECTING: &'static str = "a Parameter Object"; @@ -1547,20 +1500,7 @@ 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; - let mut seen_extensions = 0; - while let Some(entry) = map.next_key::()? { - let key = match entry { - ParameterEntry::Extension(hit) => { - extension::once(&hit, &mut seen_extensions)?; - match hit.field { - ParameterField::Name => { - param.field_name = map.next_value_seed(seed::(doc))?; - } - } - continue; - } - ParameterEntry::Key(key) => key, - }; + while let Some(key) = map.next_key::()? { key.once(&mut seen)?; match key { ParameterKey::Ref => param.reference = map.next_value_seed(seed::(doc))?, @@ -1577,6 +1517,7 @@ 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)?, } } @@ -1678,11 +1619,13 @@ keys! { Default = "default", Deprecated = "deprecated", ReadOnly = "readOnly", + Sensitive = "x-forge-sensitive", + ModelName = "x-forge-model-name", + FieldName = "x-forge-property-name", + Skip = "x-forge-skip", } } -entry!(SchemaEntry, SchemaKey, SchemaField, metadata::schema::read); - impl ReadObject for SchemaObject { type Out = Id; const EXPECTING: &'static str = "a Schema Object"; @@ -1690,25 +1633,7 @@ 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; - let mut seen_extensions = 0; - while let Some(entry) = map.next_key::()? { - let key = match entry { - SchemaEntry::Extension(hit) => { - extension::once(&hit, &mut seen_extensions)?; - match hit.field { - SchemaField::ModelName => { - schema.model_name = map.next_value_seed(seed::(doc))?; - } - SchemaField::PropertyName => { - schema.field_name = map.next_value_seed(seed::(doc))?; - } - SchemaField::Skip => schema.skip = map.next_value()?, - SchemaField::Sensitive => schema.sensitive = map.next_value()?, - } - continue; - } - SchemaEntry::Key(key) => key, - }; + while let Some(key) = map.next_key::()? { key.once(&mut seen)?; match key { SchemaKey::Ref => schema.reference = map.next_value_seed(seed::(doc))?, @@ -1751,6 +1676,10 @@ 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()?, SchemaKey::Other => skip(&mut map)?, } } diff --git a/src/metadata/mod.rs b/src/metadata/mod.rs deleted file mode 100644 index 0d94528be..000000000 --- a/src/metadata/mod.rs +++ /dev/null @@ -1,395 +0,0 @@ -//! 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. Reading the values, and building a -//! `Forge` from them, is the work of `link`. It reads these keys and no -//! others: the extensions other tools write are ordinary extensions to it, -//! and `forge normalize` is what rewrites them into these. - -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"; - -/// What every native key begins with. 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 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 - /// in the table of names of its kind of object. - pub(crate) slot: u8, -} - -impl Hit { - /// Returns a key, in slot `slot`, which is under 64. - const fn new(field: F, slot: u8) -> Hit { - Hit { field, slot } - } -} - -/// 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: 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. - Skip, - /// 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. - Skip, - /// 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, -} - -/// Returns whether `key` is the `x-forge-aliases` of an operation. -pub(crate) fn is_aliases(key: &str) -> bool { - key == ALIASES -} - -/// The native keys of an operation, or of an alias in its `x-forge-aliases`. -pub(crate) mod definition { - use super::{DefinitionField, Hit, PREFIX}; - - pub(crate) const METHOD_NAME: &str = "x-forge-method-name"; - pub(crate) const GROUP_NAME: &str = "x-forge-group-name"; - const SKIP: &str = "x-forge-skip"; - pub(crate) 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 the native key that says what `field` says. - 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> { - if !key.starts_with(PREFIX) { - return None; - } - Some(match key { - METHOD_NAME => Hit::new(DefinitionField::Method, 0), - GROUP_NAME => Hit::new(DefinitionField::Group, 1), - SKIP => Hit::new(DefinitionField::Skip, 2), - AVAILABILITY => Hit::new(DefinitionField::Availability, 3), - HIDDEN => Hit::new(DefinitionField::Hidden, 4), - CONFIRMATION => Hit::new(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 the native key that says what `field` says. - 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> { - if !key.starts_with(PREFIX) { - return None; - } - Some(match key { - NAME => Hit::new(ParameterField::Name, 0), - _ => return None, - }) - } -} - -/// The native keys of a Schema Object. -pub(crate) mod schema { - use super::{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 names of the native keys of a Schema Object, by slot. - pub(crate) const NAMES: [&str; 4] = [MODEL_NAME, PROPERTY_NAME, SKIP, SENSITIVE]; - - /// Returns the native key that says what `field` says. - 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> { - if !key.starts_with(PREFIX) { - return None; - } - Some(match key { - MODEL_NAME => Hit::new(SchemaField::ModelName, 0), - PROPERTY_NAME => Hit::new(SchemaField::PropertyName, 1), - SKIP => Hit::new(SchemaField::Skip, 2), - SENSITIVE => Hit::new(SchemaField::Sensitive, 3), - _ => 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::new(DocumentField::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::*; - - /// 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] { - [ - &[ - "x-forge-method-name", - "x-forge-group-name", - "x-forge-skip", - "x-forge-availability", - "x-forge-hidden", - "x-forge-require-confirmation", - ], - &["x-forge-parameter-name"], - &[ - "x-forge-model-name", - "x-forge-property-name", - "x-forge-skip", - "x-forge-sensitive", - ], - &["x-forge-commands"], - ] - } - - /// Asserts that each of `keys` is read by `read` in the slot that 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_eq!(names.get(usize::from(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() { - let [definitions, parameters, schemas, documents] = native_keys(); - 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 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, - DefinitionField::Availability, - DefinitionField::Hidden, - DefinitionField::Confirmation, - ] { - let hit = definition::read(definition::key(field)).unwrap(); - assert!(same(&hit.field) == same(&field)); - } - let hit = parameter::read(parameter::key(ParameterField::Name)).unwrap(); - assert!(matches!(hit.field, ParameterField::Name)); - for field in [ - SchemaField::ModelName, - SchemaField::PropertyName, - SchemaField::Skip, - SchemaField::Sensitive, - ] { - let hit = schema::read(schema::key(field)).unwrap(); - assert!(same(&hit.field) == same(&field)); - } - } - - #[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] - fn slots_are_unique_among_the_keys_of_an_object_and_fit_in_a_set() { - 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 < 64), "a set of keys read is a u64"); - }; - 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] - fn other_keys_are_not_native() { - for key in [ - "x-other-method-name", - "x-other-skip", - "x-custom", - "summary", - "x-forge-nope", - "x-forge-method", - // What other systems write for a native key, which is not read. - "x-sensitive", - ] { - 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::read("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/normalize/vendor/README.md b/src/normalize/vendor/README.md index 1d10725df..c5f0c4d8c 100644 --- a/src/normalize/vendor/README.md +++ b/src/normalize/vendor/README.md @@ -1,7 +1,7 @@ # The vendor tables -`forge` reads one vocabulary: the native `x-forge-*` extensions in -[`src/metadata/`](../../metadata/mod.rs). The extensions other tools write +`forge` reads one vocabulary: the native `x-forge-*` extensions, in the readers +of `src/link/`. The extensions other tools write (Fern's `x-fern-*`, Stainless's `x-stainless-*`) are ordinary extensions to it, kept for plugins like any other it does not read. This folder is the only place that knows them, and what they say: `forge normalize` uses it to @@ -14,8 +14,8 @@ rewrite them into native keys, before `forge` reads the document. their comments and docs. `tests/boundary.rs` fails, with file and line, if one appears elsewhere (`just boundary`; it also runs with `cargo test`). The tests of vendor-keyed documents live in `tests/normalize/`. -- **Mapping, not semantics.** A table says which native field a key stands - for, and whether its value is written as the native key's is, as data. +- **Mapping, not semantics.** A table says which native key a key becomes, + and whether its value is written as the native key's is, as data. Anything structural, such as how aliases become methods, belongs to the builder, not here. The one value that is written again is a group path, which becomes the names joined by dots (`walk.rs`). @@ -24,8 +24,7 @@ rewrite them into native keys, before `forge` reads the document. does not read it at is left alone, since rewriting it would give it a meaning it did not have. - **Native wins.** When a document has the native key, the vendor one is - dropped, or reported if it says something else, whatever order they appear - in. When a vendor key has no native form, it is left as it is written: on a + dropped, whatever order they appear in. When a vendor key has no native form, it is left as it is written: on a property, `x-stainless-skip: true` and `false` become `x-forge-skip` with the same value, but a list of targets such as `["terraform"]` says "skip for these targets", which `x-forge-skip` cannot, as it is for every target. On an @@ -33,11 +32,12 @@ rewrite them into native keys, before `forge` reads the document. ## 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 ask it from `mod.rs`. +1. Find the native key it stands for in the readers of `src/link/`. If there + is none, add a native `x-forge-*` key to the reader first: a vendor key + never introduces a primitive. +2. Map it in the table of the adapter for its tool, or add an adapter next to + `fern.rs` and `stainless.rs` and ask it from `mod.rs`. A test fails if the + table names a key that `forge` does not read at that kind of object. 3. Add a test in `tests/normalize/` showing that the vendor-keyed document, after `forge normalize`, builds the same `Forge` as the native-keyed one. diff --git a/src/normalize/vendor/fern.rs b/src/normalize/vendor/fern.rs index 1f6f42ce3..a9a59f1b4 100644 --- a/src/normalize/vendor/fern.rs +++ b/src/normalize/vendor/fern.rs @@ -5,83 +5,21 @@ //! single group. A document written for Fern uses the array, which means the //! same in both. -/// What every key of the adapter begins with. A key without it is turned -/// away before it is matched against the table. -pub(super) const PREFIX: &str = "x-fern-"; +use super::Key; /// The keys of an operation or an alias. -pub(super) mod definition { - use super::PREFIX; - use crate::metadata::DefinitionField; - use crate::normalize::vendor::Key; - - 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. - #[cfg(test)] - pub(in crate::normalize::vendor) const NAMES: [&str; 4] = [METHOD_NAME, GROUP_NAME, IGNORE, AVAILABILITY]; - - pub(in crate::normalize::vendor) fn read(key: &str) -> Option> { - if !key.starts_with(PREFIX) { - return None; - } - Some(match key { - METHOD_NAME => Key::same(DefinitionField::Method), - GROUP_NAME => Key::same(DefinitionField::Group), - IGNORE => Key::same(DefinitionField::Skip), - AVAILABILITY => Key::same(DefinitionField::Availability), - _ => return None, - }) - } -} +pub(super) const DEFINITION: [(&str, Key); 4] = [ + ("x-fern-sdk-method-name", Key::same("x-forge-method-name")), + ("x-fern-sdk-group-name", Key::group("x-forge-group-name")), + ("x-fern-ignore", Key::same("x-forge-skip")), + ("x-fern-availability", Key::same("x-forge-availability")), +]; /// The keys of a Parameter Object. -pub(super) mod parameter { - use super::PREFIX; - use crate::metadata::ParameterField; - use crate::normalize::vendor::Key; - - const NAME: &str = "x-fern-parameter-name"; - - /// The names of the adapter's keys of a Parameter Object. - #[cfg(test)] - pub(in crate::normalize::vendor) const NAMES: [&str; 1] = [NAME]; - - pub(in crate::normalize::vendor) fn read(key: &str) -> Option> { - if !key.starts_with(PREFIX) { - return None; - } - Some(match key { - NAME => Key::same(ParameterField::Name), - _ => return None, - }) - } -} +pub(super) const PARAMETER: [(&str, Key); 1] = [("x-fern-parameter-name", Key::same("x-forge-parameter-name"))]; /// The keys of a Schema Object. -pub(super) mod schema { - use super::PREFIX; - use crate::metadata::SchemaField; - use crate::normalize::vendor::Key; - - 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. - #[cfg(test)] - pub(in crate::normalize::vendor) const NAMES: [&str; 2] = [TYPE_NAME, PROPERTY_NAME]; - - pub(in crate::normalize::vendor) fn read(key: &str) -> Option> { - if !key.starts_with(PREFIX) { - return None; - } - Some(match key { - TYPE_NAME => Key::same(SchemaField::ModelName), - PROPERTY_NAME => Key::same(SchemaField::PropertyName), - _ => return None, - }) - } -} +pub(super) const SCHEMA: [(&str, Key); 2] = [ + ("x-fern-type-name", Key::same("x-forge-model-name")), + ("x-fern-property-name", Key::same("x-forge-property-name")), +]; diff --git a/src/normalize/vendor/mod.rs b/src/normalize/vendor/mod.rs index c783968a5..f48c648a2 100644 --- a/src/normalize/vendor/mod.rs +++ b/src/normalize/vendor/mod.rs @@ -1,14 +1,14 @@ -//! The keys other tools' metadata is written with, and the native extension of -//! [`metadata`](crate::metadata) that each says the same as. This is the only -//! code that holds them: `forge` itself reads native keys and no others. +//! The keys other tools' metadata is written with, and the native extension +//! that each says the same as. This is the only code that holds them: `forge` +//! itself reads native keys and no others. //! -//! Each adapter, [`fern`] and [`stainless`], maps its tool's keys onto native -//! fields, and says whether the value is written as the native key's is. The -//! names other systems write without the `x-forge-` prefix are in -//! [`unprefixed`]. This module asks them, once per kind of object. +//! Each adapter, [`fern`] and [`stainless`], is a table per kind of object that +//! maps its tool's keys onto native ones, and says whether the value is +//! written as the native key's is. The names other systems write without the +//! `x-forge-` prefix are in [`unprefixed`]. //! -//! A key has to be read at the kind of object `forge` reads its native key -//! at, so that a rewrite does not give a key a meaning it did not have. An +//! A key has to be in the table of the kind of object `forge` reads its native +//! key at, so that a rewrite does not give a key a meaning it did not have. An //! extension that says something `forge` has no primitive for is not here. mod fern; @@ -17,33 +17,56 @@ mod stainless; mod tests; mod unprefixed; -use crate::metadata::{DefinitionField, ParameterField, SchemaField}; - -/// What a key of another tool says, and how its value is written. +/// What a key of another tool becomes. #[derive(Clone, Copy)] -pub(super) struct Key { - /// The native field that says the same. - pub(super) field: F, - /// Whether the value may be a list of targets, as well as a boolean. A - /// list of targets is not something a native key says. - pub(super) targets: bool, +pub(super) struct Key { + /// The native key that says the same. + pub(super) native: &'static str, + /// How the value is written when it is written as the native key's is. + pub(super) form: Form, +} + +/// How the value of a key is written as the native key's is. +#[derive(Clone, Copy, PartialEq, Eq)] +pub(super) enum Form { + /// As it is. + Same, + /// A group path: an array of names is the names joined by dots. + Group, + /// A boolean, or a list of the targets, which a native key does not say. + BoolOrTargets, } -impl Key { +impl Key { /// Returns a key whose value is written as the native key's is. - const fn same(field: F) -> Key { - Key { field, targets: false } + const fn same(native: &'static str) -> Key { + Key { + native, + form: Form::Same, + } + } + + /// Returns a key whose value is a group path. + const fn group(native: &'static str) -> Key { + Key { + native, + form: Form::Group, + } } /// Returns a key whose value is a boolean, or a list of targets. - const fn bool_or_targets(field: F) -> Key { - Key { field, targets: true } + const fn bool_or_targets(native: &'static str) -> Key { + Key { + native, + form: Form::BoolOrTargets, + } } } -/// The text that every key of an adapter has in it, for finding whether a -/// document has one without reading it. -const MENTIONS: [&str; 3] = [fern::PREFIX, stainless::PREFIX, unprefixed::schema::SENSITIVE]; +/// The text that a key of another tool has in it, for finding whether a +/// document has one without reading it. A test checks that each key of the +/// tables has one of them. +const MENTIONS: [&str; 3] = ["x-fern-", "x-stainless-", "x-sensitive"]; /// Returns whether `json` has the text of a key that an adapter reads in it /// somewhere, which is the least that it has to for there to be one. @@ -53,21 +76,25 @@ pub(super) fn mentions(json: &[u8]) -> bool { .any(|text| memchr::memmem::find(json, text.as_bytes()).is_some()) } -/// Returns what `key` says on an operation or an alias, if an adapter reads +/// Returns what `key` becomes on an operation or an alias, if an adapter reads /// it there. -pub(super) fn definition(key: &str) -> Option> { - fern::definition::read(key) +pub(super) fn definition(key: &str) -> Option { + find(&fern::DEFINITION, key) } -/// Returns what `key` says on a Parameter Object, if an adapter reads it +/// Returns what `key` becomes on a Parameter Object, if an adapter reads it /// there. -pub(super) fn parameter(key: &str) -> Option> { - fern::parameter::read(key) +pub(super) fn parameter(key: &str) -> Option { + find(&fern::PARAMETER, key) +} + +/// Returns what `key` becomes on a Schema Object, if an adapter reads it there. +pub(super) fn schema(key: &str) -> Option { + find(&fern::SCHEMA, key) + .or_else(|| find(&stainless::SCHEMA, key)) + .or_else(|| find(&unprefixed::SCHEMA, key)) } -/// Returns what `key` says on a Schema Object, if an adapter reads it there. -pub(super) fn schema(key: &str) -> Option> { - fern::schema::read(key) - .or_else(|| stainless::schema::read(key)) - .or_else(|| unprefixed::schema::read(key)) +fn find(table: &[(&str, Key)], key: &str) -> Option { + table.iter().find(|(name, _)| *name == key).map(|(_, key)| *key) } diff --git a/src/normalize/vendor/stainless.rs b/src/normalize/vendor/stainless.rs index 79f9e3317..144cb2e1b 100644 --- a/src/normalize/vendor/stainless.rs +++ b/src/normalize/vendor/stainless.rs @@ -3,29 +3,7 @@ //! `x-stainless-skip` may be a list of the targets to skip a property for, //! which a native skip, that is for every target, does not say. -/// What every key of the adapter begins with. A key without it is turned -/// away before it is matched against the table. -pub(super) const PREFIX: &str = "x-stainless-"; +use super::Key; /// The keys of a Schema Object. -pub(super) mod schema { - use super::PREFIX; - use crate::metadata::SchemaField; - use crate::normalize::vendor::Key; - - const SKIP: &str = "x-stainless-skip"; - - /// The names of the adapter's keys of a Schema Object. - #[cfg(test)] - pub(in crate::normalize::vendor) const NAMES: [&str; 1] = [SKIP]; - - pub(in crate::normalize::vendor) fn read(key: &str) -> Option> { - if !key.starts_with(PREFIX) { - return None; - } - Some(match key { - SKIP => Key::bool_or_targets(SchemaField::Skip), - _ => return None, - }) - } -} +pub(super) const SCHEMA: [(&str, Key); 1] = [("x-stainless-skip", Key::bool_or_targets("x-forge-skip"))]; diff --git a/src/normalize/vendor/tests.rs b/src/normalize/vendor/tests.rs index 120589129..f79583643 100644 --- a/src/normalize/vendor/tests.rs +++ b/src/normalize/vendor/tests.rs @@ -3,158 +3,67 @@ //! only this module may. use super::*; -use crate::metadata; -#[test] -fn a_document_that_has_a_key_of_the_tables_is_found_to_mention_one() { - let names: Vec<&str> = [ - &fern::definition::NAMES[..], - &fern::parameter::NAMES, - &fern::schema::NAMES, - &stainless::schema::NAMES, - &unprefixed::schema::NAMES, - ] - .concat(); - for name in names { - assert!(mentions(format!(r#"{{"{name}": true}}"#).as_bytes()), "{name}"); - } - assert!(!mentions(br#"{"x-forge-skip": true, "description": "x-forge"}"#)); +/// A kind of object that has keys of other tools. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum Level { + Definition, + Parameter, + Schema, } -/// The keys of each kind of object that the tables read, with what each says. -fn definitions() -> Vec<(&'static str, Key)> { - fern::definition::NAMES - .iter() - .map(|&key| (key, definition(key).unwrap_or_else(|| panic!("{key} is not read")))) - .collect() -} - -fn parameters() -> Vec<(&'static str, Key)> { - fern::parameter::NAMES - .iter() - .map(|&key| (key, parameter(key).unwrap_or_else(|| panic!("{key} is not read")))) - .collect() -} - -fn schemas() -> Vec<(&'static str, Key)> { +/// Every key of the tables, with the kind of object it is read at. +fn tables() -> Vec<(Level, &'static str, Key)> { + let table = |level: Level, table: &[(&'static str, Key)]| -> Vec<_> { + table.iter().map(|&(name, key)| (level, name, key)).collect() + }; [ - &fern::schema::NAMES[..], - &stainless::schema::NAMES, - &unprefixed::schema::NAMES, + table(Level::Definition, &fern::DEFINITION), + table(Level::Parameter, &fern::PARAMETER), + table(Level::Schema, &fern::SCHEMA), + table(Level::Schema, &stainless::SCHEMA), + table(Level::Schema, &unprefixed::SCHEMA), ] .concat() - .into_iter() - .map(|key| (key, schema(key).unwrap_or_else(|| panic!("{key} is not read")))) - .collect() } -#[test] -fn each_key_says_the_native_field_it_stands_for() { - for (key, want) in [ - ("x-fern-sdk-method-name", DefinitionField::Method), - ("x-fern-sdk-group-name", DefinitionField::Group), - ("x-fern-ignore", DefinitionField::Skip), - ("x-fern-availability", DefinitionField::Availability), - ] { - let read = definition(key).unwrap(); - assert!( - std::mem::discriminant(&read.field) == std::mem::discriminant(&want), - "{key}" - ); - } - assert!(matches!( - parameter("x-fern-parameter-name").unwrap().field, - ParameterField::Name - )); - for (key, want) in [ - ("x-fern-type-name", SchemaField::ModelName), - ("x-fern-property-name", SchemaField::PropertyName), - ("x-stainless-skip", SchemaField::Skip), - ("x-sensitive", SchemaField::Sensitive), - ] { - let read = schema(key).unwrap(); - assert!( - std::mem::discriminant(&read.field) == std::mem::discriminant(&want), - "{key}" - ); +fn read(level: Level, key: &str) -> Option { + match level { + Level::Definition => definition(key), + Level::Parameter => parameter(key), + Level::Schema => schema(key), } } #[test] -fn only_the_skip_of_a_schema_that_stainless_writes_may_be_a_list_of_targets() { - for (key, read) in definitions() { - assert!(!read.targets, "{key}"); - } - for (key, read) in parameters() { - assert!(!read.targets, "{key}"); - } - for (key, read) in schemas() { - assert_eq!(read.targets, key == "x-stainless-skip", "{key}"); +fn a_document_that_has_a_key_of_the_tables_is_found_to_mention_one() { + for (_, name, _) in tables() { + assert!(mentions(format!(r#"{{"{name}": true}}"#).as_bytes()), "{name}"); } + assert!(!mentions(br#"{"x-forge-skip": true, "description": "x-forge"}"#)); } #[test] fn a_key_is_read_only_at_its_level() { - let all = [ - fern::definition::NAMES.to_vec(), - fern::parameter::NAMES.to_vec(), - [ - &fern::schema::NAMES[..], - &stainless::schema::NAMES, - &unprefixed::schema::NAMES, - ] - .concat(), - ]; - for (level, keys) in all.iter().enumerate() { - for &key in keys { - let read = [ - definition(key).is_some(), - parameter(key).is_some(), - schema(key).is_some(), - ]; - for (other, reads) in read.into_iter().enumerate() { - assert_eq!(reads, other == level, "{key}"); - } + for (level, name, _) in tables() { + for other in [Level::Definition, Level::Parameter, Level::Schema] { + assert_eq!(read(other, name).is_some(), other == level, "{name} at {other:?}"); } } } #[test] -fn what_a_key_says_has_a_native_key_that_reads_as_it() { - for (key, read) in definitions() { - let native = metadata::definition::key(read.field); - let hit = metadata::definition::read(native).unwrap_or_else(|| panic!("{key} has no native key")); - assert!( - std::mem::discriminant(&hit.field) == std::mem::discriminant(&read.field), - "{key}" - ); - } - for (key, read) in parameters() { - assert!( - metadata::parameter::read(metadata::parameter::key(read.field)).is_some(), - "{key}" - ); - } - for (key, read) in schemas() { - let native = metadata::schema::key(read.field); - let hit = metadata::schema::read(native).unwrap_or_else(|| panic!("{key} has no native key")); - assert!( - std::mem::discriminant(&hit.field) == std::mem::discriminant(&read.field), - "{key}" - ); +fn what_a_key_becomes_is_a_native_key_and_no_key_of_another_tool_is_one() { + for (_, name, key) in tables() { + assert!(key.native.starts_with("x-forge-"), "{name} becomes {}", key.native); + assert!(!name.starts_with("x-forge-"), "{name}"); } } #[test] -fn no_key_of_another_tool_is_a_native_key() { - for (key, _) in definitions() { - assert!(metadata::definition::read(key).is_none(), "{key}"); - } - for (key, _) in parameters() { - assert!(metadata::parameter::read(key).is_none(), "{key}"); - } - for (key, _) in schemas() { - assert!(metadata::schema::read(key).is_none(), "{key}"); +fn only_the_skip_of_a_schema_that_stainless_writes_may_be_a_list_of_targets() { + for (_, name, key) in tables() { + assert_eq!(key.form == Form::BoolOrTargets, name == "x-stainless-skip", "{name}"); } } @@ -176,3 +85,68 @@ fn the_other_extensions_of_these_tools_are_not_read() { assert!(schema(key).is_none(), "{key}"); } } + +/// A value that the native key `native` can have, as JSON. +fn value(native: &str) -> &'static str { + match native { + "x-forge-method-name" => r#""get""#, + "x-forge-group-name" => r#"["a"]"#, + "x-forge-skip" | "x-forge-sensitive" => "true", + "x-forge-availability" => r#""beta""#, + "x-forge-parameter-name" | "x-forge-property-name" => r#""p""#, + "x-forge-model-name" => r#""M""#, + _ => panic!("{native} has no value to try: add one"), + } +} + +/// A document that has `members` in an object of the kind `level`. +fn document(level: Level, members: &str) -> String { + let (paths, components) = match level { + Level::Definition => ( + format!(r#"{{"/a": {{"get": {{{members}, "responses": {{"204": {{"description": "ok"}}}}}}}}}}"#), + "{}".to_owned(), + ), + Level::Parameter => ( + format!( + r#"{{"/a/{{id}}": {{"parameters": [{{"name": "id", "in": "path", "required": true, "schema": {{"type": "string"}}, {members}}}], "get": {{"responses": {{"204": {{"description": "ok"}}}}}}}}}}"# + ), + "{}".to_owned(), + ), + Level::Schema => ( + "{}".to_owned(), + format!(r#"{{"schemas": {{"S": {{"type": "object", {members}}}}}}}"#), + ), + }; + format!( + r#"{{"openapi": "3.0.3", "info": {{"title": "t", "version": "1"}}, "paths": {paths}, "components": {components}}}"# + ) +} + +/// Reads `document` as `forge` does, and returns the error as text. +fn parse(document: &str) -> Result { + serde_json::from_str(document).map_err(|err| err.to_string()) +} + +/// What a key becomes has to be a key that `forge` reads, where the rewrite +/// puts it: a key it reads that is written twice is an error, and one that it +/// does not read is not. This is what keeps the tables from naming a key that +/// `forge` has no use for, or one at the wrong kind of object. +#[test] +fn every_native_key_is_read_by_forge_where_it_is_written() { + for (level, name, key) in tables() { + let once = format!(r#""{}": {}"#, key.native, value(key.native)); + let twice = format!("{once}, {once}"); + + parse(&document(level, &once)).unwrap_or_else(|err| panic!("{name} once: {err}")); + let err = parse(&document(level, &twice)).err().unwrap_or_else(|| { + panic!( + "{name} becomes {}, which {level:?} does not read: it can be written twice", + key.native + ) + }); + assert!( + err.contains(&format!("duplicate field `{}`", key.native)), + "{name}: {err}" + ); + } +} diff --git a/src/normalize/vendor/unprefixed.rs b/src/normalize/vendor/unprefixed.rs index f3e11cbd9..460f7fb67 100644 --- a/src/normalize/vendor/unprefixed.rs +++ b/src/normalize/vendor/unprefixed.rs @@ -1,22 +1,7 @@ //! The names other systems write native keys under, without the `x-forge-` //! prefix. They are in use, so they are not deprecated: they are rewritten. -/// The keys of a Schema Object. -pub(super) mod schema { - use crate::metadata::SchemaField; - use crate::normalize::vendor::Key; - - /// What other systems write for `x-forge-sensitive`. - pub(in crate::normalize::vendor) const SENSITIVE: &str = "x-sensitive"; +use super::Key; - /// The names of the keys of a Schema Object. - #[cfg(test)] - pub(in crate::normalize::vendor) const NAMES: [&str; 1] = [SENSITIVE]; - - pub(in crate::normalize::vendor) fn read(key: &str) -> Option> { - match key { - SENSITIVE => Some(Key::same(SchemaField::Sensitive)), - _ => None, - } - } -} +/// The keys of a Schema Object. +pub(super) const SCHEMA: [(&str, Key); 1] = [("x-sensitive", Key::same("x-forge-sensitive"))]; diff --git a/src/normalize/walk.rs b/src/normalize/walk.rs index 58a9d095a..b9c7cb319 100644 --- a/src/normalize/walk.rs +++ b/src/normalize/walk.rs @@ -9,52 +9,8 @@ use serde_json::{Map, Value}; -use super::vendor::{self, Key}; +use super::vendor::{self, Form, Key}; use super::{Options, Report}; -use crate::metadata::{self, DefinitionField, ParameterField, SchemaField}; - -/// A kind of object with extensions. -trait Level { - type Field: Copy; - - /// Returns what `key` says on the object, if another 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 the value 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 - } -} - -struct Definition; -struct Parameter; -struct Schema; - -impl Level for Definition { - type Field = DefinitionField; - - fn read(key: &str) -> Option> { - vendor::definition(key) - } - - fn native(field: DefinitionField) -> &'static str { - metadata::definition::key(field) - } - - /// 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; - }; - dotted(names).map(Value::String) - } -} /// Returns the names of an array joined by dots, if each is a name that a /// dotted string reads back as it is. @@ -70,30 +26,6 @@ fn dotted(names: &[Value]) -> Option { Some(path) } -impl Level for Parameter { - type Field = ParameterField; - - fn read(key: &str) -> Option> { - vendor::parameter(key) - } - - fn native(field: ParameterField) -> &'static str { - metadata::parameter::key(field) - } -} - -impl Level for Schema { - type Field = SchemaField; - - fn read(key: &str) -> Option> { - vendor::schema(key) - } - - fn native(field: SchemaField) -> &'static str { - metadata::schema::key(field) - } -} - /// A kind of object that is read. #[derive(Clone, Copy)] enum Kind { @@ -190,9 +122,9 @@ impl Walk<'_> { return; }; match kind { - Kind::Operation | Kind::Alias => self.rewrite::(members), - Kind::Parameter => self.rewrite::(members), - Kind::Schema => self.rewrite::(members), + Kind::Operation | Kind::Alias => self.rewrite(members, vendor::definition), + Kind::Parameter => self.rewrite(members, vendor::parameter), + Kind::Schema => self.rewrite(members, vendor::schema), Kind::Document | Kind::Components | Kind::PathItem | Kind::Body | Kind::Media => {} } for (key, member) in members.iter_mut() { @@ -219,36 +151,36 @@ impl Walk<'_> { } } - /// Decides what to do to the keys of the object, which is of the kind `L`, - /// that a native key replaces, and does it. - fn rewrite(&mut self, members: &mut Map) { + /// Decides what to do to the keys of the object that a native key replaces, + /// as `read` says which they are for the kind of object it is, and does it. + fn rewrite(&mut self, members: &mut Map, read: fn(&str) -> Option) { let mut actions: Vec<(String, Action)> = Vec::new(); for (key, value) in members.iter() { if !key.starts_with("x-") { continue; } - let Some(hit) = L::read(key) else { + let Some(hit) = read(key) else { continue; }; - let native = L::native(hit.field); // A list of targets is something no native key says: it is left // as it is written, and not for a rewrite to say some other way // or to drop. - if hit.targets && value.is_array() { + if hit.form == Form::BoolOrTargets && value.is_array() { continue; } // The native key that is beside is the one that decides. - let beside = members.get(native); - - let writes = beside.is_none(); - let written = L::written(hit.field, value).filter(|_| writes); + let writes = !members.contains_key(hit.native); + let written = match value { + Value::Array(names) if writes && hit.form == Form::Group => dotted(names).map(Value::String), + _ => None, + }; if written.is_some() { self.report.joined += 1; } let action = match (self.options.leave_old_extensions, writes) { - (false, true) => Action::Rename(native, written), + (false, true) => Action::Rename(hit.native, written), (false, false) => Action::Remove, - (true, true) => Action::Insert(native, written), + (true, true) => Action::Insert(hit.native, written), (true, false) => continue, }; *self.report.migrated.entry(key.clone()).or_insert(0) += 1; From 08f59ca12e445eb48fd6e25c1792c28f9560f224 Mon Sep 17 00:00:00 2001 From: Niels Swimberghe Date: Thu, 29 Oct 2026 17:54:07 -0400 Subject: [PATCH 18/20] test: put back what the tests of the parser said before the keys were renamed A document in `tests/parse` that named a group with a string, such as `"x-fern-sdk-group-name": "zones"`, had been rewritten to name it with an array, `["zones"]`, while the reader was being made to rank native keys against other tools'. The reader takes either, as it did on `main`, so the change said nothing, and it made the diff of these tests, which should be the rename of a key, look like a change to what they test. The 220 lines that were that, and only that, are what `main` has again, with the key renamed. What is left in `tests/parse` that is more than a rename is the 12 tests that `main` does not have, and the three whose names say skipped for ignored. --- tests/parse/aliases.rs | 42 +++++----- tests/parse/bodies.rs | 28 +++---- tests/parse/common.rs | 4 +- tests/parse/document.rs | 2 +- tests/parse/extensions.rs | 16 ++-- tests/parse/groups.rs | 38 ++++----- tests/parse/headers.rs | 48 +++++------ tests/parse/methods.rs | 52 ++++++------ tests/parse/models.rs | 20 ++--- 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 | 4 +- tests/parse/types/refs.rs | 18 ++--- tests/parse/types/scalars.rs | 6 +- tests/parse/types/unions.rs | 6 +- 19 files changed, 220 insertions(+), 220 deletions(-) diff --git a/tests/parse/aliases.rs b/tests/parse/aliases.rs index 9e13022ef..f0d7db39d 100644 --- a/tests/parse/aliases.rs +++ b/tests/parse/aliases.rs @@ -39,7 +39,7 @@ fn methods(forge: &Forge) -> Vec { } const PAUSE_AND_TERMINATE: &str = r#"[ - {"x-forge-group-name": ["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": "terminate"} ]"#; @@ -97,10 +97,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-name": ["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-name": ["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} ]"#, "", @@ -131,7 +131,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-name": ["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": {}},"#, )); @@ -148,8 +148,8 @@ 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-name": ["a"], "x-forge-method-name": "pause"}, - {"x-forge-group-name": ["a"]}, + {"x-forge-group-name": "a", "x-forge-method-name": "pause"}, + {"x-forge-group-name": "a"}, {"x-forge-method-name": "resume"}, {"x-forge-group-name": [], "x-forge-method-name": "restart"} ]"#, @@ -162,7 +162,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-name": ["a"], "x-forge-method-name": "pause"}]"#, + r#"["pause", null, 1, true, ["a"], {"x-forge-group-name": "a", "x-forge-method-name": "pause"}]"#, "", )); @@ -179,7 +179,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-name": ["a"], "x-forge-method-name": "pause"}"#, + r#"{"x-forge-group-name": "a", "x-forge-method-name": "pause"}"#, "", )); @@ -192,11 +192,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-name": ["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-name": ["a"], "x-forge-method-name": "resume"}, - {"x-forge-group-name": ["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"}}}} } }"#, @@ -212,8 +212,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-name": ["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"}, + {"x-forge-group-name": "a", "x-forge-method-name": "pause"} ]"#, "", )); @@ -240,7 +240,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-name": ["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"}]"#, "", )); @@ -271,8 +271,8 @@ fn skipped_operation_still_has_its_aliases() { fn skipped_alias_is_not_a_method() { let forge = parse(&aliased( r#"[ - {"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} + {"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} ]"#, "", )); @@ -283,8 +283,8 @@ fn skipped_alias_is_not_a_method() { #[test] fn aliases_are_not_extensions() { let forge = parse(&aliased( - 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"}]}]"#, + 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"}]}]"#, "", )); @@ -297,7 +297,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-name": ["a"], "x-forge-method-name": "pause", + r#"[{"x-forge-group-name": "a", "x-forge-method-name": "pause", "summary": "Pause", "deprecated": false, "path": "/b"}]"#, "", )); @@ -313,7 +313,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-name": ["a"], "x-forge-method-name": "get", + "paths": {"/a": {"get": {"x-forge-group-name": "a", "x-forge-method-name": "get", "responses": {"204": {"description": "ok"}}}}} }"#, ); @@ -348,7 +348,7 @@ 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-name": ["x"], "x-forge-method-name": "get", + "x-forge-group-name": "x", "x-forge-method-name": "get", "x-forge-aliases": [ {"x-forge-group-name": ["a", "b"], "x-forge-method-name": "get"}, {"x-forge-group-name": ["a", "c"], "x-forge-method-name": "get"} diff --git a/tests/parse/bodies.rs b/tests/parse/bodies.rs index 296d30b5a..e1adc96ba 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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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 901cf07bb..6bb8510a1 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-name": ["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-name": ["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 cbb9fc30d..894ab6083 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-name": ["a"], "x-forge-method-name": "get", + "paths": {{"/a": {{"get": {{"x-forge-group-name": "a", "x-forge-method-name": "get", "responses": {{"204": {{"description": "ok"}}}}}}}}}} }}"# )); diff --git a/tests/parse/extensions.rs b/tests/parse/extensions.rs index 14fbd9eb0..efbc6f4f3 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-name": ["a"], "x-forge-method-name": "get", + "x-forge-group-name": "a", "x-forge-method-name": "get", "responses": {{"204": {{"description": "ok"}}}}}}}}}} }}"# ) @@ -115,7 +115,7 @@ fn extensions_forge_reads_are_still_read() { "paths": {"/a": { "get": {"x-forge-group-name": ["a", "b"], "x-forge-method-name": "list", "x-a": 1, "responses": {"204": {"description": "ok"}}}, - "post": {"x-forge-group-name": ["a"], "x-forge-method-name": "create", "x-forge-skip": true, + "post": {"x-forge-group-name": "a", "x-forge-method-name": "create", "x-forge-skip": 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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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 48e350ead..3feeec799 100644 --- a/tests/parse/groups.rs +++ b/tests/parse/groups.rs @@ -126,7 +126,7 @@ fn groups_with_equal_names_are_one_group() { r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": { - "/a": {"get": {"x-forge-group-name": ["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-name": ["abuse_reports"], "x-forge-method-name": "get", "responses": {"204": {"description": "ok"}}}} @@ -144,7 +144,7 @@ fn subgroup_and_method_can_share_a_name() { r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": { - "/a": {"delete": {"x-forge-group-name": ["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-name": ["members", "delete"], "x-forge-method-name": "bulk", "responses": {"204": {"description": "ok"}}}} @@ -189,7 +189,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-name": ["a"], + "paths": {"/a": {"get": {"x-forge-group-name": "a", "responses": {"204": {"description": "ok"}}}}} }"#, ); @@ -202,7 +202,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-name": ["a"], "x-forge-method-name": "", + "paths": {"/a": {"get": {"x-forge-group-name": "a", "x-forge-method-name": "", "responses": {"204": {"description": "ok"}}}}} }"#, ); @@ -216,9 +216,9 @@ fn skipped_operation_is_not_a_method() { r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": {"/a": { - "get": {"x-forge-group-name": ["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-name": ["a"], "x-forge-method-name": "create", "x-forge-skip": true, + "post": {"x-forge-group-name": "a", "x-forge-method-name": "create", "x-forge-skip": true, "responses": {"204": {"description": "ok"}}} }} }"#, @@ -258,9 +258,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-name": ["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-name": ["a"], "x-forge-method-name": "list", + "/b": {"get": {"x-forge-group-name": "a", "x-forge-method-name": "list", "responses": {"204": {"description": "ok"}}}} } }"#, @@ -275,9 +275,9 @@ fn method_names_are_equal_as_names() { r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": { - "/a": {"get": {"x-forge-group-name": ["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-name": ["a"], "x-forge-method-name": "listAll", + "/b": {"get": {"x-forge-group-name": "a", "x-forge-method-name": "listAll", "responses": {"204": {"description": "ok"}}}} } }"#, @@ -294,7 +294,7 @@ fn groups_are_depth_first() { "paths": { "/a": {"get": {"x-forge-group-name": ["a", "b", "c"], "x-forge-method-name": "get", "responses": {"204": {"description": "ok"}}}}, - "/b": {"get": {"x-forge-group-name": ["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-name": ["a", "d"], "x-forge-method-name": "get", "responses": {"204": {"description": "ok"}}}} @@ -326,7 +326,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-name": ["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."}} }"#, @@ -431,7 +431,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-name": ["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."}} }"#, @@ -445,7 +445,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-name": ["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."}} }"#, @@ -474,11 +474,11 @@ fn groups_are_sorted_by_name() { r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": { - "/a": {"get": {"x-forge-group-name": ["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-name": ["accounts", "members"], "x-forge-method-name": "get", "responses": {"204": {"description": "ok"}}}}, - "/c": {"get": {"x-forge-group-name": ["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-name": ["accounts", "billing"], "x-forge-method-name": "get", "responses": {"204": {"description": "ok"}}}} @@ -502,11 +502,11 @@ fn methods_are_sorted_by_name() { r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": {"/a": { - "get": {"x-forge-group-name": ["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-name": ["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-name": ["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 1d45cdb8c..ea395b522 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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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-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"}}} + "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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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"}}}}} }"#, @@ -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-name": ["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-name": ["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 029470219..ad67eb6f3 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-name": ["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-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"}}} + "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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["a"], "x-forge-method-name": "post", + "post": {"x-forge-group-name": "a", "x-forge-method-name": "post", "deprecated": true, "responses": {"204": {"description": "ok"}}} }} }"#, diff --git a/tests/parse/models.rs b/tests/parse/models.rs index f4bfe3b36..6ffbe5954 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-name": ["zones"], + "x-forge-group-name": "zones", "x-forge-method-name": "list", "responses": { "200": { @@ -61,7 +61,7 @@ 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-name": ["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-name": ["a", "b", "c"], "x-forge-method-name": "get", "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/M"}}}}}}} @@ -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-name": ["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-name": ["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"}}}}} @@ -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-name": ["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,7 +144,7 @@ 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-name": ["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"}}}}}}}, "/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"}}}}}}}, @@ -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-name": ["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"}}} @@ -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-name": ["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-name": ["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-name": ["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 b0e9eaaae..43576f5a2 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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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-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"}}} + "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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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-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"}}} + "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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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 6555d6935..7b1de934a 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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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 88caa3184..9d56960f7 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-name": ["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-name": ["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 1e331ab35..85ec33c22 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-name": ["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 0794b2739..d9f154ae0 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-name": ["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-name": ["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 79ee061b7..87fadcbab 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-name": ["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-name": ["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-name": ["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-name": ["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 4adcd338f..9cbb36789 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-name": ["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-name": ["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} }}}}}}}} diff --git a/tests/parse/types/refs.rs b/tests/parse/types/refs.rs index 6c6429a6d..b3170080f 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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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-name": ["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 3ec41e305..b54d8d670 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-name": ["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-name": ["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-name": ["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 8f26b6e38..897d7c28f 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-name": ["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-name": ["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-name": ["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"} From 3792edb85921c4faefd0e9660e6f4f78f9d04be4 Mon Sep 17 00:00:00 2001 From: Niels Swimberghe Date: Thu, 29 Oct 2026 18:01:07 -0400 Subject: [PATCH 19/20] fix: the step before forge reads a document finds a key written with escapes The step looks for the text of a key of another tool, and reads the document only if it finds one. A key can be written with escapes, as `"x\u002dfern-ignore"` is the key `x-fern-ignore`, and its text is then not in the document: a document whose only old keys were written so was not rewritten, and built no method, where the original engine builds one. That was in the list of known limitations. It is not a limitation the step has to have. The keys of other tools are ASCII, so a key can only be written with an escape as `\u00` and a digit from 2 to 7, the printable ASCII range. The step now reads a document that has one. What is escaped in most documents, `\u00e9` for one, is not ASCII, and does not make it read the document; one that is read and has nothing to rewrite is used as it was. A test of the tables says which escapes are found and which are not, and a test of the step builds the method from a document with escaped keys. Each fails without the check. It costs nothing on the 28 MB spec, which has no escape at all, and the SDK from it is still the original engine's, file for file. The limitation is out of the list, in the README and in the tests that pin them. --- packages/forge/README.md | 3 +-- src/normalize/mod.rs | 2 +- src/normalize/vendor/mod.rs | 13 ++++++++++++- src/normalize/vendor/tests.rs | 15 +++++++++++++++ tests/normalize/known_gaps.rs | 11 ----------- tests/normalize/normalize.rs | 16 ++++++++++++++++ 6 files changed, 45 insertions(+), 15 deletions(-) diff --git a/packages/forge/README.md b/packages/forge/README.md index 18c305ca3..02476c494 100644 --- a/packages/forge/README.md +++ b/packages/forge/README.md @@ -83,8 +83,7 @@ has a key to rewrite is not a minimal edit. The order of its keys is kept. the end of the file. `forge` says so, and `forge normalize --leave-old-extensions` prints that document. - **Errors**: JSON with comments, a byte order mark, and a document nested - deeper than 128 levels are not read. A key written with an escape is rewritten - by `forge normalize`, and not by the step `forge` does in memory. + deeper than 128 levels are not read. - **Memory**: a document is held in memory at about 6.5 times its size, one file at a time. diff --git a/src/normalize/mod.rs b/src/normalize/mod.rs index db8f13bfa..ebf070406 100644 --- a/src/normalize/mod.rs +++ b/src/normalize/mod.rs @@ -83,7 +83,7 @@ impl From for Error { /// Returns whether `json` has the text of a key that another tool writes. A /// document that does not has nothing to rewrite, which is told without -/// reading it. A key written with an escape is not found. +/// reading it. #[doc(hidden)] pub fn mentions_old_keys(json: &[u8]) -> bool { vendor::mentions(json) diff --git a/src/normalize/vendor/mod.rs b/src/normalize/vendor/mod.rs index f48c648a2..bb9ccb92e 100644 --- a/src/normalize/vendor/mod.rs +++ b/src/normalize/vendor/mod.rs @@ -69,11 +69,22 @@ impl Key { const MENTIONS: [&str; 3] = ["x-fern-", "x-stainless-", "x-sensitive"]; /// Returns whether `json` has the text of a key that an adapter reads in it -/// somewhere, which is the least that it has to for there to be one. +/// somewhere, which is the least that it has to for there to be one. A key can +/// be written with escapes, so that its text is not there: `"x\u002dfern-ignore"` +/// is the key `x-fern-ignore`. pub(super) fn mentions(json: &[u8]) -> bool { MENTIONS .iter() .any(|text| memchr::memmem::find(json, text.as_bytes()).is_some()) + || escapes_ascii(json) +} + +/// Returns whether `json` writes an ASCII character with an escape, which is +/// `\u00` and a digit from 2 to 7: the keys of other tools are ASCII, so that +/// is the only way one of them can be written so that its text is not found. +/// What is escaped in most documents is not ASCII, as `\u00e9` is not. +fn escapes_ascii(json: &[u8]) -> bool { + memchr::memmem::find_iter(json, br"\u00").any(|at| matches!(json.get(at + 4), Some(b'2'..=b'7'))) } /// Returns what `key` becomes on an operation or an alias, if an adapter reads diff --git a/src/normalize/vendor/tests.rs b/src/normalize/vendor/tests.rs index f79583643..82d102155 100644 --- a/src/normalize/vendor/tests.rs +++ b/src/normalize/vendor/tests.rs @@ -43,6 +43,21 @@ fn a_document_that_has_a_key_of_the_tables_is_found_to_mention_one() { assert!(!mentions(br#"{"x-forge-skip": true, "description": "x-forge"}"#)); } +#[test] +fn a_key_written_with_escapes_is_found_to_be_mentioned_and_text_with_other_escapes_is_not() { + // Each character of the key can be an escape, or none. + for key in [ + r#"x\u002dfern-ignore"#, + r#"\u0078-fern-ignore"#, + r#"x-fern-\u0069gnore"#, + r#"x\u002Dstainless-skip"#, + ] { + assert!(mentions(format!(r#"{{"{key}": true}}"#).as_bytes()), "{key}"); + } + // What is escaped in most documents is not ASCII, or is not a key. + assert!(!mentions(br#"{"description": "caf\u00e9 \u00ff \u2019 \n"}"#)); +} + #[test] fn a_key_is_read_only_at_its_level() { for (level, name, _) in tables() { diff --git a/tests/normalize/known_gaps.rs b/tests/normalize/known_gaps.rs index a403ac516..db42c1e42 100644 --- a/tests/normalize/known_gaps.rs +++ b/tests/normalize/known_gaps.rs @@ -112,17 +112,6 @@ fn forge_does_not_reject_a_key_written_twice_in_a_document_that_has_a_key_to_rew parse(&String::from_utf8(rewritten).unwrap()); } -/// GAP: a key written with an escape is rewritten by `forge normalize`, which -/// reads the document, and not by the step `forge` does before it reads one, -/// which only reads a document that has the text of a key to rewrite in it. -#[test] -fn a_key_written_with_an_escape_is_not_found_by_the_step_before_forge_reads() { - let document = r#"{"x\u002dfern-ignore": true}"#; - - assert!(!mentions_old_keys(document.as_bytes())); - assert!(mentions_old_keys(br#"{"x-fern-ignore": true}"#)); -} - /// GAP: JSON with comments, which some call JSONC, is not JSON, and is an error. #[test] fn json_with_comments_is_an_error() { diff --git a/tests/normalize/normalize.rs b/tests/normalize/normalize.rs index 36999d9ba..4c39f109c 100644 --- a/tests/normalize/normalize.rs +++ b/tests/normalize/normalize.rs @@ -888,3 +888,19 @@ fn a_value_that_cannot_be_read_is_rewritten_and_forge_says_so() { assert!(text.contains(r#""x-forge-group-name": 5"#), "{text}"); assert!(parse_err(&text).contains("expected a string or an array of strings")); } + +/// A key is a key however it is written: with escapes, its text is not in the +/// document, and the step before `forge` reads it still has to find it. +#[test] +fn the_step_before_forge_reads_rewrites_a_key_that_is_written_with_escapes() { + let document = operation_with(r#""x\u002dfern-sdk-group-name": ["a"], "x\u002dfern-sdk-method-name": "get","#); + + let text = read_in_memory(&document); + + assert_eq!( + parse(&text).groups().flat_map(|group| group.methods()).count(), + 1, + "{text}" + ); + assert_ne!(text, document); +} From 79261e864070e63b9767c39e3074ba4a8a0fba59 Mon Sep 17 00:00:00 2001 From: Niels Swimberghe Date: Thu, 29 Oct 2026 20:06:57 -0400 Subject: [PATCH 20/20] fix: align normalization with forge reader --- AGENTS.md | 2 +- packages/forge/README.md | 7 +++--- rfcs/0001-native-metadata.md | 4 ++-- src/bin/forge/main.rs | 2 +- src/bin/forge/normalize.rs | 18 +++++---------- src/normalize/mod.rs | 6 ++--- src/normalize/vendor/tests.rs | 5 +++++ src/normalize/walk.rs | 19 ++++++++++++---- tests/normalize/known_gaps.rs | 29 +++++-------------------- tests/normalize/normalize.rs | 41 +++++++++++++++++++++++++++++++++-- 10 files changed, 81 insertions(+), 52 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 02319754a..f3cc7f5c6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,7 +15,7 @@ API documentation site for Forge OpenAPI specs, built on Astro. Compatibility with other tools' extensions (`x-fern-*`, `x-stainless-*`, and `x-sensitive`) is sectioned off into its own area, `src/normalize/vendor/`, and nowhere else in the codebase knows them. `forge` does not read them: they are ordinary extensions to it, and `forge normalize` is what rewrites them into native ones. Any new compatibility key belongs in that area. See [`src/normalize/vendor/README.md`](src/normalize/vendor/README.md). - Never name a vendor extension outside `src/normalize/vendor/` and `tests/normalize/`, comments and docs included. `tests/boundary.rs` enforces this (`just boundary`). -- Run `forge normalize --write --leave-old-extensions` on a vendor-keyed document before `forge` reads it. The old keys stay for tools that still read them. Until the release that makes this step required, the `forge` command also does it in memory when it reads a document (`before_reading` in `src/bin/forge/normalize.rs`); that call is temporary, and the engine in `src/` never does it. +- Run `forge normalize --write --leave-old-extensions` on a vendor-keyed document before `forge` reads it. The old keys stay for tools that still read them. Until the release that makes this step required, the `forge` command also does it in memory when it reads a document (`before_reading` in `src/normalize/mod.rs`); that call is temporary, and the engine in `src/` never does it. - 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. diff --git a/packages/forge/README.md b/packages/forge/README.md index 02476c494..f8f458e1f 100644 --- a/packages/forge/README.md +++ b/packages/forge/README.md @@ -57,9 +57,10 @@ What is rewritten: key is kept. For now, `forge` also does this in memory when it reads a document, so one that -has not been through `forge normalize` still builds. It keeps the old keys, as -`--leave-old-extensions` does, so plugins read them as ordinary extensions. That -stops in the release that makes `forge normalize` a step to run first. +has not been through `forge normalize` still builds as before, subject to the +known limitations below. It keeps the old keys, as `--leave-old-extensions` +does, so plugins read them as ordinary extensions. That stops in the release +that makes `forge normalize` a step to run first.
Known limitations diff --git a/rfcs/0001-native-metadata.md b/rfcs/0001-native-metadata.md index ce5927a84..5cc19b20c 100644 --- a/rfcs/0001-native-metadata.md +++ b/rfcs/0001-native-metadata.md @@ -12,7 +12,7 @@ Vendor names were hardcoded in the readers, in value formats, in error messages, - `forge` reads native keys only: no tiers, no fallback, no feature to turn on. - Nothing outside `src/normalize/vendor/` and `tests/normalize/` names a vendor extension, enforced by a test. -- A document, after `forge normalize`, builds what it built before. +- A document, after `forge normalize`, builds what it built before, apart from the documented limitations of reading and writing it with `serde_json`. Not in scope: Fern's structural behavior (allOf/anyOf, inline request types), operation ID prefixing, and the docs packages, which read vendor keys themselves. Vendor keys with no Forge primitive are neither read nor rewritten. @@ -61,7 +61,7 @@ src/ ## Migration - Run `forge normalize --write` before `forge` reads a document written with vendor keys, with `--leave-old-extensions` where something else still reads them. A pipeline is `cat spec.json | forge normalize - | forge - --target ts --out sdk`. -- Until the release that makes `forge normalize` a step to run first, the `forge` command does the same rewrite in memory when it reads a document, so one that has not been through it builds as before. It leaves the old keys, as `--leave-old-extensions` does, so plugins read them as the ordinary extensions they become in that release. It costs a pass over the document, and stops in that release; the core never reads anything but native keys. +- Until the release that makes `forge normalize` a step to run first, the `forge` command does the same rewrite in memory when it reads a document, so one that has not been through it builds as before, subject to the same documented limitations. It leaves the old keys, as `--leave-old-extensions` does, so plugins read them as the ordinary extensions they become in that release. It costs a pass over the document, and stops in that release; the core never reads anything but native keys. - After that release, a document that is not normalized is not an error: `forge` finds no method names in it, and so no methods. - Keys that are left are ordinary extensions, so plugins see them. diff --git a/src/bin/forge/main.rs b/src/bin/forge/main.rs index 617d0e82d..01e3c071e 100644 --- a/src/bin/forge/main.rs +++ b/src/bin/forge/main.rs @@ -3,7 +3,7 @@ //! ```text //! forge openapi.json --target ts --out sdk //! forge openapi.json --target wasm://plugin.wasm --out sdk -//! forge normalize 'specs/*.json' +//! forge normalize specs/*.json //! ``` use std::fs; diff --git a/src/bin/forge/normalize.rs b/src/bin/forge/normalize.rs index fb1a0c590..3c5da7414 100644 --- a/src/bin/forge/normalize.rs +++ b/src/bin/forge/normalize.rs @@ -82,17 +82,11 @@ pub fn run(args: Args) -> Result<(), Err> { return print_file(file, &options, say); } - // One file at a time: each is held as a tree of several times its size. - let results: Vec<(PathBuf, Result)> = files - .into_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) = (0, 0, 0); - for (path, result) in results { + // One file at a time: each is held as a tree of several times its size. + for input in files { + let name = input.shown.display().to_string(); + let result = rewrite(&input.real, &options, say).map_err(|err| format!("{name}: {err}")); match result { Ok(report) => { if report.changed() { @@ -100,7 +94,7 @@ pub fn run(args: Args) -> Result<(), Err> { } else { unchanged += 1; } - print(&path.display().to_string(), &report, say); + print(&name, &report, say); } Err(err) => { failed += 1; @@ -197,7 +191,7 @@ fn unique_files(inputs: &[String]) -> Result, Err> { } /// 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. +/// link is, is found to be one and rewritten once. #[derive(PartialEq, Eq, Hash)] enum Identity { /// The device it is on, and where on it. diff --git a/src/normalize/mod.rs b/src/normalize/mod.rs index ebf070406..8e1d8da94 100644 --- a/src/normalize/mod.rs +++ b/src/normalize/mod.rs @@ -84,8 +84,7 @@ impl From for Error { /// Returns whether `json` has the text of a key that another tool writes. A /// document that does not has nothing to rewrite, which is told without /// reading it. -#[doc(hidden)] -pub fn mentions_old_keys(json: &[u8]) -> bool { +fn mentions_old_keys(json: &[u8]) -> bool { vendor::mentions(json) } @@ -107,7 +106,8 @@ pub fn mentions_old_keys(json: &[u8]) -> bool { /// /// This is temporary. `forge` reads native keys only, and `forge normalize` is /// what is meant to come first. This keeps a document that has not been through -/// it building as it did, until the release that stops doing it. +/// it building as it did, subject to the documented limitations, until the +/// release that stops doing it. #[doc(hidden)] pub fn before_reading(json: Vec) -> Result<(Vec, bool), Error> { if !mentions_old_keys(&json) { diff --git a/src/normalize/vendor/tests.rs b/src/normalize/vendor/tests.rs index 82d102155..b758c1501 100644 --- a/src/normalize/vendor/tests.rs +++ b/src/normalize/vendor/tests.rs @@ -43,6 +43,11 @@ fn a_document_that_has_a_key_of_the_tables_is_found_to_mention_one() { assert!(!mentions(br#"{"x-forge-skip": true, "description": "x-forge"}"#)); } +#[test] +fn a_key_with_a_known_vendor_prefix_is_found_even_when_no_table_reads_it() { + assert!(mentions(br#"{"x-fern-unknown": true}"#)); +} + #[test] fn a_key_written_with_escapes_is_found_to_be_mentioned_and_text_with_other_escapes_is_not() { // Each character of the key can be an escape, or none. diff --git a/src/normalize/walk.rs b/src/normalize/walk.rs index b9c7cb319..03139dd2f 100644 --- a/src/normalize/walk.rs +++ b/src/normalize/walk.rs @@ -50,6 +50,8 @@ enum Shape { One(Kind), /// The value is an object, and each of its members is. Values(Kind), + /// The value is an object, and each member that is not an extension is. + Entries(Kind), /// The value is an array, and each of its elements is. Elements(Kind), } @@ -60,7 +62,7 @@ fn child(kind: Kind, key: &str, value: &Value) -> Option { use Kind::*; use Shape::*; Some(match (kind, key) { - (Document, "paths") => Values(PathItem), + (Document, "paths") => Entries(PathItem), (Document, "components") => One(Components), (Components, "schemas") => Values(Schema), (Components, "parameters") => Values(Parameter), @@ -74,16 +76,16 @@ fn child(kind: Kind, key: &str, value: &Value) -> Option { (PathItem, "additionalOperations") => Values(Operation), (Operation, "parameters") => Elements(Parameter), (Operation, "requestBody") => One(Body), - (Operation, "responses") => Values(Body), + (Operation, "responses") => Entries(Body), (Operation, "x-forge-aliases") => Elements(Alias), (Parameter, "schema") => One(Schema), (Parameter, "content") => Values(Media), (Body, "content") => Values(Media), (Media, "schema") => One(Schema), (Schema, "properties" | "$defs") => Values(Schema), - (Schema, "additionalProperties" | "unevaluatedProperties" | "not") => One(Schema), + (Schema, "additionalProperties" | "unevaluatedProperties") => One(Schema), (Schema, "items") if value.is_object() => One(Schema), - (Schema, "items" | "prefixItems" | "allOf" | "anyOf" | "oneOf") => Elements(Schema), + (Schema, "items" | "allOf" | "anyOf" | "oneOf") => Elements(Schema), _ => return None, }) } @@ -140,6 +142,15 @@ impl Walk<'_> { } } } + Shape::Entries(kind) => { + if let Some(members) = member.as_object_mut() { + for (key, member) in members { + if !key.starts_with("x-") { + self.visit(kind, member); + } + } + } + } Shape::Elements(kind) => { if let Some(elements) = member.as_array_mut() { for element in elements.iter_mut() { diff --git a/tests/normalize/known_gaps.rs b/tests/normalize/known_gaps.rs index db42c1e42..8fc5a080b 100644 --- a/tests/normalize/known_gaps.rs +++ b/tests/normalize/known_gaps.rs @@ -5,7 +5,7 @@ //! passes. When one of them fails, a gap was fixed: move it out of here and //! out of "Known limitations" in the README of the package, if it is in it. -use forge::normalize::{Options, before_reading, mentions_old_keys, normalize}; +use forge::normalize::{Options, before_reading, normalize}; use super::common::{parse, parse_err}; @@ -173,22 +173,6 @@ fn minus_zero_is_written_as_a_float() { assert!(text.contains("-0.0"), "{text}"); } -/// GAP: the step before `forge` reads a document is given a document that has -/// the text of a key of another tool that no table reads, reads it, and does -/// nothing to it. -#[test] -fn a_key_of_another_tool_that_is_not_read_is_found_and_changes_nothing() { - let document = r#"{"openapi": "3.0.3", "x-fern-unknown": 1}"#; - - assert!(mentions_old_keys(document.as_bytes())); - assert!( - normalize(document.as_bytes(), &Options::default()) - .unwrap() - .json - .is_none() - ); -} - /// What is not a gap: a document without a key to rewrite is not read, and not /// written, by the step before `forge` reads it, so none of the above is true /// of it. @@ -196,11 +180,8 @@ fn a_key_of_another_tool_that_is_not_read_is_found_and_changes_nothing() { fn a_document_with_no_key_to_rewrite_is_left_exactly_as_it_is() { let document = "{\"openapi\": \"3.0.3\",\r\n \"x\": 1E5, \"y\": [1,2]}"; - assert!(!mentions_old_keys(document.as_bytes())); - assert!( - normalize(document.as_bytes(), &Options::default()) - .unwrap() - .json - .is_none() - ); + let (read, changed) = before_reading(document.as_bytes().to_vec()).unwrap(); + + assert!(!changed); + assert_eq!(read, document.as_bytes()); } diff --git a/tests/normalize/normalize.rs b/tests/normalize/normalize.rs index 4c39f109c..48eb70266 100644 --- a/tests/normalize/normalize.rs +++ b/tests/normalize/normalize.rs @@ -272,7 +272,7 @@ fn schemas_are_found_wherever_forge_reads_them() { "get": { "requestBody": {"content": {"application/json": {"schema": { "allOf": [skipped], "items": skipped, "additionalProperties": skipped, - "prefixItems": [skipped], "not": skipped, "$defs": {"d": skipped}}}}}, + "$defs": {"d": skipped}}}}}, "responses": {"200": {"description": "ok", "content": media(&skipped)}}}}}, "components": { "parameters": {"p": {"name": "p", "in": "query", "content": media(&skipped)}}, @@ -283,10 +283,47 @@ fn schemas_are_found_wherever_forge_reads_them() { 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-stainless-skip"], 10); assert_eq!(report.migrated["x-fern-parameter-name"], 1); } +#[test] +fn schemas_forge_does_not_read_are_left_alone() { + let skipped = json!({"type": "string", "x-stainless-skip": true}); + let document = responding_with(&json!({"prefixItems": [skipped], "not": skipped}).to_string(), "{}"); + + for options in [Options::default(), KEEP] { + let normalized = normalize(document.as_bytes(), &options).unwrap(); + + assert!(normalized.json.is_none() && !normalized.report.changed()); + } +} + +#[test] +fn entries_that_are_extensions_are_not_walked() { + let document = json!({ + "openapi": "3.1.0", + "info": {"title": "t", "version": "1"}, + "paths": { + "x-paths-extension": { + "get": {"x-fern-sdk-method-name": "ignored"} + }, + "/a": {"get": { + "responses": {"x-responses-extension": { + "content": {"application/json": {"schema": {"x-sensitive": true}}} + }} + }} + } + }) + .to_string(); + + for options in [Options::default(), KEEP] { + let normalized = normalize(document.as_bytes(), &options).unwrap(); + + assert!(normalized.json.is_none() && !normalized.report.changed()); + } +} + /// Returns a document with one schema in `components.schemas` that has the /// property `p` written as `property`. fn with_property(property: &str) -> String {