Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 25 additions & 22 deletions client-sdks/platform/rust/build/openapi_filter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -803,7 +803,7 @@ fn normalize_binding_unions(document: &mut Map<String, Value>) -> Result<(), Str
return Ok(());
};
for schema in schemas.values_mut() {
normalize_nullable_string_enums(schema);
normalize_nullable_strings(schema);
}
if schemas.get("JsonValue") == Some(&serde_json::json!({})) {
for schema in schemas.values_mut() {
Expand Down Expand Up @@ -972,34 +972,37 @@ fn normalize_disjoint_tagged_unions(schema: &mut Value) {
.expect("tagged union normalization is infallible");
}

// A closed string enum and null are disjoint. Progenitor needs oneOf here
// to generate a scalar enum instead of a flattened struct that rejects strings.
fn normalize_nullable_string_enums(schema: &mut Value) {
// A non-nullable string (open, or a closed enum) and null are disjoint. Progenitor
// needs oneOf here to generate a scalar type instead of a flattened struct that
// rejects strings.
fn normalize_nullable_strings(schema: &mut Value) {
if let Some(object) = schema.as_object_mut() {
let disjoint =
object
.get("anyOf")
.and_then(Value::as_array)
.is_some_and(|branches| {
branches.len() == 2 && branches.iter().any(|branch| {
branch == &serde_json::json!({"type": "string", "nullable": true, "enum": [null]})
}) && branches.iter().any(|branch| {
branch.get("$ref").is_none()
&& branch.get("type").and_then(Value::as_str) == Some("string")
&& branch.get("nullable") != Some(&Value::Bool(true))
&& branch.get("enum").and_then(Value::as_array).is_some_and(|values| {
!values.is_empty() && values.iter().all(Value::is_string)
})
})
});
let disjoint = object
.get("anyOf")
.and_then(Value::as_array)
.is_some_and(|branches| {
branches.len() == 2 && branches.iter().any(|branch| {
branch
== &serde_json::json!({"type": "string", "nullable": true, "enum": [null]})
}) && branches.iter().any(|branch| {
branch.get("$ref").is_none()
&& branch.get("type").and_then(Value::as_str) == Some("string")
&& branch.get("nullable") != Some(&Value::Bool(true))
&& branch.get("enum").is_none_or(|values| {
values.as_array().is_some_and(|values| {
!values.is_empty() && values.iter().all(Value::is_string)
})
})
})
});
if disjoint && !object.contains_key("oneOf") {
let branches = object.remove("anyOf").expect("union was checked");
object.insert("oneOf".to_string(), branches);
}
}
for_schema_children_mut(schema, |child| {
normalize_nullable_string_enums(child);
normalize_nullable_strings(child);
Ok::<(), ()>(())
})
.expect("nullable enum normalization is infallible");
.expect("nullable string normalization is infallible");
}
58 changes: 58 additions & 0 deletions client-sdks/platform/rust/tests/observed_inventory.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
//! The hosted manager forwards each Operator's observed inventory to the Platform API through
//! the full client. A Kubernetes observer tags workload samples with a resource type hint, so
//! that optional string must survive the generated types.
#![cfg(feature = "full-api")]

use alien_core::{
HeartbeatBackend, ObservedHealth, ObservedInventoryBatch, ObservedResourceSample, Platform,
ProviderLifecycleState, ResourceType,
};
use alien_platform_api::types::ObservedInventoryBatch as ApiObservedInventoryBatch;

fn sample(resource_type_hint: Option<ResourceType>) -> ObservedResourceSample {
ObservedResourceSample {
deployment_id: Some("dep_aaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string()),
raw_identity: "apps/v1/Deployment/demo/gateway".to_string(),
provider_kind: "apps/v1/Deployment".to_string(),
display_name: "gateway".to_string(),
namespace: Some("demo".to_string()),
region: None,
scope: None,
resource_type_hint,
version: None,
alien_resource_id: None,
health: ObservedHealth::Healthy,
lifecycle: ProviderLifecycleState::Running,
message: None,
partial: false,
provider_stale: false,
counts: None,
collection_issues: Vec::new(),
labels: Default::default(),
attributes: Default::default(),
raw: Vec::new(),
images: Vec::new(),
}
}

#[test]
fn observed_inventory_keeps_resource_type_hints() {
let source = ObservedInventoryBatch {
resources: vec![sample(Some(ResourceType::from("container"))), sample(None)],
source_kind: "operator".to_string(),
inventory_scope: "kubernetes/demo".to_string(),
controller_platform: Platform::Kubernetes,
backend: HeartbeatBackend::Kubernetes,
observed_at: "2026-10-07T16:00:00Z".parse().expect("timestamp parses"),
complete: true,
};

let api: ApiObservedInventoryBatch =
serde_json::from_value(serde_json::to_value(&source).expect("batch encodes"))
.expect("generated client decodes the batch");
let round_tripped: ObservedInventoryBatch =
serde_json::from_value(serde_json::to_value(&api).expect("generated batch encodes"))
.expect("batch decodes");

assert_eq!(round_tripped, source);
}
Loading