use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use wyrd_semver::VersionBlock;
use crate::envelope::{Card, CardKind};
use crate::ids::{CardName, CardUid, SpaceName};
use crate::query::MetadataQuery;
use crate::reference::CardRef;
use crate::registry::{CardLifecycleStatus, RelativeArtifactPath};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields, rename_all = "snake_case")]
pub struct GetCardResponse {
pub card: Card,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields, rename_all = "snake_case")]
pub struct DeleteCardResponse {
pub card_uid: CardUid,
pub deleted: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields, rename_all = "snake_case")]
pub struct CardSummary {
pub card_uid: CardUid,
pub kind: CardKind,
pub space: SpaceName,
pub name: CardName,
pub version: VersionBlock,
pub spec_hash: String,
#[serde(default)]
pub artifact_hash: Option<String>,
pub status: CardLifecycleStatus,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields, rename_all = "snake_case")]
pub struct ListCardsRequest {
pub kind: Option<CardKind>,
pub space: Option<SpaceName>,
pub name: Option<CardName>,
pub version_range: Option<String>,
pub status: Option<CardLifecycleStatus>,
#[serde(
default,
serialize_with = "metadata_query_option::serialize",
deserialize_with = "metadata_query_option::deserialize"
)]
#[schemars(with = "Option<String>")]
pub filter: Option<MetadataQuery>,
#[serde(default)]
pub include_prerelease: bool,
pub limit: Option<i32>,
pub cursor: Option<String>,
}
mod metadata_query_option {
use super::MetadataQuery;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
#[expect(
clippy::ref_option,
reason = "serde's serialize_with passes the field by reference"
)]
pub fn serialize<S>(value: &Option<MetadataQuery>, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
value
.as_ref()
.map(ToString::to_string)
.serialize(serializer)
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<MetadataQuery>, D::Error>
where
D: Deserializer<'de>,
{
Option::<String>::deserialize(deserializer)?
.map(|value| MetadataQuery::parse(&value).map_err(serde::de::Error::custom))
.transpose()
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields, rename_all = "snake_case")]
pub struct ListCardsResponse {
pub items: Vec<CardSummary>,
#[serde(default)]
pub next_cursor: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields, rename_all = "snake_case")]
pub struct ListVersionsResponse {
pub versions: Vec<VersionBlock>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(rename_all = "snake_case")]
pub enum CardLocator {
Uid {
kind: CardKind,
uid: CardUid,
},
Ref(CardRef),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields, rename_all = "snake_case")]
pub struct ArtifactInventoryResponse {
pub artifacts: Vec<StoredArtifactEntry>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields, rename_all = "snake_case")]
pub struct StoredArtifactEntry {
pub relative_path: RelativeArtifactPath,
pub sha256: String,
pub size_bytes: i64,
pub content_type: Option<String>,
}
#[cfg(test)]
mod tests {
use super::{ArtifactInventoryResponse, CardLocator, ListCardsRequest, StoredArtifactEntry};
use crate::envelope::CardKind;
use crate::ids::CardUid;
use crate::query::{FieldRef, MetadataQuery, Operator, Predicate, Value};
use crate::registry::RelativeArtifactPath;
use serde_json::json;
#[test]
fn card_locator_uid_variant_uses_snake_case_tag() {
let uid = CardUid::new("01890f28-7c4a-7cc3-98e7-4f4a3c2d1b00").unwrap();
let encoded = serde_json::to_value(CardLocator::Uid {
kind: CardKind::Service,
uid: uid.clone(),
})
.unwrap();
assert_eq!(encoded["uid"]["kind"], "Service");
assert_eq!(
encoded["uid"]["uid"],
"01890f28-7c4a-7cc3-98e7-4f4a3c2d1b00"
);
let decoded: CardLocator =
serde_json::from_value(encoded).expect("locator round-trips through JSON");
assert!(matches!(
decoded,
CardLocator::Uid {
kind: CardKind::Service,
uid: back,
} if back == uid
));
}
#[test]
fn card_locator_uid_requires_kind() {
let payload = serde_json::json!({
"uid": { "uid": "01890f28-7c4a-7cc3-98e7-4f4a3c2d1b00" }
});
assert!(serde_json::from_value::<CardLocator>(payload).is_err());
}
#[test]
fn artifact_inventory_response_round_trips() {
let payload = json!({
"artifacts": [
{
"relative_path": "weights/model.bin",
"sha256": "abc",
"size_bytes": 42,
"content_type": "application/octet-stream",
}
]
});
let decoded: ArtifactInventoryResponse =
serde_json::from_value(payload).expect("inventory deserializes");
assert_eq!(decoded.artifacts.len(), 1);
assert_eq!(
decoded.artifacts[0].relative_path.as_str(),
"weights/model.bin"
);
let re_encoded = serde_json::to_value(&decoded).unwrap();
assert_eq!(re_encoded["artifacts"][0]["size_bytes"], 42);
}
#[test]
fn stored_artifact_entry_denies_unknown_fields() {
let payload = json!({
"relative_path": "weights/model.bin",
"sha256": "abc",
"size_bytes": 42,
"content_type": null,
"server_id": "1"
});
serde_json::from_value::<StoredArtifactEntry>(payload)
.expect_err("unknown stored-artifact fields must be rejected");
}
#[test]
fn relative_artifact_path_via_stored_entry_rejects_absolute_paths() {
let bad = json!({
"relative_path": "/etc/passwd",
"sha256": "abc",
"size_bytes": 42,
"content_type": null,
});
serde_json::from_value::<StoredArtifactEntry>(bad)
.expect_err("absolute paths must be rejected in wire values");
let path = RelativeArtifactPath::new("weights/model.bin")
.expect("well-formed relative path is accepted");
assert_eq!(path.as_str(), "weights/model.bin");
}
#[test]
fn list_filter_serializes_as_canonical_query_string() {
let request = ListCardsRequest {
kind: None,
space: None,
name: None,
version_range: None,
status: None,
filter: Some(MetadataQuery::Predicate(Predicate {
field: FieldRef::Label("stage".to_owned()),
op: Operator::Eq(Value::String("prod".to_owned())),
})),
include_prerelease: false,
limit: Some(20),
cursor: None,
};
let encoded = serde_urlencoded::to_string(&request).unwrap();
assert!(encoded.contains("filter=labels.stage+%3D+%22prod%22"));
assert_eq!(
serde_urlencoded::from_str::<ListCardsRequest>(&encoded).unwrap(),
request
);
}
}