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")?);
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()))?;
normalize_openapi_31_to_30(&mut spec);
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}");
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(())
}
fn rustfmt(source: &str) -> Option<String> {
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()
}
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);
}
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(¶m, &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));
}
}
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));
}
}
}
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);
}
}
}