diff --git a/AGENTS.md b/AGENTS.md index 432588972..8f0388f61 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 --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. ## Conventions diff --git a/Cargo.lock b/Cargo.lock index db4f44398..51d2bfcaf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -574,6 +574,7 @@ version = "0.1.0" dependencies = [ "clap", "divan", + "memchr", "rayon", "serde", "serde_json", @@ -1228,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 184531c7a..49d72ab54 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,15 +8,15 @@ edition = "2024" [dependencies] 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 = { version = "1.0.151", optional = true } +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 } [dev-dependencies] divan = "0.1.21" -serde_json = "1.0.151" [[bench]] name = "parse" @@ -26,7 +26,7 @@ harness = false default = ["cli"] # The `forge` command. Library users can turn it off with # `default-features = false`. -cli = ["dep:clap", "dep:serde_json"] +cli = ["dep:clap"] # `forge::wasm`, which runs plugins compiled to WebAssembly components. wasm = ["dep:wasmtime", "dep:wasmtime-wasi"] 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..f5499fb14 100644 --- a/packages/forge/README.md +++ b/packages/forge/README.md @@ -16,6 +16,80 @@ 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 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 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 + +# Keep the old keys beside the native ones, for what still reads them. +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 + +# Read from standard input, write to standard output, and generate from that. +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/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..1cc3b3bb6 --- /dev/null +++ b/rfcs/0001-metadata-resolution-layer.md @@ -0,0 +1,75 @@ +# 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 + +- 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 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. + +## 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. +``` + +## 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. +- 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`, 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 6781dd8ea..2b59e689a 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,44 @@ use std::process::ExitCode; use clap::Parser; use forge::{Err, Forge}; +mod normalize; 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 --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)] +#[command(version, args_conflicts_with_subcommands = true)] struct Args { + #[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. +#[derive(clap::Subcommand)] +enum Command { + /// Rewrite other tools' extensions into the native x-forge-* ones. + /// + /// 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. + Normalize(normalize::Args), } /// What forge generates an SDK with. @@ -68,25 +92,41 @@ fn main() -> ExitCode { } } -/// Generates the SDK that `args` ask for. +/// Does what `args` ask for. fn run(args: Args) -> Result<(), Err> { - match args.target { + 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)?) } } } /// 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("-") { @@ -96,7 +136,14 @@ fn read(path: &Path) -> Result { } else { fs::read(path)? }; - Ok(serde_json::from_slice(&json)?) + let (json, rewritten) = forge::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()) } @@ -104,11 +151,32 @@ fn read(path: &Path) -> Result { #[cfg(test)] mod tests { use clap::CommandFactory; + use clap::Parser; + use std::path::Path; use super::Args; + use super::Command; #[test] fn args() { Args::command().debug_assert(); } + + #[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..8619df8d3 --- /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::{Normalized, Options, Report, normalize}; + +/// What `forge normalize` is given, which says what it does in `main.rs`. +#[derive(clap::Args)] +pub struct Args { + /// 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)] + 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, + /// 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> { + let options = Options { + leave_old_extensions: args.leave_old_extensions, + }; + 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 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); + } + + // 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, mut warnings) = (0, 0, 0, 0); + for (path, result) in results { + match result { + Ok(report) => { + if report.changed() { + rewritten += 1; + } else { + unchanged += 1; + } + warnings += report.conflicts.len(); + print(&path.display().to_string(), &report, say); + } + Err(err) => { + failed += 1; + eprintln!("forge: {err}"); + } + } + } + let total = rewritten + unchanged; + let warned = match warnings { + 0 => String::new(), + n => format!(", {n} {}", plural(n, "warning", "warnings")), + }; + if failed > 0 { + return Err(format!("{failed} {} could not be processed", plural(failed, "file", "files")).into()); + } + if say.check { + if rewritten > 0 { + return Err(format!( + "{rewritten} of {total} {} would be rewritten{warned}", + plural(total, "file", "files") + ) + .into()); + } + eprintln!( + "forge: {total} {} checked, none would be rewritten{warned}", + plural(total, "file", "files") + ); + } else { + eprintln!( + "forge: {total} {}: {rewritten} rewritten, {unchanged} unchanged{warned}", + plural(total, "file", "files") + ); + } + Ok(()) +} + +/// Returns `one` if `n` is 1, and `many` if it is not. +fn plural<'a>(n: usize, one: &'a str, many: &'a str) -> &'a str { + if n == 1 { one } else { many } +} + +/// Rewrites the document read from standard input to standard output. +fn filter(options: &Options, say: Say) -> Result<(), Err> { + let mut json = Vec::new(); + io::stdin().read_to_end(&mut json)?; + 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(name, &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) => out.write_all(edited)?, + 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(name, &normalized.report, say); + Ok(()) +} + +/// 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 { + 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) +} + +/// 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()) + } +} + +/// 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()) + .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()); + } + 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 + && !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: &[u8]) -> 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: &[u8]) -> io::Result<()> { + fs::File::create(path)?.write_all(edited) +} + +/// 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: &[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(); + 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)?; + file.write_all(edited) + }) + .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 + ); + } +} + +/// 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 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}"); + } + 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..b62729a1a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,8 +5,10 @@ use std::{iter, ptr}; mod arena; mod json; mod link; +mod metadata; mod method; mod name; +pub mod normalize; mod plugin; mod types; pub use json::Json; @@ -223,7 +225,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 +235,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 +252,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 +263,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 +317,7 @@ impl<'f> Method<'f> { /// Returns the stage of the API the method is in, such as /// [`Availability::Beta`], or `None` if the operation does not say. /// - /// Corresponds to the `x-fern-availability` extension of the + /// Corresponds to the `x-forge-availability` extension of the /// operation, a string that is one of `alpha`, `beta`, `preview`, /// `generally-available` and `deprecated`, as [`Availability`] lists. /// Any other value, including one that differs only in case, is an @@ -323,7 +326,7 @@ impl<'f> Method<'f> { /// /// It is apart from [`is_deprecated`](Method::is_deprecated), which /// comes from OpenAPI's own `deprecated`: an operation with - /// `x-fern-availability: deprecated` and without `deprecated: true` is + /// `x-forge-availability: deprecated` and without `deprecated: true` is /// [`Availability::Deprecated`] but not deprecated, and one with /// `deprecated: true` has whatever availability it says, or none. pub fn availability(self) -> Option { @@ -335,7 +338,7 @@ impl<'f> Method<'f> { /// /// Corresponds to the `x-forge-hidden` extension of the operation, a /// boolean, which is false without it. A value that is not a boolean - /// is an error, as for `x-fern-ignore`. Unlike `x-fern-ignore`, a + /// is an error, as for `x-forge-skip`. Unlike `x-forge-skip`, a /// hidden operation is still a method. pub fn is_hidden(self) -> bool { self.data().hidden @@ -384,7 +387,7 @@ impl<'f> Method<'f> { /// client sends the fields that [`Field::is_read_only`] reports. A /// parameter's field is never read-only. /// - /// A parameter's field is named after the `x-fern-parameter-name` + /// A parameter's field is named after the `x-forge-parameter-name` /// extension of the parameter, or else its wire name. Its description /// is the parameter's, or else its schema's, following `$ref`s, as /// [`Field::description`] says; from OpenAPI 3.1, the `description` of @@ -494,11 +497,13 @@ impl<'f> Method<'f> { /// /// An extension is a key of the /// [Operation Object](https://spec.openapis.org/oas/latest.html#operation-object) - /// that starts with `x-`. Those forge reads, `x-fern-sdk-method-name`, - /// `x-fern-sdk-group-name`, `x-fern-ignore`, `x-fern-availability`, + /// that starts with `x-`. Those forge reads, `x-forge-method-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 +593,7 @@ impl HttpMethod { /// The stage of the API a [`Method`] is in, as /// [`Method::availability`] gives it. /// -/// Corresponds to the value of the `x-fern-availability` extension of an +/// Corresponds to the value of the `x-forge-availability` extension of an /// operation, which each variant names. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum Availability { @@ -606,7 +611,7 @@ pub enum Availability { } impl Availability { - /// Returns the availability as `x-fern-availability` writes it, such + /// Returns the availability as `x-forge-availability` writes it, such /// as `generally-available`. pub fn as_str(self) -> &'static str { match self { @@ -646,7 +651,7 @@ impl<'f> Model<'f> { /// Returns the name of the model. /// - /// Corresponds to the `x-fern-type-name` extension of the schema. A + /// Corresponds to the `x-forge-model-name` extension of the schema. A /// schema without one is named after its schema name, leaving out /// `$defs`, so `Tree/$defs/Node` has the name `Tree Node`. pub fn name(self) -> &'f Name { diff --git a/src/link.rs b/src/link.rs index e77314894..685237a84 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,7 +565,7 @@ impl<'d> Builder<'d> { http_method: HttpMethod, ) { let doc = self.doc; - if definition.ignore { + if definition.skip { return; } if definition.method_name.is_empty() { @@ -576,22 +578,29 @@ 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("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", + metadata::definition::GROUP_NAME + )); return; } } let name = Name::new(doc.str(definition.method_name)); 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", + metadata::definition::METHOD_NAME + )); return; } let mut availability = None; if let Some(value) = definition.availability { 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", + metadata::definition::AVAILABILITY )); } } @@ -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..9e5b5afd5 --- /dev/null +++ b/src/link/extension.rs @@ -0,0 +1,50 @@ +//! 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 ba7fa4906..d8d2ab310 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. @@ -240,21 +242,19 @@ pub(super) struct Operation { /// `x-forge-aliases`, a method. #[derive(Default)] pub(super) struct Definition { - /// The `x-fern-sdk-method-name`, or empty for none. + /// The name of the method, 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. + /// 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`. + /// Whether the definition makes no method. + pub(super) skip: bool, + /// 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. + /// 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: 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 `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 +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-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 +850,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 +1035,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 +1085,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 +1102,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 +1125,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 +1235,26 @@ 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 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, - key: DefinitionKey, + field: DefinitionField, ) -> 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))?); - } - DefinitionKey::Availability => { + 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))?); } - DefinitionKey::Other => skip(map)?, + DefinitionField::Hidden => self.hidden = map.next_value()?, + DefinitionField::Confirmation => { + self.confirmation = Some(map.next_value_seed(seed::(doc))?); + } } Ok(()) } @@ -1236,9 +1277,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.field)?; } AliasField::Extension(name) => json::insert(&mut extensions, name, map.next_value()?), AliasField::Other => skip(&mut map)?, @@ -1346,7 +1387,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 +1404,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 +1421,6 @@ impl<'de> Deserialize<'de> for AliasField { keys! { OperationKey { - Aliases = "x-forge-aliases", Summary = "summary", Description = "description", Deprecated = "deprecated", @@ -1404,9 +1444,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.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))?); continue; } OperationField::Extension(name) => { @@ -1416,9 +1463,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 +1485,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 +1507,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 +1535,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 +1547,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 +1577,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 +1678,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 +1690,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 +1751,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 +1854,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..0d94528be --- /dev/null +++ b/src/metadata/mod.rs @@ -0,0 +1,395 @@ +//! 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/mod.rs b/src/normalize/mod.rs new file mode 100644 index 000000000..6155e963c --- /dev/null +++ b/src/normalize/mod.rs @@ -0,0 +1,253 @@ +//! 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 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 limitations", and each one is pinned by a test in +//! `tests/normalize/known_gaps.rs`. + +mod vendor; +mod walk; + +use std::collections::BTreeMap; +use std::fmt; + +use serde::Serialize; +use serde_json::Value; +use serde_json::ser::PrettyFormatter; + +/// What [`normalize`] does. +#[derive(Clone, Copy, Debug, Default)] +pub struct Options { + /// Keeps each key that is rewritten, beside the native key that replaces + /// it, for what still reads the old one. + pub leave_old_extensions: bool, +} + +/// What [`normalize`] did. +#[derive(Debug, Default)] +pub struct Report { + /// How many keys were rewritten, or removed for a native key that + /// already said it, by the name of the key. + pub migrated: BTreeMap, + /// The keys that say something other than the native key beside them. + pub conflicts: Vec, + /// How many group paths were written again, as the names joined by dots, + /// which is the one value that is not copied as it was. + pub joined: usize, +} + +impl Report { + /// Returns whether the document is different from what it was. + pub fn changed(&self) -> bool { + !self.migrated.is_empty() + } +} + +/// A key that says something other than the native key beside it, which +/// wins. +#[derive(Debug)] +pub struct Conflict { + /// Where the object is, as a JSON Pointer. + pub location: String, + /// The key that lost. + pub key: String, + /// The native key that won. + pub native: &'static str, +} + +/// A document after [`normalize`]. +#[derive(Debug)] +pub struct Normalized { + /// 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()) + } +} + +/// 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) +} + +/// 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 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. +/// +/// 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. +#[doc(hidden)] +pub fn before_reading(json: Vec) -> Result<(Vec, bool), Error> { + if !mentions_old_keys(&json) { + return Ok((json, false)); + } + let options = Options { + leave_old_extensions: true, + }; + 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. +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 report = walk::walk(&mut document, options); + if !report.changed() { + return Ok(Normalized { json: None, report }); + } + Ok(Normalized { + json: Some(write(&document, json)), + report, + }) +} + +/// 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"), + } + if original.ends_with(b"\n") { + out.push(b'\n'); + } + out +} + +/// 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]); + } + } + Some(b"") +} + +#[cfg(test)] +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/vendor/README.md b/src/normalize/vendor/README.md new file mode 100644 index 000000000..1d10725df --- /dev/null +++ b/src/normalize/vendor/README.md @@ -0,0 +1,44 @@ +# 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, 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 + +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..1f6f42ce3 --- /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. +pub(super) 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..c783968a5 --- /dev/null +++ b/src/normalize/vendor/mod.rs @@ -0,0 +1,73 @@ +//! 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 } + } +} + +/// 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]; + +/// 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> { + 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..79f9e3317 --- /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. +pub(super) 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..120589129 --- /dev/null +++ b/src/normalize/vendor/tests.rs @@ -0,0 +1,178 @@ +//! 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; + +#[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 + .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..f3e11cbd9 --- /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`. + pub(in crate::normalize::vendor) 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..f2da5a050 --- /dev/null +++ b/src/normalize/walk.rs @@ -0,0 +1,338 @@ +//! 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. 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}; + +use super::vendor::{self, Key}; +use super::{Conflict, 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 + } + + /// 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; + }; + 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 +/// 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 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. +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 { + options, + report: Report::default(), + pointer: String::new(), + }; + walk.visit(Kind::Document, document); + walk.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<'_> { + /// 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; + }; + 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 => {} + } + 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); + } + } + } + } + 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`, + /// 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 { + 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() { + continue; + } + // 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); + if written.is_some() { + self.report.joined += 1; + } + let action = match (self.options.leave_old_extensions, writes) { + (false, true) => Action::Rename(native, written), + (false, false) => Action::Remove, + (true, true) => Action::Insert(native, written), + (true, false) => continue, + }; + *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. 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 => { + 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); + } + }, + } + } + } +} 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..477d29d1a --- /dev/null +++ b/tests/normalize/common.rs @@ -0,0 +1,145 @@ +//! 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).unwrap()) +} + +/// 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()), + } +} + +/// 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 new file mode 100644 index 000000000..7e45c89d7 --- /dev/null +++ b/tests/normalize/fern.rs @@ -0,0 +1,284 @@ +//! 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, operation_with, 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 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/known_gaps.rs b/tests/normalize/known_gaps.rs new file mode 100644 index 000000000..75ea2e2f7 --- /dev/null +++ b/tests/normalize/known_gaps.rs @@ -0,0 +1,218 @@ +//! 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 limitations" in the README of the package, if it is in it. + +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 { + 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: `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. +#[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}"); +} + +/// 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)); +} + +/// 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. +#[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 new file mode 100644 index 000000000..5780f22eb --- /dev/null +++ b/tests/normalize/main.rs @@ -0,0 +1,22 @@ +//! 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, 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` has no vendor names in it. + +mod common; +#[path = "../wasm/dump.rs"] +mod dump; +mod fern; +mod known_gaps; +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..a9ba20bc7 --- /dev/null +++ b/tests/normalize/normalize.rs @@ -0,0 +1,994 @@ +//! `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, before_reading, normalize}; +use serde_json::{Value, json}; + +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, +}; + +/// 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).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"); +} + +/// 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 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_json_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_json_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_json_eq( + &text, + &operation_with(&format!( + r#""x-forge-group-name": {names}, "x-forge-method-name": "get","# + )), + ); + } +} + +#[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_json_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_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. +#[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_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_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_json_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_json_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).is_ok(), + "{}", + String::from_utf8_lossy(document) + ); + } + } + } +} + +#[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 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()).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() + ); +} + +/// 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().0).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); +} + +/// 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 new file mode 100644 index 000000000..3df77a45d --- /dev/null +++ b/tests/normalize/normalize_cli.rs @@ -0,0 +1,913 @@ +//! `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 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")) + .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 = rewrite(&[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 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 = rewrite(&[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(&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"]; + 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_file_that_is_missing_is_an_error_that_names_it() { + let out = rewrite(&["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 (a, b) = (dir.join("a.json"), dir.join("b.json")); + + let out = rewrite(&[a.to_str().unwrap(), b.to_str().unwrap()], ""); + + 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 = 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}"); +} + +#[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 = rewrite(&[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 = 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}"); + 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_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 = rewrite(&[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 = rewrite(&[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 = rewrite(&[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 = rewrite(&[file.to_str().unwrap(), &again], ""); + + succeeded(&out); + assert!( + text(&out.stderr).contains("1 file: 1 rewritten"), + "{}", + text(&out.stderr) + ); + assert_eq!(fs::read_to_string(&file).unwrap(), NATIVE); + } +} + +/// 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 = rewrite(&[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 = rewrite(&[&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"); + let file = dir.join("spec[1].json"); + fs::write(&file, FERN).unwrap(); + + let out = rewrite(&[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 = rewrite(&[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 = rewrite(&[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 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); + let broken = normalize(&["-"], "{\"x-fern-ignore\": "); + + assert!( + text(&out.stderr).starts_with("stdin: 2 keys rewritten in the output"), + "{}", + 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 = rewrite(&["-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(&rewrite(&[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 = rewrite(&[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 = rewrite(&[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); +} + +/// 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)); +} + +/// 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] +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)); +} diff --git a/tests/normalize/stainless.rs b/tests/normalize/stainless.rs new file mode 100644 index 000000000..0eee599df --- /dev/null +++ b/tests/normalize/stainless.rs @@ -0,0 +1,84 @@ +//! 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, 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_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, }