use crate::types::body::{DataPeriod, Signature};
use crate::types::data_ref::DataRef;
use crate::types::primitives::*;
use crate::types::serde_helpers::de_present;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PublishRequest {
pub version: u32,
pub supersedes: Option<CtxId>,
pub agent_id: AgentDid,
pub contributors: Vec<AgentDid>,
pub title: String,
#[serde(rename = "type")]
pub context_type: ContextType,
pub data_refs: Vec<DataRef>,
pub derived_from: Vec<CtxId>,
pub visibility: Visibility,
pub content_hash: ContentHash,
pub signature: Signature,
#[serde(skip_serializing_if = "Option::is_none")]
pub audience: Option<Vec<AgentDid>>,
#[serde(
default,
deserialize_with = "de_present",
skip_serializing_if = "Option::is_none"
)]
pub acdp_version: Option<String>,
#[serde(
default,
deserialize_with = "de_present",
skip_serializing_if = "Option::is_none"
)]
pub description: Option<String>,
#[serde(
default,
deserialize_with = "de_present",
skip_serializing_if = "Option::is_none"
)]
pub summary: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub lineage_id: Option<LineageId>,
#[serde(
default,
deserialize_with = "de_present",
skip_serializing_if = "Option::is_none"
)]
pub tags: Option<Vec<String>>,
#[serde(
default,
deserialize_with = "de_present",
skip_serializing_if = "Option::is_none"
)]
pub domain: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub data_period: Option<DataPeriod>,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<serde_json::Value>,
#[serde(
default,
deserialize_with = "de_present",
skip_serializing_if = "Option::is_none"
)]
pub schema_uri: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PublishResponse {
pub ctx_id: CtxId,
pub lineage_id: LineageId,
pub version: u32,
pub created_at: DateTime<Utc>,
pub status: Status,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub registry_receipt: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WireError {
pub error: WireErrorBody,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WireErrorBody {
pub code: String,
pub message: String,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "crate::types::serde_helpers::de_present_object"
)]
pub details: Option<serde_json::Value>,
}
impl WireErrorBody {
pub fn supersession_reason(&self) -> Option<crate::error::SupersessionReason> {
self.details
.as_ref()
.and_then(|d| d.get("reason"))
.and_then(|v| serde_json::from_value(v.clone()).ok())
}
pub fn unreachable_ctx_id(&self) -> Option<&str> {
self.details
.as_ref()
.and_then(|d| d.get("unreachable_ctx_id"))
.and_then(|v| v.as_str())
}
pub fn idempotency_key(&self) -> Option<&str> {
self.details
.as_ref()
.and_then(|d| d.get("idempotency_key"))
.and_then(|v| v.as_str())
}
pub fn original_ctx_id(&self) -> Option<&str> {
self.details
.as_ref()
.and_then(|d| d.get("original_ctx_id"))
.and_then(|v| v.as_str())
}
}
impl std::fmt::Display for WireError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}: {}", self.error.code, self.error.message)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn minimal_request_with_extra(extra: &str) -> String {
format!(
r#"{{
"version": 1,
"agent_id": "did:web:agents.example.com:test",
"contributors": [],
"title": "t",
"type": "data_snapshot",
"data_refs": [],
"derived_from": [],
"visibility": "public",
"content_hash": "sha256:0",
"signature": {{
"algorithm": "ed25519",
"key_id": "did:web:agents.example.com:test#key-1",
"value": "{sig}"
}}{extra}
}}"#,
sig = "A".repeat(88),
extra = extra
)
}
#[test]
fn extra_top_level_field_is_rejected() {
let body = minimal_request_with_extra(r#", "ctx_id": "acdp://r/x""#);
let res: Result<PublishRequest, _> = serde_json::from_str(&body);
assert!(res.is_err(), "ctx_id in publish request must be rejected");
}
#[test]
fn extra_origin_registry_field_is_rejected() {
let body = minimal_request_with_extra(r#", "origin_registry": "did:web:r.x""#);
let res: Result<PublishRequest, _> = serde_json::from_str(&body);
assert!(res.is_err());
}
#[test]
fn extra_created_at_field_is_rejected() {
let body = minimal_request_with_extra(r#", "created_at": "2026-01-01T00:00:00.000Z""#);
let res: Result<PublishRequest, _> = serde_json::from_str(&body);
assert!(res.is_err());
}
#[test]
fn arbitrary_unknown_field_is_rejected() {
let body = minimal_request_with_extra(r#", "noodle": 42"#);
let res: Result<PublishRequest, _> = serde_json::from_str(&body);
assert!(res.is_err());
}
#[test]
fn baseline_no_extra_fields_deserializes_ok() {
let body = minimal_request_with_extra("");
serde_json::from_str::<PublishRequest>(&body)
.expect("baseline minimal request must still deserialize");
}
}