* feat: observation_scopes field to drive observations granularity * fix(migration): make a2b3c4d5e6f7 a no-op to fix CI on fresh DB The z1u2v3w4x5y6 migration already creates observation_scopes directly, so the rename migration fails on fresh installs where observation_tags never existed. * chore: remove no-op migration a2b3c4d5e6f7 * feat: regenerate clients with observation_scopes field - Add observation_scopes to OpenAPI spec and all generated clients - Fix Rust build.rs to handle anyOf with >2 variants containing null (previously only handled 2-item anyOf, causing progenitor to panic on the observation_scopes union type) * fix(rust): add observation_scopes: None to MemoryItem struct literals * fix(api): add title to observation_scopes Field for deterministic client generation Adding title="ObservationScopes" makes the inline anyOf schema use the explicit name instead of deriving it from the field name, which was non-deterministic between arm64 (macOS) and amd64 (CI) Docker. Also fixes description: "each entity" -> "each tag". * fix(scripts): use linux/amd64 Docker for client generation to ensure reproducibility Both Python and Go client generation now use --platform linux/amd64 Docker, ensuring identical output on macOS arm64 (local) and Linux amd64 (CI). Also switches Go from JAR+Java to Docker to eliminate Java version variability. * chore: update generated clients to API v0.4.14 * fix(test): add retry logic to test_retain_chinese_content to handle non-deterministic LLM output * fix(test): mark test_retain_chinese_content as xfail due to non-deterministic LLM translation
181 lines
7.1 KiB
Rust
181 lines
7.1 KiB
Rust
use std::env;
|
|
use std::fs;
|
|
use std::path::PathBuf;
|
|
|
|
/// Convert OpenAPI 3.1 spec to 3.0 for progenitor compatibility
|
|
fn convert_31_to_30(spec: &mut serde_json::Value) {
|
|
// Change version from 3.1.x to 3.0.3
|
|
if let Some(obj) = spec.as_object_mut() {
|
|
obj.insert("openapi".to_string(), serde_json::json!("3.0.3"));
|
|
}
|
|
|
|
// Recursively convert anyOf with null to nullable
|
|
convert_anyof_to_nullable(spec);
|
|
}
|
|
|
|
/// Remove paths with multipart/form-data content type (not supported by progenitor)
|
|
fn filter_multipart_endpoints(spec: &mut serde_json::Value) {
|
|
if let Some(paths) = spec.get_mut("paths").and_then(|v| v.as_object_mut()) {
|
|
let mut paths_to_remove = Vec::new();
|
|
|
|
for (path_name, path_item) in paths.iter() {
|
|
if let Some(operations) = path_item.as_object() {
|
|
for (_method, operation) in operations.iter() {
|
|
if let Some(request_body) = operation.get("requestBody") {
|
|
if let Some(content) = request_body.get("content") {
|
|
if let Some(content_obj) = content.as_object() {
|
|
if content_obj.contains_key("multipart/form-data") {
|
|
eprintln!("Filtering out endpoint with multipart/form-data: {}", path_name);
|
|
paths_to_remove.push(path_name.clone());
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Remove the paths
|
|
for path in paths_to_remove {
|
|
paths.remove(&path);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn convert_anyof_to_nullable(value: &mut serde_json::Value) {
|
|
match value {
|
|
serde_json::Value::Object(obj) => {
|
|
// Check if this object has anyOf with null and process it
|
|
let has_null_in_anyof = obj.get("anyOf")
|
|
.and_then(|v| v.as_array())
|
|
.map(|array| {
|
|
array.iter().any(|v| {
|
|
v.get("type")
|
|
.and_then(|t| t.as_str())
|
|
.map(|s| s == "null")
|
|
.unwrap_or(false)
|
|
})
|
|
})
|
|
.unwrap_or(false);
|
|
|
|
if has_null_in_anyof {
|
|
// Clone the anyOf array to avoid borrow issues
|
|
if let Some(any_of) = obj.get("anyOf").cloned() {
|
|
if let Some(array) = any_of.as_array() {
|
|
let non_null_schemas: Vec<_> = array.iter().filter(|v| {
|
|
v.get("type")
|
|
.and_then(|t| t.as_str())
|
|
.map(|s| s != "null")
|
|
.unwrap_or(true)
|
|
}).cloned().collect();
|
|
|
|
obj.remove("anyOf");
|
|
if non_null_schemas.len() == 1 {
|
|
// Single non-null type: inline it with nullable: true
|
|
if let Some(non_null_obj) = non_null_schemas[0].as_object() {
|
|
for (k, v) in non_null_obj.iter() {
|
|
obj.insert(k.clone(), v.clone());
|
|
}
|
|
}
|
|
} else {
|
|
// Multiple non-null types: keep anyOf with nulls removed
|
|
obj.insert("anyOf".to_string(), serde_json::json!(non_null_schemas));
|
|
}
|
|
obj.insert("nullable".to_string(), serde_json::json!(true));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Recursively process all values
|
|
for (_key, val) in obj.iter_mut() {
|
|
convert_anyof_to_nullable(val);
|
|
}
|
|
}
|
|
serde_json::Value::Array(arr) => {
|
|
for item in arr.iter_mut() {
|
|
convert_anyof_to_nullable(item);
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn main() {
|
|
// Get the OpenAPI spec path from hindsight-docs/static (single source of truth)
|
|
let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap());
|
|
let openapi_path = manifest_dir
|
|
.parent()
|
|
.unwrap()
|
|
.parent()
|
|
.unwrap()
|
|
.join("hindsight-docs")
|
|
.join("static")
|
|
.join("openapi.json");
|
|
|
|
// Tell Cargo to rebuild if the OpenAPI spec changes
|
|
println!("cargo:rerun-if-changed={}", openapi_path.display());
|
|
|
|
// Read the OpenAPI spec
|
|
let spec_content = fs::read_to_string(&openapi_path)
|
|
.expect("Failed to read openapi.json. Make sure it exists in the project root.");
|
|
|
|
// Parse as generic JSON first to convert 3.1 to 3.0
|
|
let mut spec_json: serde_json::Value = serde_json::from_str(&spec_content)
|
|
.expect("Failed to parse openapi.json");
|
|
|
|
// Convert OpenAPI 3.1.0 to 3.0.3 for progenitor compatibility
|
|
if let Some(version) = spec_json.get("openapi").and_then(|v| v.as_str()) {
|
|
if version.starts_with("3.1") {
|
|
eprintln!("Converting OpenAPI 3.1 to 3.0 for compatibility...");
|
|
convert_31_to_30(&mut spec_json);
|
|
}
|
|
}
|
|
|
|
// Filter out multipart/form-data endpoints (progenitor doesn't support them)
|
|
filter_multipart_endpoints(&mut spec_json);
|
|
|
|
// Now parse as OpenAPI struct
|
|
let spec: openapiv3::OpenAPI = serde_json::from_value(spec_json)
|
|
.expect("Failed to parse converted OpenAPI spec");
|
|
|
|
// Generate the client
|
|
let mut generator = progenitor::Generator::default();
|
|
|
|
// Generate code
|
|
let tokens = generator.generate_tokens(&spec)
|
|
.expect("Failed to generate client code from OpenAPI spec");
|
|
|
|
// Write to the output directory
|
|
let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap());
|
|
let dest_path = out_dir.join("hindsight_client_generated.rs");
|
|
|
|
let syntax_tree = syn::parse2(tokens)
|
|
.expect("Failed to parse generated tokens");
|
|
let mut formatted = prettyplease::unparse(&syntax_tree);
|
|
|
|
// Fix progenitor bug with optional header parameters
|
|
// The generated code tries to call .to_string() on Option<&str> which doesn't work
|
|
// We need to unwrap the Option first
|
|
formatted = fix_optional_header_params(&formatted);
|
|
|
|
fs::write(&dest_path, formatted)
|
|
.expect("Failed to write generated client code");
|
|
|
|
println!("Generated client at: {}", dest_path.display());
|
|
}
|
|
|
|
/// Fix progenitor's generated code for optional header parameters
|
|
/// Replaces patterns like `value.to_string().try_into()?` where value is Option<&str>
|
|
/// with `value.unwrap_or_default().to_string().try_into()?`
|
|
fn fix_optional_header_params(code: &str) -> String {
|
|
use regex::Regex;
|
|
|
|
// Pattern: header_map.append("authorization", value.to_string().try_into()?);
|
|
// Should become: header_map.append("authorization", value.unwrap_or_default().to_string().try_into()?);
|
|
let re = Regex::new(r#"header_map\.append\("authorization", value\.to_string\(\)\.try_into\(\)\?\)"#)
|
|
.expect("Invalid regex");
|
|
|
|
re.replace_all(code, r#"header_map.append("authorization", value.unwrap_or_default().to_string().try_into()?)"#)
|
|
.to_string()
|
|
}
|