From 76b642275ac3cca2a8a3f169ddda10e113b8af6a Mon Sep 17 00:00:00 2001 From: Dimitri Mitropoulos Date: Fri, 13 Nov 2026 10:00:11 -0500 Subject: [PATCH 1/2] feat: add forge methods, which prints the methods a document makes forge methods prints each method's group path and name, in kebab-case, with its HTTP method, path, hidden, deprecated and availability, as JSON. Other tools can compare the methods of two versions of a document without naming operations themselves, so they agree with forge on x-forge-group-name, x-forge-method-name and the operationId fallback. --- packages/forge/README.md | 34 ++++++++++++++++ src/bin/forge/main.rs | 18 ++++++++- src/bin/forge/methods.rs | 39 ++++++++++++++++++ tests/cli.rs | 86 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 175 insertions(+), 2 deletions(-) create mode 100644 src/bin/forge/methods.rs diff --git a/packages/forge/README.md b/packages/forge/README.md index a4264d028..71e5c9ed6 100644 --- a/packages/forge/README.md +++ b/packages/forge/README.md @@ -85,6 +85,40 @@ has a key to rewrite is not a minimal edit. The order of its keys is kept. +## Listing methods + +`forge methods` prints the methods a document makes, as a JSON array, without +generating anything. Each method has the group path and name forge gives it, +in kebab-case, which is how forge reads `x-forge-group-name`, +`x-forge-method-name` and `operationId`, so a tool can compare the methods of +two versions of a document without naming them itself. + +```sh +npx forge methods openapi.json + +# Other tools' extensions are read only once they are normalized. +npx forge normalize openapi.json | npx forge methods - +``` + +```json +[ + { + "group": ["dns", "records"], + "name": "list", + "method": "GET", + "path": "/zones/{zone_id}/dns_records", + "hidden": false, + "deprecated": false, + "availability": "generally-available" + } +] +``` + +Methods come in the order of their groups, depth first from the root, and by +name within a group. `availability` is `null` when the operation does not say. +A document forge cannot read, such as one with two methods of the same name in +a group, is an error, as it is for generating an SDK. + ## Plugins A plugin generates an SDK, or anything else, from the document: forge parses diff --git a/src/bin/forge/main.rs b/src/bin/forge/main.rs index 98df6de29..0d3be1e63 100644 --- a/src/bin/forge/main.rs +++ b/src/bin/forge/main.rs @@ -6,6 +6,7 @@ //! forge openapi.json --target mcp --out tools //! forge openapi.json --target wasm://plugin.wasm --out sdk //! forge normalize specs/*.json +//! forge methods openapi.json //! ``` use std::fs; @@ -19,6 +20,7 @@ use strum::{EnumString, VariantNames}; mod capnweb; mod mcp; +mod methods; mod normalize; mod ts; @@ -52,6 +54,13 @@ enum Command { /// 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. Normalize(normalize::Args), + /// Print the methods a document makes, as JSON. + /// + /// Each method has its group path and name, in kebab-case, as forge + /// resolves them from x-forge-group-name, x-forge-method-name and the + /// operationId, with its HTTP method and path. Other tools' extensions + /// are not read: run `forge normalize` on the document first. + Methods(methods::Args), } /// What forge generates an SDK with. @@ -107,8 +116,10 @@ fn main() -> ExitCode { /// Does what `args` ask for. fn run(args: Args) -> Result<(), Err> { - if let Some(Command::Normalize(args)) = args.command { - return normalize::run(args); + match args.command { + Some(Command::Normalize(args)) => return normalize::run(args), + Some(Command::Methods(args)) => return methods::run(args), + None => {} } // 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 { @@ -174,6 +185,9 @@ mod tests { let command = parse(&["normalize", "--check", "a.json"]).unwrap(); assert!(matches!(command.command, Some(Command::Normalize(_))) && command.spec.is_none()); + let command = parse(&["methods", "a.json"]).unwrap(); + assert!(matches!(command.command, Some(Command::Methods(_))) && 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"))); diff --git a/src/bin/forge/methods.rs b/src/bin/forge/methods.rs new file mode 100644 index 000000000..a064667ec --- /dev/null +++ b/src/bin/forge/methods.rs @@ -0,0 +1,39 @@ +//! `forge methods`, which prints the methods a document makes. + +use std::io::{self, Write}; +use std::path::PathBuf; + +use forge::{Err, Forge, Method, Name}; +use serde_json::{Value, json}; + +#[derive(clap::Args)] +pub struct Args { + /// The OpenAPI JSON document, or - to read it from standard input. + spec: PathBuf, +} + +pub fn run(args: Args) -> Result<(), Err> { + let forge = super::read(&args.spec)?; + let mut out = io::stdout().lock(); + serde_json::to_writer_pretty(&mut out, &methods(&forge))?; + writeln!(out)?; + Ok(()) +} + +/// Returns every method of `forge` as JSON, in the order of +/// [`Forge::groups`], and by name within a group. +fn methods(forge: &Forge) -> Value { + forge.groups().flat_map(|group| group.methods()).map(method).collect() +} + +fn method(method: Method<'_>) -> Value { + json!({ + "group": method.group().path().map(Name::kebab).collect::>(), + "name": method.name().kebab(), + "method": method.http_method().as_str(), + "path": method.path(), + "hidden": method.is_hidden(), + "deprecated": method.is_deprecated(), + "availability": method.availability().map(|availability| availability.as_str()), + }) +} diff --git a/tests/cli.rs b/tests/cli.rs index 8c77984b3..cb2050036 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -104,3 +104,89 @@ fn capnweb() { ); std::fs::remove_dir_all(&out).unwrap(); } + +#[test] +fn methods() { + let spec = r#"{ + "openapi": "3.0.3", + "info": {"title": "t", "version": "1"}, + "paths": { + "/zones/{zone_id}/dns_records": { + "get": { + "x-forge-group-name": "dns.records", + "x-forge-method-name": "listRecords", + "x-forge-availability": "beta", + "responses": {"200": {"description": "ok"}} + }, + "post": { + "operationId": "dns.records.create", + "x-forge-hidden": true, + "deprecated": true, + "responses": {"200": {"description": "ok"}} + }, + "put": { + "operationId": "dns.records.skipped", + "x-forge-skip": true, + "responses": {"200": {"description": "ok"}} + } + }, + "/user": { + "get": { + "operationId": "getUser", + "responses": {"200": {"description": "ok"}} + } + } + } + }"#; + let out = forge(&["methods", "-"], spec); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + let methods: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + assert_eq!( + methods, + serde_json::json!([ + { + "group": [], + "name": "get-user", + "method": "GET", + "path": "/user", + "hidden": false, + "deprecated": false, + "availability": null + }, + { + "group": ["dns", "records"], + "name": "create", + "method": "POST", + "path": "/zones/{zone_id}/dns_records", + "hidden": true, + "deprecated": true, + "availability": null + }, + { + "group": ["dns", "records"], + "name": "list-records", + "method": "GET", + "path": "/zones/{zone_id}/dns_records", + "hidden": false, + "deprecated": false, + "availability": "beta" + } + ]) + ); +} + +#[test] +fn methods_of_an_invalid_document() { + let spec = r#"{ + "openapi": "3.0.3", + "info": {"title": "t", "version": "1"}, + "paths": { + "/a": { + "get": {"operationId": "a.get", "responses": {"200": {"description": "ok"}}}, + "post": {"operationId": "a.get", "responses": {"200": {"description": "ok"}}} + } + } + }"#; + let err = failure(forge(&["methods", "-"], spec)); + assert!(err.contains("in the same group"), "{err}"); +} From fe49ddbab57466cb5e61b9f427f33f17e4f79b3e Mon Sep 17 00:00:00 2001 From: Dimitri Mitropoulos Date: Fri, 13 Nov 2026 20:01:03 -0500 Subject: [PATCH 2/2] feat: move forge methods under forge inspect forge inspect prints what forge reads in a document, as JSON, with methods as its first subcommand, so groups, models and the rest can follow under the same command. --- packages/forge/README.md | 13 +++++----- src/bin/forge/{methods.rs => inspect.rs} | 30 +++++++++++++++++++++--- src/bin/forge/main.rs | 20 +++++++--------- tests/cli.rs | 8 +++---- 4 files changed, 46 insertions(+), 25 deletions(-) rename src/bin/forge/{methods.rs => inspect.rs} (54%) diff --git a/packages/forge/README.md b/packages/forge/README.md index 71e5c9ed6..850f450b3 100644 --- a/packages/forge/README.md +++ b/packages/forge/README.md @@ -85,19 +85,20 @@ has a key to rewrite is not a minimal edit. The order of its keys is kept. -## Listing methods +## Inspecting a document -`forge methods` prints the methods a document makes, as a JSON array, without -generating anything. Each method has the group path and name forge gives it, -in kebab-case, which is how forge reads `x-forge-group-name`, +`forge inspect` prints what forge reads in a document, as JSON, without +generating anything. `forge inspect methods` prints the methods a document +makes, as an array. Each method has the group path and name forge gives it, in +kebab-case, which is how forge reads `x-forge-group-name`, `x-forge-method-name` and `operationId`, so a tool can compare the methods of two versions of a document without naming them itself. ```sh -npx forge methods openapi.json +npx forge inspect methods openapi.json # Other tools' extensions are read only once they are normalized. -npx forge normalize openapi.json | npx forge methods - +npx forge normalize openapi.json | npx forge inspect methods - ``` ```json diff --git a/src/bin/forge/methods.rs b/src/bin/forge/inspect.rs similarity index 54% rename from src/bin/forge/methods.rs rename to src/bin/forge/inspect.rs index a064667ec..c255ff689 100644 --- a/src/bin/forge/methods.rs +++ b/src/bin/forge/inspect.rs @@ -1,4 +1,4 @@ -//! `forge methods`, which prints the methods a document makes. +//! `forge inspect`, which prints what forge reads in a document. use std::io::{self, Write}; use std::path::PathBuf; @@ -8,14 +8,38 @@ use serde_json::{Value, json}; #[derive(clap::Args)] pub struct Args { + #[command(subcommand)] + command: Command, +} + +/// What `forge inspect` prints. +#[derive(clap::Subcommand)] +enum Command { + /// Print the methods a document makes, as JSON. + /// + /// Each method has its group path and name, in kebab-case, as forge + /// resolves them from x-forge-group-name, x-forge-method-name and the + /// operationId, with its HTTP method and path. Other tools' extensions + /// are not read: run `forge normalize` on the document first. + Methods(Spec), +} + +#[derive(clap::Args)] +struct Spec { /// The OpenAPI JSON document, or - to read it from standard input. spec: PathBuf, } pub fn run(args: Args) -> Result<(), Err> { - let forge = super::read(&args.spec)?; + match args.command { + Command::Methods(Spec { spec }) => print(&methods(&super::read(&spec)?)), + } +} + +/// Prints `value` to standard output. +fn print(value: &Value) -> Result<(), Err> { let mut out = io::stdout().lock(); - serde_json::to_writer_pretty(&mut out, &methods(&forge))?; + serde_json::to_writer_pretty(&mut out, value)?; writeln!(out)?; Ok(()) } diff --git a/src/bin/forge/main.rs b/src/bin/forge/main.rs index 0d3be1e63..9295329be 100644 --- a/src/bin/forge/main.rs +++ b/src/bin/forge/main.rs @@ -6,7 +6,7 @@ //! forge openapi.json --target mcp --out tools //! forge openapi.json --target wasm://plugin.wasm --out sdk //! forge normalize specs/*.json -//! forge methods openapi.json +//! forge inspect methods openapi.json //! ``` use std::fs; @@ -19,8 +19,8 @@ use forge::{Err, Forge, Plugin}; use strum::{EnumString, VariantNames}; mod capnweb; +mod inspect; mod mcp; -mod methods; mod normalize; mod ts; @@ -54,13 +54,8 @@ enum Command { /// 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. Normalize(normalize::Args), - /// Print the methods a document makes, as JSON. - /// - /// Each method has its group path and name, in kebab-case, as forge - /// resolves them from x-forge-group-name, x-forge-method-name and the - /// operationId, with its HTTP method and path. Other tools' extensions - /// are not read: run `forge normalize` on the document first. - Methods(methods::Args), + /// Print what forge reads in a document, as JSON. + Inspect(inspect::Args), } /// What forge generates an SDK with. @@ -118,7 +113,7 @@ fn main() -> ExitCode { fn run(args: Args) -> Result<(), Err> { match args.command { Some(Command::Normalize(args)) => return normalize::run(args), - Some(Command::Methods(args)) => return methods::run(args), + Some(Command::Inspect(args)) => return inspect::run(args), None => {} } // They are required, which a command is the only way around. @@ -185,8 +180,9 @@ mod tests { let command = parse(&["normalize", "--check", "a.json"]).unwrap(); assert!(matches!(command.command, Some(Command::Normalize(_))) && command.spec.is_none()); - let command = parse(&["methods", "a.json"]).unwrap(); - assert!(matches!(command.command, Some(Command::Methods(_))) && command.spec.is_none()); + let command = parse(&["inspect", "methods", "a.json"]).unwrap(); + assert!(matches!(command.command, Some(Command::Inspect(_))) && command.spec.is_none()); + assert!(parse(&["inspect", "a.json"]).is_err()); let document = parse(&["./normalize", "--target", "ts", "--out", "sdk"]).unwrap(); assert!(document.command.is_none() && document.spec.as_deref() == Some(Path::new("./normalize"))); diff --git a/tests/cli.rs b/tests/cli.rs index cb2050036..4c7dad1ef 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -106,7 +106,7 @@ fn capnweb() { } #[test] -fn methods() { +fn inspect_methods() { let spec = r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, @@ -138,7 +138,7 @@ fn methods() { } } }"#; - let out = forge(&["methods", "-"], spec); + let out = forge(&["inspect", "methods", "-"], spec); assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); let methods: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); assert_eq!( @@ -176,7 +176,7 @@ fn methods() { } #[test] -fn methods_of_an_invalid_document() { +fn inspect_methods_of_an_invalid_document() { let spec = r#"{ "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, @@ -187,6 +187,6 @@ fn methods_of_an_invalid_document() { } } }"#; - let err = failure(forge(&["methods", "-"], spec)); + let err = failure(forge(&["inspect", "methods", "-"], spec)); assert!(err.contains("in the same group"), "{err}"); }