1use acdp_primitives::primitives::ContentHash;
9use serde::{Deserialize, Serialize};
10
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "snake_case")]
15pub enum DataRefType {
16 PrimaryResult,
18 RawData,
20 SupportingInfo,
22 DerivedData,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct DataRef {
33 #[serde(rename = "type")]
35 pub ref_type: DataRefType,
36
37 #[serde(
42 default,
43 skip_serializing_if = "Option::is_none",
44 deserialize_with = "crate::serde_helpers::de_present"
45 )]
46 pub description: Option<String>,
47
48 #[serde(
50 default,
51 skip_serializing_if = "Option::is_none",
52 deserialize_with = "crate::serde_helpers::de_present"
53 )]
54 pub size_bytes: Option<u64>,
55
56 #[serde(
58 default,
59 skip_serializing_if = "Option::is_none",
60 deserialize_with = "crate::serde_helpers::de_present"
61 )]
62 pub format: Option<String>,
63
64 #[serde(
66 default,
67 skip_serializing_if = "Option::is_none",
68 deserialize_with = "crate::serde_helpers::de_present"
69 )]
70 pub schema_version: Option<String>,
71
72 #[serde(
76 default,
77 skip_serializing_if = "Option::is_none",
78 deserialize_with = "crate::serde_helpers::de_present"
79 )]
80 pub content_hash: Option<ContentHash>,
81
82 #[serde(
85 default,
86 skip_serializing_if = "Option::is_none",
87 deserialize_with = "crate::serde_helpers::de_present"
88 )]
89 pub location: Option<Location>,
90
91 #[serde(
93 default,
94 skip_serializing_if = "Option::is_none",
95 deserialize_with = "crate::serde_helpers::de_present"
96 )]
97 pub embedded: Option<EmbeddedContent>,
98
99 #[serde(flatten)]
111 pub extensions: serde_json::Map<String, serde_json::Value>,
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize)]
117#[serde(untagged)]
118pub enum Location {
119 Uri(String),
123 Structured(serde_json::Map<String, serde_json::Value>),
127}
128
129impl DataRef {
130 pub fn uri(ref_type: DataRefType, uri: impl Into<String>) -> Self {
132 Self {
133 ref_type,
134 description: None,
135 size_bytes: None,
136 format: None,
137 schema_version: None,
138 content_hash: None,
139 location: Some(Location::Uri(uri.into())),
140 embedded: None,
141 extensions: serde_json::Map::new(),
142 }
143 }
144
145 pub fn uri_verified(ref_type: DataRefType, uri: impl Into<String>, hash: ContentHash) -> Self {
147 Self {
148 ref_type,
149 description: None,
150 size_bytes: None,
151 format: None,
152 schema_version: None,
153 content_hash: Some(hash),
154 location: Some(Location::Uri(uri.into())),
155 embedded: None,
156 extensions: serde_json::Map::new(),
157 }
158 }
159
160 pub fn structured(
170 ref_type: DataRefType,
171 scheme: impl Into<String>,
172 extra: serde_json::Map<String, serde_json::Value>,
173 ) -> Self {
174 let scheme: String = scheme.into();
175 debug_assert!(
176 is_dotted_namespace_scheme(&scheme),
177 "DataRef::structured: scheme '{scheme}' does not match \
178 ^[a-z][a-z0-9-]*(\\.[a-z][a-z0-9-]*)+$ — pass a dotted-namespace identifier \
179 like 'kafka.offset' or use try_structured for runtime checking"
180 );
181 let mut map = extra;
182 map.insert("scheme".into(), serde_json::Value::String(scheme));
183 Self {
184 ref_type,
185 description: None,
186 size_bytes: None,
187 format: None,
188 schema_version: None,
189 content_hash: None,
190 location: Some(Location::Structured(map)),
191 embedded: None,
192 extensions: serde_json::Map::new(),
193 }
194 }
195
196 pub fn try_structured(
200 ref_type: DataRefType,
201 scheme: impl Into<String>,
202 extra: serde_json::Map<String, serde_json::Value>,
203 ) -> Result<Self, acdp_primitives::error::AcdpError> {
204 let scheme: String = scheme.into();
205 if !is_dotted_namespace_scheme(&scheme) {
206 return Err(acdp_primitives::error::AcdpError::SchemaViolation(format!(
207 "structured locator scheme '{scheme}' must match \
208 ^[a-z][a-z0-9-]*(\\.[a-z][a-z0-9-]*)+$"
209 )));
210 }
211 let mut map = extra;
212 map.insert("scheme".into(), serde_json::Value::String(scheme));
213 Ok(Self {
214 ref_type,
215 description: None,
216 size_bytes: None,
217 format: None,
218 schema_version: None,
219 content_hash: None,
220 location: Some(Location::Structured(map)),
221 embedded: None,
222 extensions: serde_json::Map::new(),
223 })
224 }
225
226 pub fn embedded_json(ref_type: DataRefType, content: serde_json::Value) -> Self {
228 Self {
229 ref_type,
230 description: None,
231 size_bytes: None,
232 format: Some("application/json".into()),
233 schema_version: None,
234 content_hash: None,
235 location: None,
236 embedded: Some(EmbeddedContent {
237 encoding: EmbeddedEncoding::Json,
238 content,
239 content_hash: None,
240 }),
241 extensions: serde_json::Map::new(),
242 }
243 }
244
245 pub fn embedded_utf8(ref_type: DataRefType, text: impl Into<String>) -> Self {
247 Self {
248 ref_type,
249 description: None,
250 size_bytes: None,
251 format: None,
252 schema_version: None,
253 content_hash: None,
254 location: None,
255 embedded: Some(EmbeddedContent {
256 encoding: EmbeddedEncoding::Utf8,
257 content: serde_json::Value::String(text.into()),
258 content_hash: None,
259 }),
260 extensions: serde_json::Map::new(),
261 }
262 }
263
264 pub fn embedded_base64(ref_type: DataRefType, b64: impl Into<String>) -> Self {
266 Self {
267 ref_type,
268 description: None,
269 size_bytes: None,
270 format: None,
271 schema_version: None,
272 content_hash: None,
273 location: None,
274 embedded: Some(EmbeddedContent {
275 encoding: EmbeddedEncoding::Base64,
276 content: serde_json::Value::String(b64.into()),
277 content_hash: None,
278 }),
279 extensions: serde_json::Map::new(),
280 }
281 }
282
283 pub fn primary_result_uri(uri: impl Into<String>) -> Self {
290 Self::uri(DataRefType::PrimaryResult, uri)
291 }
292 pub fn raw_data_uri(uri: impl Into<String>) -> Self {
294 Self::uri(DataRefType::RawData, uri)
295 }
296 pub fn supporting_info_uri(uri: impl Into<String>) -> Self {
298 Self::uri(DataRefType::SupportingInfo, uri)
299 }
300 pub fn derived_data_uri(uri: impl Into<String>) -> Self {
302 Self::uri(DataRefType::DerivedData, uri)
303 }
304
305 pub fn primary_result_json(content: serde_json::Value) -> Self {
307 Self::embedded_json(DataRefType::PrimaryResult, content)
308 }
309 pub fn derived_data_json(content: serde_json::Value) -> Self {
311 Self::embedded_json(DataRefType::DerivedData, content)
312 }
313}
314
315#[derive(Debug, Clone, Serialize, Deserialize)]
317#[serde(deny_unknown_fields)]
318pub struct EmbeddedContent {
319 pub encoding: EmbeddedEncoding,
321 pub content: serde_json::Value,
324 #[serde(
328 default,
329 skip_serializing_if = "Option::is_none",
330 deserialize_with = "crate::serde_helpers::de_present"
331 )]
332 pub content_hash: Option<ContentHash>,
333}
334
335fn is_dotted_namespace_scheme(s: &str) -> bool {
337 let parts: Vec<&str> = s.split('.').collect();
338 if parts.len() < 2 {
339 return false;
340 }
341 parts.iter().all(|part| {
342 !part.is_empty()
343 && part.chars().next().is_some_and(|c| c.is_ascii_lowercase())
344 && part
345 .chars()
346 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
347 })
348}
349
350#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
352#[serde(rename_all = "lowercase")]
353pub enum EmbeddedEncoding {
354 Json,
356 Utf8,
358 Base64,
360}
361
362#[cfg(test)]
363mod tests {
364 use super::*;
365 use serde_json::json;
366
367 #[test]
370 fn dotted_namespace_scheme_accepts_valid() {
371 for s in [
372 "kafka.offset",
373 "ipfs.cid",
374 "db.row",
375 "a.b",
376 "a1.b2.c3",
377 "with-hyphen.part-two",
378 ] {
379 assert!(is_dotted_namespace_scheme(s), "should accept {s:?}");
380 }
381 }
382
383 #[test]
384 fn dotted_namespace_scheme_rejects_invalid() {
385 for s in [
386 "", "nodot", "Kafka.offset", "kafka.Offset", "kafka..offset", ".leading", "trailing.", "1kafka.offset", "kafka.1offset", "kafka.off_set", ] {
397 assert!(!is_dotted_namespace_scheme(s), "should reject {s:?}");
398 }
399 }
400
401 #[test]
404 fn try_structured_ok_inserts_scheme_and_extra() {
405 let mut extra = serde_json::Map::new();
406 extra.insert("offset".into(), json!(42));
407 let dr = DataRef::try_structured(DataRefType::RawData, "kafka.offset", extra).unwrap();
408 match dr.location {
409 Some(Location::Structured(map)) => {
410 assert_eq!(map["scheme"], json!("kafka.offset"));
411 assert_eq!(map["offset"], json!(42));
412 }
413 other => panic!("expected structured location, got {other:?}"),
414 }
415 assert!(dr.embedded.is_none(), "structured locator has no embedded");
416 }
417
418 #[test]
419 fn try_structured_rejects_bad_scheme() {
420 let err = DataRef::try_structured(DataRefType::RawData, "nodot", serde_json::Map::new())
421 .unwrap_err();
422 assert!(
423 matches!(err, acdp_primitives::error::AcdpError::SchemaViolation(_)),
424 "bad scheme must be SchemaViolation, got {err:?}"
425 );
426 }
427
428 #[test]
429 fn structured_inserts_scheme_for_valid_input() {
430 let dr = DataRef::structured(DataRefType::RawData, "ipfs.cid", serde_json::Map::new());
432 match dr.location {
433 Some(Location::Structured(map)) => assert_eq!(map["scheme"], json!("ipfs.cid")),
434 other => panic!("expected structured location, got {other:?}"),
435 }
436 }
437
438 #[test]
441 fn uri_constructor_sets_location_without_hash() {
442 let dr = DataRef::uri(DataRefType::PrimaryResult, "https://x.example/d");
443 assert_eq!(dr.ref_type, DataRefType::PrimaryResult);
444 assert!(matches!(dr.location, Some(Location::Uri(ref u)) if u == "https://x.example/d"));
445 assert!(dr.content_hash.is_none());
446 assert!(dr.embedded.is_none());
447 }
448
449 #[test]
450 fn uri_verified_carries_content_hash() {
451 let hash = ContentHash(
452 "sha256:f170150ddbf59d99794e7797824591b374d459782084597b644ecc57a41031b5".into(),
453 );
454 let dr = DataRef::uri_verified(DataRefType::RawData, "https://x/d", hash.clone());
455 assert_eq!(dr.content_hash, Some(hash));
456 assert!(matches!(dr.location, Some(Location::Uri(_))));
457 }
458
459 #[test]
460 fn type_bound_uri_shortcuts_pick_the_right_type() {
461 assert_eq!(
462 DataRef::primary_result_uri("u").ref_type,
463 DataRefType::PrimaryResult
464 );
465 assert_eq!(DataRef::raw_data_uri("u").ref_type, DataRefType::RawData);
466 assert_eq!(
467 DataRef::supporting_info_uri("u").ref_type,
468 DataRefType::SupportingInfo
469 );
470 assert_eq!(
471 DataRef::derived_data_uri("u").ref_type,
472 DataRefType::DerivedData
473 );
474 }
475
476 #[test]
479 fn embedded_json_sets_json_encoding_and_format() {
480 let dr = DataRef::embedded_json(DataRefType::PrimaryResult, json!({"k": 1}));
481 let e = dr.embedded.expect("embedded set");
482 assert_eq!(e.encoding, EmbeddedEncoding::Json);
483 assert_eq!(e.content, json!({"k": 1}));
484 assert_eq!(dr.format.as_deref(), Some("application/json"));
485 assert!(dr.location.is_none(), "embedded ref has no location");
486 }
487
488 #[test]
489 fn embedded_utf8_stores_text_as_json_string() {
490 let dr = DataRef::embedded_utf8(DataRefType::SupportingInfo, "hello");
491 let e = dr.embedded.expect("embedded set");
492 assert_eq!(e.encoding, EmbeddedEncoding::Utf8);
493 assert_eq!(e.content, json!("hello"));
494 }
495
496 #[test]
497 fn embedded_base64_stores_payload_as_json_string() {
498 let dr = DataRef::embedded_base64(DataRefType::DerivedData, "aGVsbG8=");
499 let e = dr.embedded.expect("embedded set");
500 assert_eq!(e.encoding, EmbeddedEncoding::Base64);
501 assert_eq!(e.content, json!("aGVsbG8="));
502 }
503
504 #[test]
505 fn type_bound_json_shortcuts_pick_the_right_type() {
506 assert_eq!(
507 DataRef::primary_result_json(json!(1)).ref_type,
508 DataRefType::PrimaryResult
509 );
510 assert_eq!(
511 DataRef::derived_data_json(json!(1)).ref_type,
512 DataRefType::DerivedData
513 );
514 }
515
516 #[test]
519 fn data_ref_type_serializes_snake_case() {
520 assert_eq!(
521 serde_json::to_value(DataRefType::PrimaryResult).unwrap(),
522 json!("primary_result")
523 );
524 assert_eq!(
525 serde_json::to_value(DataRefType::RawData).unwrap(),
526 json!("raw_data")
527 );
528 assert_eq!(
529 serde_json::to_value(DataRefType::SupportingInfo).unwrap(),
530 json!("supporting_info")
531 );
532 assert_eq!(
533 serde_json::to_value(DataRefType::DerivedData).unwrap(),
534 json!("derived_data")
535 );
536 }
537
538 #[test]
539 fn embedded_content_rejects_unknown_field() {
540 let raw = json!({"encoding": "utf8", "content": "x", "surprise": 1});
542 let parsed: Result<EmbeddedContent, _> = serde_json::from_value(raw);
543 assert!(parsed.is_err(), "unknown field must be rejected");
544 }
545
546 #[test]
552 fn embedded_content_hash_round_trips() {
553 let without = EmbeddedContent {
554 encoding: EmbeddedEncoding::Utf8,
555 content: json!("hello"),
556 content_hash: None,
557 };
558 let v = serde_json::to_value(&without).unwrap();
559 assert!(
560 v.as_object().unwrap().get("content_hash").is_none(),
561 "None content_hash must be omitted, not emitted as null; got {v:?}"
562 );
563 let back: EmbeddedContent = serde_json::from_value(v).unwrap();
564 assert_eq!(back.content_hash, None);
565
566 let hash = ContentHash(
567 "sha256:0000000000000000000000000000000000000000000000000000000000000000".into(),
568 );
569 let with = EmbeddedContent {
570 encoding: EmbeddedEncoding::Utf8,
571 content: json!("hello"),
572 content_hash: Some(hash.clone()),
573 };
574 let v = serde_json::to_value(&with).unwrap();
575 assert_eq!(v["content_hash"], json!(hash.as_str()));
576 let back: EmbeddedContent = serde_json::from_value(v).unwrap();
577 assert_eq!(back.content_hash, Some(hash));
578 }
579
580 #[test]
581 fn constructed_uri_ref_round_trips_through_json() {
582 let dr = DataRef::uri(DataRefType::PrimaryResult, "https://x/d");
583 let v = serde_json::to_value(&dr).unwrap();
584 assert_eq!(v["type"], json!("primary_result"));
586 assert_eq!(v["location"], json!("https://x/d"));
587 assert!(v.as_object().unwrap().get("embedded").is_none());
588 let back: DataRef = serde_json::from_value(v).unwrap();
589 assert_eq!(back.ref_type, DataRefType::PrimaryResult);
590 }
591}