use std::env;
use std::path::PathBuf;
use schemars::schema::{InstanceType, SchemaObject};
use serde_json::Value;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let spec_json = fetch_spec().unwrap_or_else(|| {
println!("cargo::warning=Use cached `openapi.json` file.");
include_str!("openapi.json").to_string()
});
println!("cargo:rerun-if-changed=openapi.json");
let mut spec: Value = serde_json::from_str(&spec_json)?;
if let Some(openapi) = spec.get_mut("openapi") {
*openapi = Value::String("3.0.0".to_string());
}
convert_nullable_types(&mut spec);
fix_tuple_schemas(&mut spec);
let mut settings = progenitor::GenerationSettings::default();
settings.with_conversion(
SchemaObject {
instance_type: Some(InstanceType::String.into()),
format: Some("decimal".to_string()),
..Default::default()
},
"rust_decimal::Decimal",
[progenitor::TypeImpl::Display, progenitor::TypeImpl::FromStr].into_iter(),
);
let mut generator = progenitor::Generator::new(&settings);
ensure_error_responses(&mut spec);
let spec: openapiv3::OpenAPI = serde_json::from_value(spec.clone()).map_err(|e| {
let _ = std::fs::write("openapi-debug.json", serde_json::to_string_pretty(&spec).unwrap());
format!("Failed to parse OpenAPI spec: {e}. Saved debug output to openapi-debug.json")
})?;
let tokens = generator.generate_tokens(&spec)?;
let ast = syn::parse2(tokens)?;
let content = prettyplease::unparse(&ast);
let out_path = PathBuf::from(env::var("OUT_DIR").unwrap()).join("codegen.rs");
std::fs::write(&out_path, &content)?;
println!("cargo:codegen_path={}", out_path.display());
Ok(())
}
fn fix_tuple_schemas(v: &mut Value) {
match v {
Value::Object(map) => {
if let Some(Value::Bool(false)) = map.get("items")
&& let Some(_prefix_items) = map.remove("prefixItems")
{
map.insert("items".to_string(), serde_json::json!({"type": "string"}));
}
for val in map.values_mut() {
fix_tuple_schemas(val);
}
}
Value::Array(arr) => {
for val in arr.iter_mut() {
fix_tuple_schemas(val);
}
}
_ => {}
}
}
fn convert_nullable_types(v: &mut Value) {
match v {
Value::Object(map) => {
if let Some(Value::Array(types)) = map.get_mut("type") {
if types.len() == 2 {
let has_null = types.iter().any(|t| t.as_str() == Some("null"));
if has_null {
let actual_type =
types.iter().find(|t| t.as_str() != Some("null")).cloned();
if let Some(t) = actual_type {
map.insert("type".to_string(), t);
map.insert("nullable".to_string(), Value::Bool(true));
}
}
}
}
if let Some(Value::Array(types)) = map.get_mut("oneOf") {
let has_null = types
.iter_mut()
.any(|t| t.get("type").and_then(|x| x.as_str()) == Some("null"));
if has_null {
types.retain(|t| t.get("type").and_then(|x| x.as_str()) != Some("null"));
map.insert("nullable".to_string(), Value::Bool(true));
}
}
for val in map.values_mut() {
convert_nullable_types(val);
}
}
Value::Array(arr) => {
for val in arr.iter_mut() {
convert_nullable_types(val);
}
}
_ => {}
}
}
fn ensure_error_responses(spec: &mut Value) {
let has_schema = spec
.get("components")
.and_then(|c| c.get("schemas"))
.and_then(|s| s.get("ApiErrorResponse"))
.is_some();
if !has_schema {
let error_schema = serde_json::json!({
"type": "object",
"required": ["status", "message"],
"properties": {
"status": {
"type": "integer",
"format": "uint16",
"description": "HTTP status code"
},
"message": {
"type": "string",
"description": "Human-readable error message"
},
"details": {
"description": "Optional structured error details",
"nullable": true
}
}
});
if let Some(components) = spec.get_mut("components").and_then(|c| c.as_object_mut())
&& let Some(schemas) = components.get_mut("schemas").and_then(|s| s.as_object_mut())
{
schemas.insert("ApiErrorResponse".to_string(), error_schema);
}
}
let error_response = serde_json::json!({
"description": "Error response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorResponse"
}
}
}
});
if let Some(paths) = spec.get_mut("paths").and_then(|p| p.as_object_mut()) {
for path_item in paths.values_mut() {
if let Some(path_obj) = path_item.as_object_mut() {
for operation in path_obj.values_mut() {
if let Some(operation_obj) = operation.as_object_mut()
&& let Some(responses) =
operation_obj.get_mut("responses").and_then(|r| r.as_object_mut())
{
let has_error_responses = responses.iter().any(|(code, resp)| {
code != "200"
&& resp
.pointer("/content/application~1json/schema/$ref")
.and_then(|v| v.as_str())
.is_some_and(|r| r.ends_with("/ApiErrorResponse"))
});
if !has_error_responses {
responses.retain(|status_code, _| status_code == "200");
responses.insert("4XX".to_string(), error_response.clone());
responses.insert("5XX".to_string(), error_response.clone());
}
}
}
}
}
}
}
fn fetch_spec() -> Option<String> {
println!("cargo:rerun-if-env-changed=CARGO_NET_OFFLINE");
if std::env::var("CARGO_NET_OFFLINE").is_ok() {
return None;
}
println!("cargo:rerun-if-env-changed=BULLET_API_ENDPOINT");
let endpoint = std::env::var("BULLET_API_ENDPOINT")
.unwrap_or_else(|_| "https://tradingapi.bullet.xyz".to_string());
let url = endpoint + "/docs/rest/openapi.json";
let response = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(5))
.build()
.ok()?
.get(&url)
.send()
.ok()?;
if response.status().is_success() {
return response.text().ok();
} else {
println!("cargo::warning=Spec fetch at '{url}' failed with: {}", response.status());
}
None
}