cli/build.rs
Rama master · réplica de lectura del repositorio original.
//! Build script that generates API client code from the Hook0 OpenAPI specification.
//!
//! This script loads the OpenAPI spec at build time and generates Rust code
//! for API method implementations. The generated code is then included in the
//! api/generated.rs module.
//!
//! Spec sources, in order: a local `openapi.json` override, the API snapshot
//! committed in the monorepo, then a download from production. The snapshot
//! comes first so a workspace build is hermetic: a slow or truncated download
//! used to yield an empty spec and fail the CLI tests. The download only serves
//! a crate built outside the monorepo, where the snapshot is absent.
//!
//! The snapshot is the public client surface (operations tagged `sdk` or `mcp`),
//! not the whole served document, so `API_ENDPOINTS` is shorter than from a
//! download. A spec file that exists but cannot be read or parsed fails the
//! build. Only existing sources get `rerun-if-changed`: a declared but missing
//! path makes Cargo rerun this script on every build. After adding
//! `openapi.json`, `touch build.rs` once.
use heck::ToSnakeCase;
use serde::Deserialize;
use std::collections::HashMap;
use std::env;
use std::fs;
use std::path::Path;
const OPENAPI_URL: &str = "https://app.hook0.com/api/v1/swagger.json";
const OPENAPI_FALLBACK_PATH: &str = "openapi.json";
const OPENAPI_SNAPSHOT_PATH: &str =
concat!(env!("CARGO_MANIFEST_DIR"), "/../api/openapi.snapshot.json");
#[derive(Debug, Deserialize)]
struct OpenApiSpec {
paths: HashMap<String, PathItem>,
// Note: components field is parsed but unused - kept for future schema generation
#[serde(default)]
#[allow(dead_code)]
components: Components,
}
#[derive(Debug, Deserialize, Default)]
#[allow(dead_code)]
struct Components {
#[serde(default)]
schemas: HashMap<String, serde_json::Value>,
}
#[derive(Debug, Deserialize)]
struct PathItem {
#[serde(default)]
get: Option<Operation>,
#[serde(default)]
post: Option<Operation>,
#[serde(default)]
put: Option<Operation>,
#[serde(default)]
delete: Option<Operation>,
}
#[derive(Debug, Deserialize)]
struct Operation {
#[serde(rename = "operationId")]
operation_id: Option<String>,
#[serde(default)]
summary: Option<String>,
#[serde(default)]
description: Option<String>,
#[serde(default)]
parameters: Vec<Parameter>,
#[serde(default, rename = "requestBody")]
request_body: Option<RequestBody>,
#[serde(default)]
responses: HashMap<String, serde_json::Value>,
// Note: tags field is parsed but unused - kept for future filtering
#[serde(default)]
#[allow(dead_code)]
tags: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct Parameter {
name: String,
#[serde(rename = "in")]
location: String,
#[serde(default)]
required: bool,
#[serde(default)]
schema: Option<Schema>,
}
#[derive(Debug, Deserialize)]
struct Schema {
#[serde(rename = "type")]
schema_type: Option<String>,
#[serde(default)]
format: Option<String>,
}
#[derive(Debug, Deserialize)]
struct RequestBody {
#[serde(default)]
content: HashMap<String, MediaType>,
}
#[derive(Debug, Deserialize)]
struct MediaType {
#[serde(default)]
schema: Option<SchemaRef>,
}
#[derive(Debug, Deserialize)]
struct SchemaRef {
#[serde(rename = "$ref")]
reference: Option<String>,
}
/// Represents a parsed API operation ready for code generation
#[derive(Debug)]
#[allow(dead_code)]
struct ApiMethod {
name: String,
http_method: String,
path: String,
summary: String,
// Following fields are parsed for future full client generation
path_params: Vec<(String, String)>, // (name, rust_type)
query_params: Vec<(String, String, bool)>, // (name, rust_type, required)
request_body_type: Option<String>,
response_type: String,
returns_optional: bool,
}
fn main() {
println!("cargo:rerun-if-changed=build.rs");
for path in [OPENAPI_FALLBACK_PATH, OPENAPI_SNAPSHOT_PATH] {
if Path::new(path).exists() {
println!("cargo:rerun-if-changed={path}");
}
}
// Try to load OpenAPI spec
let spec = load_openapi_spec();
// Generate code
let generated_code = generate_api_info(&spec);
// Write to OUT_DIR
let out_dir = env::var("OUT_DIR").expect("OUT_DIR not set");
let dest_path = Path::new(&out_dir).join("openapi_info.rs");
fs::write(&dest_path, generated_code).expect("Failed to write generated code");
}
fn load_openapi_spec() -> OpenApiSpec {
if let Some(spec) = read_spec_file(OPENAPI_FALLBACK_PATH) {
println!("cargo:warning=Using local OpenAPI spec from {OPENAPI_FALLBACK_PATH}");
spec
} else if let Some(spec) = read_spec_file(OPENAPI_SNAPSHOT_PATH) {
spec
} else {
match download_spec() {
Ok(spec) => spec,
Err(e) => {
println!("cargo:warning=Failed to download OpenAPI spec: {e}. Using empty spec.");
OpenApiSpec {
paths: HashMap::new(),
components: Components::default(),
}
}
}
}
}
/// Reads and parses one spec file. A missing file is simply not a source; a
/// file that exists but cannot be read or parsed fails the build, so a broken
/// override or snapshot is never papered over by the next source.
fn read_spec_file(path: &str) -> Option<OpenApiSpec> {
match fs::read_to_string(path) {
Ok(content) => Some(
serde_json::from_str(&content)
.unwrap_or_else(|e| panic!("OpenAPI spec at {path} does not parse: {e}")),
),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
Err(e) => panic!("OpenAPI spec at {path} cannot be read: {e}"),
}
}
fn download_spec() -> Result<OpenApiSpec, String> {
let client = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.map_err(|e| e.to_string())?;
let response = client.get(OPENAPI_URL).send().map_err(|e| e.to_string())?;
if !response.status().is_success() {
return Err(format!("HTTP {}", response.status()));
}
let text = response.text().map_err(|e| e.to_string())?;
serde_json::from_str(&text).map_err(|e| e.to_string())
}
fn generate_api_info(spec: &OpenApiSpec) -> String {
let methods = extract_api_methods(spec);
let mut code = String::new();
// Generate API info struct
code.push_str("/// Auto-generated API information from OpenAPI spec.\n");
code.push_str("/// This is informational and used for validation/documentation.\n");
code.push_str("#[allow(dead_code)]\n");
code.push_str("pub struct OpenApiInfo {\n");
code.push_str(" pub endpoints: &'static [EndpointInfo],\n");
code.push_str("}\n\n");
code.push_str("#[allow(dead_code)]\n");
code.push_str("#[derive(Debug)]\n");
code.push_str("pub struct EndpointInfo {\n");
code.push_str(" pub name: &'static str,\n");
code.push_str(" pub method: &'static str,\n");
code.push_str(" pub path: &'static str,\n");
code.push_str(" pub summary: &'static str,\n");
code.push_str("}\n\n");
// Generate endpoint list
code.push_str("#[allow(dead_code)]\n");
code.push_str("pub const API_ENDPOINTS: &[EndpointInfo] = &[\n");
for method in &methods {
code.push_str(&format!(
" EndpointInfo {{ name: \"{}\", method: \"{}\", path: \"{}\", summary: \"{}\" }},\n",
method.name,
method.http_method,
method.path,
method
.summary
.replace('\\', "\\\\")
.replace('\"', "\\\"")
.replace('\n', " ")
));
}
code.push_str("];\n\n");
// Generate OpenAPI info instance
code.push_str("#[allow(dead_code)]\n");
code.push_str("pub const OPENAPI_INFO: OpenApiInfo = OpenApiInfo {\n");
code.push_str(" endpoints: API_ENDPOINTS,\n");
code.push_str("};\n\n");
// Generate method existence constants for compile-time validation
code.push_str("/// Compile-time constants for endpoint validation\n");
code.push_str("pub mod endpoints {\n");
for method in &methods {
let const_name = method.name.to_uppercase();
code.push_str(&format!(
" pub const {}: &str = \"{}\";\n",
const_name, method.path
));
}
code.push_str("}\n\n");
// Generate a helper function to get endpoint by name
code.push_str("#[allow(dead_code)]\n");
code.push_str("pub fn get_endpoint(name: &str) -> Option<&'static EndpointInfo> {\n");
code.push_str(" API_ENDPOINTS.iter().find(|e| e.name == name)\n");
code.push_str("}\n");
code
}
fn extract_api_methods(spec: &OpenApiSpec) -> Vec<ApiMethod> {
let mut methods = Vec::new();
for (path, item) in &spec.paths {
if let Some(op) = &item.get
&& let Some(method) = parse_operation("GET", path, op)
{
methods.push(method);
}
if let Some(op) = &item.post
&& let Some(method) = parse_operation("POST", path, op)
{
methods.push(method);
}
if let Some(op) = &item.put
&& let Some(method) = parse_operation("PUT", path, op)
{
methods.push(method);
}
if let Some(op) = &item.delete
&& let Some(method) = parse_operation("DELETE", path, op)
{
methods.push(method);
}
}
// Sort by name for consistent output
methods.sort_by(|a, b| a.name.cmp(&b.name));
methods
}
fn parse_operation(http_method: &str, path: &str, op: &Operation) -> Option<ApiMethod> {
let operation_id = op.operation_id.as_ref()?;
// Convert operationId to snake_case function name
let name = operation_id.to_snake_case();
// Extract path parameters
let path_params: Vec<(String, String)> = op
.parameters
.iter()
.filter(|p| p.location == "path")
.map(|p| {
let rust_type = schema_to_rust_type(p.schema.as_ref());
(p.name.clone(), rust_type)
})
.collect();
// Extract query parameters
let query_params: Vec<(String, String, bool)> = op
.parameters
.iter()
.filter(|p| p.location == "query")
.map(|p| {
let rust_type = schema_to_rust_type(p.schema.as_ref());
(p.name.clone(), rust_type, p.required)
})
.collect();
// Extract request body type
let request_body_type = op.request_body.as_ref().and_then(|rb| {
rb.content
.get("application/json")
.and_then(|mt| mt.schema.as_ref())
.and_then(|s| s.reference.as_ref())
.map(|r| r.replace("#/components/schemas/", ""))
});
// Determine response type
let response_type = determine_response_type(&op.responses);
let returns_optional = op.responses.contains_key("404");
let summary = op
.summary
.clone()
.or_else(|| op.description.clone())
.unwrap_or_default();
Some(ApiMethod {
name,
http_method: http_method.to_string(),
path: path.to_string(),
summary,
path_params,
query_params,
request_body_type,
response_type,
returns_optional,
})
}
fn schema_to_rust_type(schema: Option<&Schema>) -> String {
match schema {
Some(s) => match (s.schema_type.as_deref(), s.format.as_deref()) {
(Some("string"), Some("uuid")) => "Uuid".to_string(),
(Some("string"), Some("date-time")) => "DateTime<Utc>".to_string(),
(Some("string"), _) => "String".to_string(),
(Some("integer"), _) => "i64".to_string(),
(Some("boolean"), _) => "bool".to_string(),
(Some("array"), _) => "Vec<serde_json::Value>".to_string(),
(Some("object"), _) => "serde_json::Value".to_string(),
_ => "String".to_string(),
},
None => "String".to_string(),
}
}
fn determine_response_type(responses: &HashMap<String, serde_json::Value>) -> String {
// Check 200 or 201 response for type
for status in ["200", "201"] {
if let Some(response) = responses.get(status)
&& let Some(content) = response.get("content")
&& let Some(json) = content.get("application/json")
&& let Some(schema) = json.get("schema")
{
if let Some(ref_path) = schema.get("$ref")
&& let Some(ref_str) = ref_path.as_str()
{
return ref_str.replace("#/components/schemas/", "");
}
if let Some("array") = schema.get("type").and_then(|t| t.as_str())
&& let Some(items) = schema.get("items")
&& let Some(ref_path) = items.get("$ref")
&& let Some(ref_str) = ref_path.as_str()
{
return format!("Vec<{}>", ref_str.replace("#/components/schemas/", ""));
}
}
}
// 204 No Content
if responses.contains_key("204") {
return "()".to_string();
}
"serde_json::Value".to_string()
}