use std::{
env,
fmt::Write as FmtWrite,
fs,
path::{Path, PathBuf},
};
fn main() {
let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap());
let conjure_json = find_api_crate_root("nominal-api-conjure", "definitions/conjure")
.join("definitions/conjure/scout-service-api.conjure.json");
let proto_root = find_api_crate_root("nominal-api-proto", "definitions/protos");
let protos_dir = proto_root.join("definitions/protos");
let includes_dir = proto_root.join("definitions/proto-includes");
println!("cargo:rerun-if-changed={}", conjure_json.display());
println!("cargo:rerun-if-changed={}", protos_dir.display());
generate_conjure_endpoints(conjure_json.as_path(), &out_dir);
generate_grpc_http_endpoints(protos_dir.as_path(), &out_dir);
generate_proto_descriptor(protos_dir.as_path(), includes_dir.as_path(), &out_dir);
}
fn find_api_crate_root(crate_name: &str, definitions_marker: &str) -> PathBuf {
if let Ok(meta) = cargo_metadata::MetadataCommand::new().exec() {
if let Some(package) = meta
.packages
.iter()
.filter(|p| p.name == crate_name)
.filter(|p| {
p.manifest_path
.parent()
.is_some_and(|root| root.as_std_path().join(definitions_marker).exists())
})
.max_by_key(|p| p.version.clone())
{
if let Some(root) = package.manifest_path.parent() {
let root = root.as_std_path().to_path_buf();
return root;
}
}
}
let cargo_home = env::var_os("CARGO_HOME")
.map(PathBuf::from)
.or_else(|| env::var_os("HOME").map(|home| PathBuf::from(home).join(".cargo")))
.expect("neither CARGO_HOME nor HOME is set");
let registry_src = cargo_home.join("registry/src");
let version = locked_crate_version(crate_name);
let mut candidates = Vec::new();
for registry in fs::read_dir(®istry_src).unwrap_or_else(|e| {
panic!(
"failed to read cargo registry source directory {}: {e}",
registry_src.display()
)
}) {
let registry = registry.expect("failed to read cargo registry entry");
let path = registry.path();
if !path.is_dir() {
continue;
}
for package in fs::read_dir(&path).unwrap_or_else(|e| {
panic!(
"failed to read cargo registry package directory {}: {e}",
path.display()
)
}) {
let package = package.expect("failed to read cargo registry package entry");
let package_path = package.path();
let Some(name) = package_path.file_name().and_then(|name| name.to_str()) else {
continue;
};
if name == format!("{crate_name}-{version}")
&& package_path.join(definitions_marker).exists()
{
candidates.push(package_path);
}
}
}
candidates.sort();
candidates
.pop()
.unwrap_or_else(|| panic!("{crate_name} crate source not found in cargo registry"))
}
fn locked_crate_version(crate_name: &str) -> String {
let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap());
let lockfile = [
manifest_dir.join("Cargo.lock"),
manifest_dir.join("../Cargo.lock"),
]
.into_iter()
.find(|path| path.is_file())
.unwrap_or_else(|| panic!("Cargo.lock not found near {}", manifest_dir.display()));
let raw = fs::read_to_string(&lockfile)
.unwrap_or_else(|e| panic!("failed to read {}: {e}", lockfile.display()));
let mut in_package = false;
for line in raw.lines() {
let line = line.trim();
if line == "[[package]]" {
in_package = false;
continue;
}
if line == format!("name = \"{crate_name}\"") {
in_package = true;
continue;
}
if in_package {
if let Some(version) = line
.strip_prefix("version = \"")
.and_then(|rest| rest.strip_suffix('"'))
{
return version.to_owned();
}
}
}
panic!("{crate_name} package not found in {}", lockfile.display());
}
fn generate_conjure_endpoints(json_path: &Path, out_dir: &Path) {
let raw = fs::read_to_string(json_path)
.unwrap_or_else(|e| panic!("failed to read {}: {e}", json_path.display()));
let def: serde_json::Value = serde_json::from_str(&raw).expect("failed to parse conjure JSON");
let mut entries = String::new();
for svc in def["services"].as_array().expect("services array") {
let svc_name = svc["serviceName"]["name"].as_str().unwrap();
for ep in svc["endpoints"].as_array().expect("endpoints array") {
let ep_name = ep["endpointName"].as_str().unwrap();
let method = ep["httpMethod"].as_str().unwrap();
let path = ep["httpPath"].as_str().unwrap();
let body_arg = ep["args"]
.as_array()
.and_then(|args| args.iter().find(|a| a["paramType"]["type"] == "body"));
let validate_body = match body_arg {
None => "None".to_owned(),
Some(arg) => match conjure_type_to_rust(&arg["type"]) {
Ok(rust_ty) => format!(
"Some(|s: &str| -> ::std::result::Result<(), ::std::string::String> {{ \
::serde_json::from_str::<{rust_ty}>(s)\
.map(|_| ())\
.map_err(|e| e.to_string()) \
}})"
),
Err(_) => "None".to_owned(),
},
};
writeln!(
entries,
" ConjureEndpoint {{ service: {svc_name:?}, name: {ep_name:?}, \
method: {method:?}, path_template: {path:?}, validate_body: {validate_body} }},",
)
.unwrap();
}
}
let src = format!(
"pub struct ConjureEndpoint {{\n\
pub service: &'static str,\n\
pub name: &'static str,\n\
pub method: &'static str,\n\
pub path_template: &'static str,\n\
pub validate_body: Option<fn(&str) -> ::std::result::Result<(), ::std::string::String>>,\n\
}}\n\n\
pub static CONJURE_ENDPOINTS: &[ConjureEndpoint] = &[\n{entries}];\n"
);
fs::write(out_dir.join("conjure_endpoints.rs"), src)
.expect("failed to write conjure_endpoints.rs");
}
fn conjure_type_to_rust(ty: &serde_json::Value) -> Result<String, String> {
match ty["type"].as_str().unwrap_or("") {
"reference" => {
let pkg = ty["reference"]["package"]
.as_str()
.ok_or("missing package")?;
let name = ty["reference"]["name"].as_str().ok_or("missing name")?;
let module = pkg
.strip_prefix("io.nominal.")
.unwrap_or(pkg)
.replace('.', "::");
Ok(format!("::nominal_api::objects::{module}::{name}"))
}
"set" => Ok(format!(
"::std::collections::BTreeSet<{}>",
conjure_type_to_rust(&ty["set"]["itemType"])?
)),
"list" => Ok(format!(
"::std::vec::Vec<{}>",
conjure_type_to_rust(&ty["list"]["itemType"])?
)),
"map" => Ok(format!(
"::std::collections::BTreeMap<{}, {}>",
conjure_type_to_rust(&ty["map"]["keyType"])?,
conjure_type_to_rust(&ty["map"]["valueType"])?
)),
"optional" => Ok(format!(
"::std::option::Option<{}>",
conjure_type_to_rust(&ty["optional"]["itemType"])?
)),
"primitive" => match ty["primitive"].as_str().unwrap_or("") {
"STRING" => Ok("::std::string::String".into()),
"INTEGER" => Ok("i32".into()),
"DOUBLE" => Ok("f64".into()),
"BOOLEAN" => Ok("bool".into()),
"RID" => Ok("::conjure_object::ResourceIdentifier".into()),
"SAFELONG" => Ok("::conjure_object::SafeLong".into()),
"DATETIME" => Ok("::conjure_object::DateTime<::conjure_object::Utc>".into()),
"UUID" => Ok("::conjure_object::Uuid".into()),
"BEARERTOKEN" => Ok("::conjure_object::BearerToken".into()),
"BINARY" | "ANY" => Ok("::serde_json::Value".into()),
other => Err(format!("unknown primitive: {other}")),
},
other => Err(format!("unknown conjure type kind: {other}")),
}
}
fn generate_grpc_http_endpoints(protos_dir: &Path, out_dir: &Path) {
let mut entries = String::new();
let proto_files = collect_proto_files(protos_dir);
for proto_file in &proto_files {
let content = match fs::read_to_string(proto_file) {
Ok(c) => c,
Err(_) => continue,
};
parse_grpc_http_endpoints(&content, &mut entries);
}
let src = format!(
"pub struct GrpcHttpEndpoint {{\n\
pub service: &'static str,\n\
pub rpc: &'static str,\n\
pub method: &'static str,\n\
pub path_template: &'static str,\n\
/// \"*\" = whole message is body, a field name = only that field, None = no body\n\
pub body: Option<&'static str>,\n\
/// If set, unwrap this field from the response JSON\n\
pub response_body: Option<&'static str>,\n\
}}\n\n\
pub static GRPC_HTTP_ENDPOINTS: &[GrpcHttpEndpoint] = &[\n{entries}];\n"
);
fs::write(out_dir.join("grpc_http_endpoints.rs"), src)
.expect("failed to write grpc_http_endpoints.rs");
}
fn collect_proto_files(dir: &Path) -> Vec<PathBuf> {
let mut out = Vec::new();
if let Ok(rd) = fs::read_dir(dir) {
for entry in rd.flatten() {
let path = entry.path();
if path.is_dir() {
out.extend(collect_proto_files(&path));
} else if path.extension().is_some_and(|e| e == "proto") {
out.push(path);
}
}
}
out
}
fn parse_grpc_http_endpoints(content: &str, out: &mut String) {
let package = content
.lines()
.find_map(|l| {
l.trim()
.strip_prefix("package ")
.map(|rest| rest.trim_end_matches(';').trim().to_owned())
})
.unwrap_or_default();
let service_name = content
.lines()
.find_map(|l| {
l.trim()
.strip_prefix("service ")
.map(|rest| rest.split('{').next().unwrap_or("").trim().to_owned())
})
.unwrap_or_default();
let full_service = if package.is_empty() {
service_name.clone()
} else {
format!("{package}.{service_name}")
};
let lines: Vec<&str> = content.lines().collect();
let mut i = 0;
while i < lines.len() {
let l = lines[i].trim();
if let Some(rpc_name) = parse_rpc_name(l) {
let window = &lines[i..std::cmp::min(i + 30, lines.len())];
if let Some(binding) = parse_http_binding(window) {
writeln!(
out,
" GrpcHttpEndpoint {{ service: {full_service:?}, rpc: {rpc_name:?}, \
method: {:?}, path_template: {:?}, body: {:?}, response_body: {:?} }},",
binding.method,
binding.path,
binding.body.as_deref(),
binding.response_body.as_deref(),
)
.unwrap();
}
}
i += 1;
}
}
fn parse_rpc_name(line: &str) -> Option<String> {
let line = line.trim();
if !line.starts_with("rpc ") {
return None;
}
let after = line.strip_prefix("rpc ")?.trim();
let name = after.split('(').next()?.trim().to_owned();
if name.is_empty() { None } else { Some(name) }
}
struct HttpBinding {
method: String,
path: String,
body: Option<String>,
response_body: Option<String>,
}
fn parse_http_binding(lines: &[&str]) -> Option<HttpBinding> {
let start = lines.iter().position(|l| l.contains("google.api.http"))?;
let mut block_lines: Vec<&str> = Vec::new();
for line in &lines[start..] {
block_lines.push(line);
if line.contains("};") {
break;
}
}
let snippet = block_lines.join(" ");
let method_path = ["get", "post", "put", "delete", "patch"]
.iter()
.find_map(|&m| {
let pattern = format!("{m}:");
if let Some(pos) = snippet.find(&pattern) {
let after = snippet[pos + pattern.len()..].trim_start();
let path = after
.trim_start_matches('"')
.split('"')
.next()
.unwrap_or("")
.to_owned();
if !path.is_empty() {
return Some((m.to_uppercase(), path));
}
}
None
})?;
let body = extract_field_value(&snippet, "body");
let response_body = extract_field_value(&snippet, "response_body");
Some(HttpBinding {
method: method_path.0,
path: method_path.1,
body,
response_body,
})
}
fn extract_field_value(snippet: &str, field: &str) -> Option<String> {
let pattern = format!("{field}:");
let mut search = snippet;
loop {
let pos = search.find(&pattern)?;
let boundary_ok = pos == 0
|| search[..pos]
.chars()
.last()
.is_none_or(|c| c.is_whitespace() || c == '{');
let after = &search[pos + pattern.len()..];
if boundary_ok {
let after = after.trim_start();
if after.starts_with('"') {
let val = after.trim_start_matches('"').split('"').next()?;
if !val.is_empty() {
return Some(val.to_owned());
}
}
}
search = after;
}
}
fn generate_proto_descriptor(protos_dir: &Path, includes_dir: &Path, out_dir: &Path) {
let descriptor_path = out_dir.join("nominal_descriptor.bin");
let proto_files = collect_proto_files(protos_dir);
let proto_file_paths: Vec<&Path> = proto_files.iter().map(|p| p.as_path()).collect();
tonic_prost_build::configure()
.build_server(false)
.build_client(false)
.file_descriptor_set_path(&descriptor_path)
.compile_protos(&proto_file_paths, &[protos_dir, includes_dir])
.expect("failed to compile proto descriptors");
}