openapp-sdk-common 0.1.55

Shared models and low-level types for the OpenApp SDK (single contract: packages/api-spec/openapi.json).
Documentation
//! Regenerates `openapp-sdk-common`'s `src/generated.rs` from
//! `packages/api-spec/openapi.json` using
//! [`progenitor`](https://github.com/oxidecomputer/progenitor).
//!
//! This is the implementation behind `just sdk::core::openapi-gen`.
//!
//! Usage:
//!
//! ```text
//! # default: write if different
//! cargo run --features openapi-gen --bin openapp-sdk-openapi-gen
//!
//! # CI / pre-commit: fail instead of rewriting when drift is detected
//! cargo run --features openapi-gen --bin openapp-sdk-openapi-gen -- --check
//! ```

use std::{
    collections::HashSet,
    env, fs,
    io::Write,
    path::PathBuf,
    process::{Command, Stdio},
};

use anyhow::{Context, Result};
use serde_json::{Map, Value};

fn main() -> Result<()> {
    let check_mode = env::args().any(|arg| arg == "--check");

    let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR")?);
    // packages/sdk/core/crates/common -> packages/api-spec/openapi.json
    let spec_path = manifest_dir
        .join("..")
        .join("..")
        .join("..")
        .join("..")
        .join("api-spec")
        .join("openapi.json")
        .canonicalize()
        .context("locating packages/api-spec/openapi.json")?;

    let spec_bytes =
        fs::read(&spec_path).with_context(|| format!("reading {}", spec_path.display()))?;
    let mut spec: Value = serde_json::from_slice(&spec_bytes)
        .with_context(|| format!("parsing {} as JSON", spec_path.display()))?;

    // progenitor (and its underlying `openapiv3` crate) only understands OpenAPI 3.0.x.
    // The backend emits 3.1, which uses `"type": ["string", "null"]` to express nullable.
    // Down-convert in-memory before handing the spec to progenitor so we don't have to
    // change the backend or pin progenitor to a 3.1-aware fork.
    normalize_openapi_31_to_30(&mut spec);

    // progenitor accepts `openapiv3::OpenAPI` — parse from Value to avoid a second I/O.
    let spec: openapiv3::OpenAPI = serde_json::from_value(spec)
        .context("OpenAPI spec failed to typecheck against openapiv3::OpenAPI")?;

    let settings = progenitor::GenerationSettings::default();
    let mut generator = progenitor::Generator::new(&settings);
    let tokens = generator
        .generate_tokens(&spec)
        .context("progenitor failed to generate client tokens")?;
    let ast: syn::File = syn::parse2(tokens).context("parsing progenitor token stream")?;
    let body = prettyplease::unparse(&ast);

    let prologue = "// @generated by `just sdk::core::openapi-gen` — DO NOT EDIT.\n\
                    // Source: packages/api-spec/openapi.json\n\n\
                    #![allow(\n    clippy::all,\n    clippy::pedantic,\n    clippy::nursery,\n    \
                    clippy::restriction,\n    clippy::cargo,\n    \
                    dead_code,\n    missing_debug_implementations,\n    \
                    unused_imports,\n    unused_variables,\n    \
                    rust_2018_idioms\n)]\n\n";
    let raw_contents = format!("{prologue}{body}");
    // Normalize through rustfmt so `--check` compares apples-to-apples with the
    // committed file (which is rustfmt'd on write, below).
    let new_contents = rustfmt(&raw_contents).unwrap_or(raw_contents);

    let dest = manifest_dir.join("src").join("generated.rs");
    let current = fs::read_to_string(&dest).unwrap_or_default();

    if check_mode {
        if current != new_contents {
            anyhow::bail!(
                "{} is stale vs packages/api-spec/openapi.json. \
                 Run `just sdk::core::openapi-gen` and commit the result.",
                dest.display()
            );
        }
        println!("openapp-sdk-common generated.rs is up to date.");
        return Ok(());
    }

    if current == new_contents {
        println!("openapp-sdk-common generated.rs already up to date.");
        return Ok(());
    }

    fs::write(&dest, &new_contents).with_context(|| format!("writing {}", dest.display()))?;
    println!("Rewrote {}.", dest.display());
    Ok(())
}

/// Pipe `source` through `rustfmt --edition=2021` on stdin and return the
/// formatted result. `None` if rustfmt isn't on PATH or exits non-zero (in
/// which case the caller should fall back to the unformatted string).
fn rustfmt(source: &str) -> Option<String> {
    // Match the workspace edition so `cargo fmt --check` (which uses the
    // per-crate `edition` from Cargo.toml) and this in-process formatter agree
    // on the normalised output.
    let mut child = Command::new("rustfmt")
        .arg("--edition=2024")
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::null())
        .spawn()
        .ok()?;
    child.stdin.as_mut()?.write_all(source.as_bytes()).ok()?;
    let output = child.wait_with_output().ok()?;
    if !output.status.success() {
        return None;
    }
    String::from_utf8(output.stdout).ok()
}

/// Down-convert an OpenAPI 3.1 document (as emitted by `utoipa`) to the subset
/// `openapiv3` / `progenitor` understand (3.0.3).
///
/// The only 3.1-isms `utoipa` emits today are:
///
/// * the version string itself (`"openapi": "3.1.0"`);
/// * `"type": ["X", "null"]` arrays to express nullable scalars, which 3.0
///   writes as `"type": "X"` + `"nullable": true`.
///
/// When `utoipa` starts emitting other 3.1-only constructs (e.g. numeric
/// `exclusiveMinimum`, `const`, `unevaluatedProperties`) this helper must grow
/// to normalize them — the top-level `main` surfaces a clear parse error in
/// that case so the gap is obvious.
fn normalize_openapi_31_to_30(spec: &mut Value) {
    if let Some(obj) = spec.as_object_mut()
        && let Some(version) = obj.get_mut("openapi")
        && version.as_str() == Some("3.1.0")
    {
        *version = Value::String("3.0.3".to_string());
    }
    rewrite_type_arrays(spec);
    flatten_object_query_params(spec);
}

/// Replace any `in: query` parameter whose schema references an object made of
/// scalar properties with the equivalent set of flat scalar query parameters.
///
/// `utoipa` emits `PaginationQuery` / `OutputOptionsQuery` as a single object
/// query parameter — valid-ish per the OpenAPI spec (`style: form, explode:
/// true`) but not something `progenitor` can codegen because it tries to call
/// `Display` on the object. The backend actually accepts the flat form
/// (`?limit=10&offset=0`), so rewrite the spec to match what the wire already
/// does.
fn flatten_object_query_params(spec: &mut Value) {
    let schemas = spec
        .get("components")
        .and_then(|c| c.get("schemas"))
        .cloned()
        .unwrap_or(Value::Null);

    let Some(paths) = spec.get_mut("paths").and_then(Value::as_object_mut) else {
        return;
    };

    for methods in paths.values_mut() {
        let Some(methods) = methods.as_object_mut() else {
            continue;
        };
        for op in methods.values_mut() {
            let Some(op) = op.as_object_mut() else {
                continue;
            };
            let Some(params) = op.get_mut("parameters").and_then(Value::as_array_mut) else {
                continue;
            };

            let mut expanded: Vec<Value> = Vec::with_capacity(params.len());
            for param in params.drain(..) {
                if let Some(flat) = expand_object_query_param(&param, &schemas) {
                    expanded.extend(flat);
                } else {
                    expanded.push(param);
                }
            }
            *params = expanded;
        }
    }
}

fn expand_object_query_param(param: &Value, schemas: &Value) -> Option<Vec<Value>> {
    let obj = param.as_object()?;
    if obj.get("in").and_then(Value::as_str) != Some("query") {
        return None;
    }
    let schema = obj.get("schema")?.as_object()?;
    let reference = schema.get("$ref").and_then(Value::as_str)?;
    let schema_name = reference.rsplit('/').next()?;
    let target_obj = schemas.get(schema_name)?.as_object()?;
    if target_obj.get("type").and_then(Value::as_str) != Some("object") {
        return None;
    }
    let properties = target_obj.get("properties")?.as_object()?;
    let required: HashSet<&str> = target_obj
        .get("required")
        .and_then(Value::as_array)
        .map(|arr| arr.iter().filter_map(Value::as_str).collect())
        .unwrap_or_default();

    let mut flat = Vec::with_capacity(properties.len());
    for (name, prop_schema) in properties {
        let mut entry = Map::new();
        entry.insert("name".to_string(), Value::String(name.clone()));
        entry.insert("in".to_string(), Value::String("query".to_string()));
        entry.insert(
            "required".to_string(),
            Value::Bool(required.contains(name.as_str())),
        );
        if let Some(desc) = prop_schema.get("description") {
            entry.insert("description".to_string(), desc.clone());
        }
        entry.insert("schema".to_string(), prop_schema.clone());
        flat.push(Value::Object(entry));
    }
    Some(flat)
}

fn rewrite_type_arrays(node: &mut Value) {
    if let Value::Object(map) = node {
        if let Some(Value::Array(arr)) = map.get("type").cloned() {
            let strings: Vec<&str> = arr.iter().filter_map(Value::as_str).collect();
            let has_null = strings.contains(&"null");
            let concrete: Vec<&str> = strings.iter().copied().filter(|s| *s != "null").collect();
            if has_null && concrete.len() == 1 {
                map.insert("type".to_string(), Value::String(concrete[0].to_string()));
                map.insert("nullable".to_string(), Value::Bool(true));
            }
        }

        // 3.1 expresses "nullable X" inside `oneOf` / `anyOf` / `allOf` as an extra
        // `{"type": "null"}` entry. 3.0 has no standalone null type — strip those
        // entries and lift `nullable: true` onto the parent.
        for combiner in ["oneOf", "anyOf", "allOf"] {
            if let Some(Value::Array(list)) = map.get_mut(combiner) {
                let before = list.len();
                list.retain(|v| v.get("type").and_then(Value::as_str) != Some("null"));
                if list.len() != before {
                    map.insert("nullable".to_string(), Value::Bool(true));
                }
                // If the combiner is now a single-entry list with just a `$ref`,
                // 3.0 requires wrapping refs with siblings in `allOf` — and
                // `progenitor` is happy with that form, so leave it as-is.
            }
        }

        for v in map.values_mut() {
            rewrite_type_arrays(v);
        }
    } else if let Value::Array(items) = node {
        for v in items {
            rewrite_type_arrays(v);
        }
    }
}