use serde::{Deserialize, Serialize};
use url::Url;
use crate::api_version::ApiVersion;
use crate::envelope::{CardKind, Metadata};
use crate::reference::CardRef;
use crate::registry::{
CardLifecycleStatus, CardUploadEntry, RegistrationOutcomeKind, RelativeArtifactPath,
};
pub fn canonical_artifact_manifest_hash(
artifacts: &[ArtifactManifestEntry],
) -> Result<Option<String>, crate::error::WyrdError> {
if artifacts.is_empty() {
return Ok(None);
}
let mut canonical = artifacts.to_vec();
canonical.sort_by(|left, right| left.relative_path.cmp(&right.relative_path));
let bytes = serde_jcs::to_vec(&canonical).map_err(|error| {
crate::error::WyrdError::from_spec_canonicalization(
crate::envelope::SpecCanonicalizationError::Serialize(error),
)
})?;
Ok(Some(blake3::hash(&bytes).to_hex().to_string()))
}
#[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 CardSubmission {
#[serde(rename = "apiVersion")]
pub api_version: ApiVersion,
pub kind: CardKind,
pub metadata: Metadata,
#[schemars(with = "serde_json::Value")]
#[cfg_attr(feature = "server", schema(value_type = serde_json::Value))]
pub spec: serde_json::Value,
#[serde(default)]
pub artifacts: Vec<ArtifactManifestEntry>,
}
#[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 ArtifactManifestEntry {
pub relative_path: crate::registry::RelativeArtifactPath,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sha256: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub size_bytes: Option<u64>,
pub content_type: Option<String>,
}
#[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 CreateCardRequest {
pub submissions: Vec<CardSubmission>,
}
#[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 CardRegistrationOutcome {
pub card_ref: CardRef,
pub spec_hash: String,
#[serde(default)]
pub artifact_hash: Option<String>,
pub status: CardLifecycleStatus,
pub outcome: RegistrationOutcomeKind,
#[schemars(with = "Option<String>")]
#[cfg_attr(feature = "server", schema(value_type = Option<String>))]
#[serde(default)]
pub card_blob_uri: Option<Url>,
}
#[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 CardUploadPlan {
pub card_ref: CardRef,
pub entries: Vec<CardUploadEntry>,
}
#[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 CreateCardResponse {
pub root: CardRef,
pub outcomes: Vec<CardRegistrationOutcome>,
pub upload_plans: Vec<CardUploadPlan>,
}
#[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 RegistrationReceipt {
pub root: CardRef,
pub outcomes: Vec<CardRegistrationOutcome>,
}
impl From<CreateCardResponse> for RegistrationReceipt {
fn from(response: CreateCardResponse) -> Self {
Self {
root: response.root,
outcomes: response.outcomes,
}
}
}
#[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 RegistrationReplaySeed {
pub root: CardRef,
pub outcomes: Vec<CardRegistrationOutcome>,
pub artifact_manifest_paths: Vec<(CardRef, Vec<RelativeArtifactPath>)>,
}
#[cfg(test)]
mod tests {
use super::{
ArtifactManifestEntry, CardSubmission, CreateCardRequest, CreateCardResponse,
RegistrationReceipt, canonical_artifact_manifest_hash,
};
use crate::api_version::ApiVersion;
use crate::envelope::{CardKind, Metadata};
use crate::registry::{CardLifecycleStatus, RegistrationOutcomeKind, RelativeArtifactPath};
use serde_json::json;
use std::collections::BTreeMap;
fn artifact(path: &str) -> ArtifactManifestEntry {
ArtifactManifestEntry {
relative_path: RelativeArtifactPath::new(path).expect("test artifact path is valid"),
sha256: Some("YWJj".to_owned()),
size_bytes: Some(3),
content_type: None,
}
}
#[test]
fn canonical_artifact_manifest_hash_ignores_authored_order() {
let first = vec![artifact("weights.bin"), artifact("config.json")];
let second = vec![artifact("config.json"), artifact("weights.bin")];
assert_eq!(
canonical_artifact_manifest_hash(&first).expect("first manifest hashes"),
canonical_artifact_manifest_hash(&second).expect("second manifest hashes"),
);
}
fn submission() -> CardSubmission {
CardSubmission {
api_version: ApiVersion::v1(),
kind: CardKind::Audit,
metadata: Metadata {
name: "audit-a".parse().expect("test name is valid"),
version: None,
bump: None,
space: Some("default".parse().expect("test space is valid")),
uid: None,
labels: BTreeMap::default(),
annotations: BTreeMap::default(),
spec_hash: None,
artifact_hash: None,
origin: None,
},
spec: json!({}),
artifacts: Vec::new(),
}
}
#[test]
fn create_card_request_is_flat_submissions_list() {
let request: CreateCardRequest = serde_json::from_value(json!({
"submissions": [{
"apiVersion": "wyrd/v1",
"kind": "Audit",
"metadata": {"name": "audit-a", "space": "default"},
"spec": {}
}]
}))
.expect("flat request deserializes");
assert_eq!(request.submissions.len(), 1);
assert!(
serde_json::from_value::<CreateCardRequest>(json!({
"card": {}, "artifacts": []
}))
.is_err()
);
}
#[test]
fn card_submission_artifacts_default_is_empty() {
let encoded = serde_json::to_value(submission()).expect("submission serializes");
assert!(encoded["artifacts"].as_array().is_some());
assert!(encoded["artifacts"].as_array().expect("array").is_empty());
}
#[test]
fn card_submission_server_managed_fields_are_not_top_level_fields() {
let mut encoded = serde_json::to_value(submission()).expect("submission serializes");
encoded["relationships"] = json!([]);
encoded["status"] = json!("active");
assert!(serde_json::from_value::<CardSubmission>(encoded).is_err());
}
#[test]
fn create_card_response_carries_root_outcomes_and_upload_plans() {
let response = CreateCardResponse {
root: serde_json::from_value(json!({
"kind": "Audit", "name": "audit-a", "space": "default", "version": "1.0.0"
}))
.expect("card ref deserializes"),
outcomes: Vec::new(),
upload_plans: Vec::new(),
};
assert!(response.outcomes.is_empty());
assert!(response.upload_plans.is_empty());
let _ = (
CardLifecycleStatus::Pending,
RegistrationOutcomeKind::Registered,
);
}
#[test]
fn registration_receipt_excludes_internal_upload_plans() {
let response = CreateCardResponse {
root: serde_json::from_value(json!({
"kind": "Audit", "name": "audit-a", "space": "default", "version": "1.0.0"
}))
.expect("card ref deserializes"),
outcomes: Vec::new(),
upload_plans: Vec::new(),
};
let receipt = RegistrationReceipt::from(response);
let value = serde_json::to_value(receipt).expect("receipt serializes");
assert!(value.get("upload_plans").is_none());
assert!(value.get("operation_id").is_none());
}
}