From 96eb2e72a81dc59465497d8326a38ba24ead0900 Mon Sep 17 00:00:00 2001 From: Peter Tripp Date: Fri, 10 Jul 2026 13:16:42 -0400 Subject: [PATCH] Update OpenAPI spec; add ci check to ensure it's up-to-date --- .github/workflows/test.yml | 2 +- rfd-api-spec.json | 25 ++++++++++---- rfd-api/src/main.rs | 8 +++++ rfd-api/src/server.rs | 64 ++++++++++++++++++++++++++---------- rfd-cli/src/generated/cli.rs | 3 +- rfd-sdk/src/generated/sdk.rs | 14 +++++--- rfd-ts/package.json | 2 +- rfd-ts/src/Api.ts | 2 +- xtask/src/main.rs | 18 ++++++++++ 9 files changed, 106 insertions(+), 32 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a162d644..0feffbbc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -83,7 +83,7 @@ jobs: - name: Verify SDK Generation run: | cargo xtask generate - GENERATED="rfd-sdk/src/generated/ rfd-cli/src/generated/ rfd-ts/" + GENERATED="rfd-api-spec.json rfd-sdk/src/generated/ rfd-cli/src/generated/ rfd-ts/" if ! git diff --quiet -- "$GENERATED"; then git diff -- "$GENERATED" echo "Found changed files after generating SDKs:" diff --git a/rfd-api-spec.json b/rfd-api-spec.json index 64a9eaaa..621bd7aa 100644 --- a/rfd-api-spec.json +++ b/rfd-api-spec.json @@ -5,9 +5,9 @@ "description": "Programmatic access to RFDs", "contact": { "url": "https://oxide.computer", - "email": "augustus@oxidecomputer.com" + "email": "corp-services@oxidecomputer.com" }, - "version": "0.14.0" + "version": "0.14.5" }, "paths": { "/.well-known/jwks.json": { @@ -978,13 +978,24 @@ } ], "responses": { - "default": { - "description": "", - "content": { - "*/*": { - "schema": {} + "307": { + "description": "redirect (temporary redirect)", + "headers": { + "location": { + "description": "HTTP \"Location\" header", + "style": "simple", + "required": true, + "schema": { + "type": "string" + } } } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" } } } diff --git a/rfd-api/src/main.rs b/rfd-api/src/main.rs index 424d20a8..0d44125e 100644 --- a/rfd-api/src/main.rs +++ b/rfd-api/src/main.rs @@ -53,6 +53,14 @@ async fn main() -> anyhow::Result<()> { let mut args = std::env::args(); let _ = args.next(); let config_path = args.next(); + + // Print the OpenAPI document to stdout and exit, without requiring the rest of the server's + // runtime configuration (database, secrets, etc). Used to keep the checked-in + // rfd-api-spec.json up to date via `cargo xtask generate`. + if config_path.as_deref() == Some("describe") { + return server::write_openapi(&mut std::io::stdout()).map_err(|err| anyhow::anyhow!(err)); + } + let param_path = config_path .as_deref() .and_then(|path| Path::new(path).parent()) diff --git a/rfd-api/src/server.rs b/rfd-api/src/server.rs index 70f19e04..b701b68b 100644 --- a/rfd-api/src/server.rs +++ b/rfd-api/src/server.rs @@ -8,7 +8,9 @@ use dropshot::{ use semver::Version; use serde::Deserialize; use slog::Drain; -use std::{collections::HashMap, error::Error, fs::File, net::SocketAddr, path::PathBuf}; +use std::{ + collections::HashMap, error::Error, fs::File, io::Write, net::SocketAddr, path::PathBuf, +}; use tracing_slog::TracingSlogDrain; use v_api::{inject_endpoints, v_system_endpoints}; @@ -43,24 +45,16 @@ pub struct ServerConfig { pub spec_output: Option, } -v_system_endpoints!(RfdContext, RfdPermission); - -pub fn server( - config: ServerConfig, -) -> Result, Box> { - let config_dropshot = ConfigDropshot { - bind_address: config.server_address, - default_request_body_max_bytes: 1024 * 1024, - ..Default::default() - }; +// Static metadata used when generating the OpenAPI document on demand (i.e. via +// `rfd-api describe`), independent of any per-deployment `[spec]` configuration. +const SPEC_TITLE: &str = "RFD API"; +const SPEC_DESCRIPTION: &str = "Programmatic access to RFDs"; +const SPEC_CONTACT_URL: &str = "https://oxide.computer"; +const SPEC_CONTACT_EMAIL: &str = "corp-services@oxidecomputer.com"; - // Construct a shim to pipe dropshot logs into the global tracing logger - let dropshot_logger = { - let level_drain = slog::LevelFilter(TracingSlogDrain, slog::Level::Debug).fuse(); - let async_drain = slog_async::Async::new(level_drain).build().fuse(); - slog::Logger::root(async_drain, slog::o!()) - }; +v_system_endpoints!(RfdContext, RfdPermission); +fn api_description() -> ApiDescription { let mut tags = HashMap::new(); tags.insert( "hidden".to_string(), @@ -132,6 +126,42 @@ pub fn server( api.register(github_webhook) .expect("Failed to register endpoint"); + api +} + +/// Writes the current OpenAPI document to `out`, using static spec metadata. Used by the +/// `describe` CLI command to print the spec independent of any runtime configuration. +pub fn write_openapi(out: &mut W) -> Result<(), Box> { + let api = api_description(); + let mut api_definition = + &mut api.openapi(SPEC_TITLE, Version::parse(env!("CARGO_PKG_VERSION"))?); + api_definition = api_definition + .description(SPEC_DESCRIPTION) + .contact_url(SPEC_CONTACT_URL) + .contact_email(SPEC_CONTACT_EMAIL); + api_definition.write(out)?; + + Ok(()) +} + +pub fn server( + config: ServerConfig, +) -> Result, Box> { + let config_dropshot = ConfigDropshot { + bind_address: config.server_address, + default_request_body_max_bytes: 1024 * 1024, + ..Default::default() + }; + + // Construct a shim to pipe dropshot logs into the global tracing logger + let dropshot_logger = { + let level_drain = slog::LevelFilter(TracingSlogDrain, slog::Level::Debug).fuse(); + let async_drain = slog_async::Async::new(level_drain).build().fuse(); + slog::Logger::root(async_drain, slog::o!()) + }; + + let api = api_description(); + if let Some(spec) = config.spec_output { // TODO: How do we validate that spec_output can be read or written? Currently File::create // panics if the file path is not a valid path diff --git a/rfd-cli/src/generated/cli.rs b/rfd-cli/src/generated/cli.rs index 6b641e7f..1e1fe34f 100644 --- a/rfd-cli/src/generated/cli.rs +++ b/rfd-cli/src/generated/cli.rs @@ -2420,7 +2420,8 @@ impl Cli { todo!() } Err(r) => { - todo!() + self.config.error(&r); + Err(anyhow::Error::new(r)) } } } diff --git a/rfd-sdk/src/generated/sdk.rs b/rfd-sdk/src/generated/sdk.rs index bca53af1..e0e62f6d 100644 --- a/rfd-sdk/src/generated/sdk.rs +++ b/rfd-sdk/src/generated/sdk.rs @@ -14260,7 +14260,7 @@ pub mod types { /// /// Programmatic access to RFDs /// -/// Version: 0.14.0 +/// Version: 0.14.5 pub struct Client { pub(crate) baseurl: String, pub(crate) client: reqwest::Client, @@ -14301,7 +14301,7 @@ impl Client { impl ClientInfo<()> for Client { fn api_version() -> &'static str { - "0.14.0" + "0.14.5" } fn baseurl(&self) -> &str { @@ -17622,7 +17622,7 @@ pub mod builder { } /// Sends a `GET` request to `/login/oauth/{provider}/code/authorize` - pub async fn send(self) -> Result, Error> { + pub async fn send(self) -> Result, Error> { let Self { client, provider, @@ -17674,7 +17674,13 @@ pub mod builder { let response = result?; match response.status().as_u16() { 200..=299 => Ok(ResponseValue::stream(response)), - _ => Err(Error::ErrorResponse(ResponseValue::stream(response))), + 400u16..=499u16 => Err(Error::ErrorResponse( + ResponseValue::from_response(response).await?, + )), + 500u16..=599u16 => Err(Error::ErrorResponse( + ResponseValue::from_response(response).await?, + )), + _ => Err(Error::UnexpectedResponse(response)), } } } diff --git a/rfd-ts/package.json b/rfd-ts/package.json index 097313ef..cbdc9067 100644 --- a/rfd-ts/package.json +++ b/rfd-ts/package.json @@ -70,4 +70,4 @@ } }, "version": "0.14.5" -} \ No newline at end of file +} diff --git a/rfd-ts/src/Api.ts b/rfd-ts/src/Api.ts index 3e1b2e3f..87860029 100644 --- a/rfd-ts/src/Api.ts +++ b/rfd-ts/src/Api.ts @@ -879,7 +879,7 @@ export class Api { * Pulled from info.version in the OpenAPI schema. Sent in the * `api-version` header on all requests. */ - apiVersion = '0.14.0' + apiVersion = '0.14.5' constructor({ host = '', baseParams = {}, token }: ApiConfig = {}) { this.host = host diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 52ce6cc8..2a1937d1 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -148,6 +148,24 @@ fn generate(check: bool, verbose: bool) -> Result<(), String> { let mut spec_path = root_path.clone(); spec_path.push("rfd-api-spec.json"); + // Regenerate the OpenAPI spec from the API server itself, so the SDKs below are always + // derived from the canonical description and the checked-in spec can be checked for drift. + print!("generating spec ... "); + std::io::stdout().flush().unwrap(); + let output = Command::new("cargo") + .args(["run", "--quiet", "-p", "rfd-api", "--", "describe"]) + .current_dir(&root_path) + .output() + .map_err(|err| format!("failed to run rfd-api describe: {}", err))?; + if !output.status.success() { + return Err(format!( + "rfd-api describe failed: {}", + String::from_utf8_lossy(&output.stderr) + )); + } + fs::write(&spec_path, &output.stdout).map_err(|e| e.to_string())?; + println!("done."); + let file = File::open(spec_path).unwrap(); let spec = serde_json::from_reader(file).unwrap(); let mut generator = Generator::new(