Skip to main content

uor_addr/schema/
photo.rs

1//! **`uor_addr::schema::photo` — Photo content-addressing**
2//! (ARCHITECTURE.md "Schema-pinned descendants" § `uor-addr-photo`).
3//!
4//! Schema-pinned descendant of [`crate::json`]. **Imports
5//! schema.org's `Photograph` type** — the host-boundary parser
6//! admits only JSON-LD values that conform to schema.org's published
7//! Photograph taxon. ψ-pipeline and κ-derivation are inherited from
8//! the JSON realization without modification.
9//!
10//! Per UOR's schema-import discipline (per the
11//! [UOR-Framework wiki](https://github.com/UOR-Foundation/UOR-Framework/wiki)),
12//! this module does **not** define a custom photo schema; it imports
13//! `https://schema.org/Photograph` and applies the schema-validation
14//! rules schema.org publishes.
15//!
16//! # `no_std` + `no_alloc`
17//!
18//! Schema admission walks the parsed [`crate::json::JsonValue`]'s
19//! tagged bytes via [`crate::json::JsonValueRef`]. There is no
20//! intermediate `serde_json::Value`; no allocator is touched.
21//!
22//! # Authoritative sources
23//!
24//! - **schema.org Photograph type** — <https://schema.org/Photograph>.
25//! - **JSON-LD 1.1** — W3C REC — <https://www.w3.org/TR/json-ld11/>.
26//!
27//! # Admission predicate (the schema.org/Photograph contract)
28//!
29//! The input must be a JSON-LD object satisfying:
30//!
31//! 1. `@context` is `"https://schema.org"` or `"http://schema.org"`.
32//! 2. `@type` is `"Photograph"`.
33//! 3. `contentUrl` — string URL.
34//! 4. `creator` — string OR object with `@type` in
35//!    `{Person, Organization}` and a `name` string.
36
37use prism::pipeline::{ShapeViolation, ViolationKind};
38
39use crate::json::{JsonValue, JsonValueRef};
40
41// ─── ShapeViolation IRIs ────────────────────────────────────────────────
42
43const PHOTO_SCHEMA_VIOLATION: ShapeViolation = ShapeViolation {
44    shape_iri: "https://schema.org/Photograph",
45    constraint_iri: "https://schema.org/Photograph/schemaOrgConformance",
46    property_iri: "https://schema.org/Photograph",
47    expected_range: "https://schema.org/Photograph",
48    min_count: 0,
49    max_count: 1,
50    kind: ViolationKind::ValueCheck,
51};
52
53/// schema.org canonical context IRIs (HTTP + HTTPS variants).
54pub const SCHEMA_ORG_CONTEXTS: &[&[u8]] = &[b"https://schema.org", b"http://schema.org"];
55
56/// schema.org Photograph type IRI fragment (used in the `@type` field).
57pub const PHOTOGRAPH_TYPE: &[u8] = b"Photograph";
58
59/// Required properties for a schema.org/Photograph instance.
60pub const REQUIRED_PROPERTIES: &[&[u8]] = &[b"@context", b"@type", b"contentUrl", b"creator"];
61
62/// Typed Photo content-addressing input. Wraps a [`JsonValue`] whose
63/// runtime JSON structure conforms to schema.org/Photograph.
64#[derive(Clone)]
65pub struct PhotoValue {
66    inner: JsonValue,
67}
68
69impl core::fmt::Debug for PhotoValue {
70    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
71        f.debug_struct("PhotoValue").finish_non_exhaustive()
72    }
73}
74
75impl PhotoValue {
76    /// Parse + admit. Accepts raw JSON bytes; admits only inputs
77    /// that conform to schema.org/Photograph.
78    pub fn parse(raw: &[u8]) -> Result<Self, ShapeViolation> {
79        let inner = JsonValue::parse(raw).map_err(|_| PHOTO_SCHEMA_VIOLATION)?;
80        let root = JsonValueRef::root(&inner);
81        if !root.is_object() {
82            return Err(PHOTO_SCHEMA_VIOLATION);
83        }
84
85        // @context — string in SCHEMA_ORG_CONTEXTS.
86        let context = root
87            .get(b"@context")
88            .and_then(|v| v.as_str())
89            .ok_or(PHOTO_SCHEMA_VIOLATION)?;
90        if !SCHEMA_ORG_CONTEXTS.contains(&context) {
91            return Err(PHOTO_SCHEMA_VIOLATION);
92        }
93
94        // @type — "Photograph".
95        let ty = root
96            .get(b"@type")
97            .and_then(|v| v.as_str())
98            .ok_or(PHOTO_SCHEMA_VIOLATION)?;
99        if ty != PHOTOGRAPH_TYPE {
100            return Err(PHOTO_SCHEMA_VIOLATION);
101        }
102
103        // contentUrl — string.
104        let _ = root
105            .get(b"contentUrl")
106            .and_then(|v| v.as_str())
107            .ok_or(PHOTO_SCHEMA_VIOLATION)?;
108
109        // creator — string OR object with @type ∈ {Person, Organization} + name.
110        let creator = root.get(b"creator").ok_or(PHOTO_SCHEMA_VIOLATION)?;
111        if creator.as_str().is_some() {
112            // string form — ok.
113        } else if creator.is_object() {
114            let ct = creator
115                .get(b"@type")
116                .and_then(|v| v.as_str())
117                .ok_or(PHOTO_SCHEMA_VIOLATION)?;
118            if ct != b"Person" && ct != b"Organization" {
119                return Err(PHOTO_SCHEMA_VIOLATION);
120            }
121            if creator.get(b"name").and_then(|v| v.as_str()).is_none() {
122                return Err(PHOTO_SCHEMA_VIOLATION);
123            }
124        } else {
125            return Err(PHOTO_SCHEMA_VIOLATION);
126        }
127
128        Ok(Self { inner })
129    }
130
131    /// Borrow the inner JSON tagged bytes.
132    #[must_use]
133    pub fn tagged_bytes(&self) -> &[u8] {
134        self.inner.tagged_bytes()
135    }
136}
137
138/// Mint a κ-label over a schema.org/Photograph-admitted JSON value.
139/// The κ-label is byte-identical to [`crate::json::address`]'s
140/// κ-label for the same JSON input — schema admission applies at
141/// parse time per SD2 Grounding, not in the ψ-pipeline.
142pub fn address(raw: &[u8]) -> Result<crate::AddressOutcome<71>, AddressFailure> {
143    PhotoValue::parse(raw).map_err(|_| AddressFailure::SchemaViolation)?;
144    crate::json::address(raw).map_err(|e| match e {
145        crate::json::AddressFailure::InvalidJson => AddressFailure::SchemaViolation,
146        crate::json::AddressFailure::PipelineFailure => AddressFailure::PipelineFailure,
147    })
148}
149
150/// As [`address`], but binds the `blake3` σ-axis ([`crate::hash`]). Schema
151/// admission is identical; only the κ-derivation hash differs.
152///
153/// # Errors
154///
155/// As [`address`].
156pub fn address_blake3(raw: &[u8]) -> Result<crate::AddressOutcome<71>, AddressFailure> {
157    PhotoValue::parse(raw).map_err(|_| AddressFailure::SchemaViolation)?;
158    crate::json::address_blake3(raw).map_err(|e| match e {
159        crate::json::AddressFailure::InvalidJson => AddressFailure::SchemaViolation,
160        crate::json::AddressFailure::PipelineFailure => AddressFailure::PipelineFailure,
161    })
162}
163
164/// As [`address`], but binds the `sha3_256` σ-axis ([`crate::hash`]). Schema
165/// admission is identical; only the κ-derivation hash differs.
166///
167/// # Errors
168///
169/// As [`address`].
170pub fn address_sha3_256(raw: &[u8]) -> Result<crate::AddressOutcome<73>, AddressFailure> {
171    PhotoValue::parse(raw).map_err(|_| AddressFailure::SchemaViolation)?;
172    crate::json::address_sha3_256(raw).map_err(|e| match e {
173        crate::json::AddressFailure::InvalidJson => AddressFailure::SchemaViolation,
174        crate::json::AddressFailure::PipelineFailure => AddressFailure::PipelineFailure,
175    })
176}
177
178/// As [`address`], but binds the `keccak256` σ-axis ([`crate::hash`]). Schema
179/// admission is identical; only the κ-derivation hash differs.
180///
181/// # Errors
182///
183/// As [`address`].
184pub fn address_keccak256(raw: &[u8]) -> Result<crate::AddressOutcome<74>, AddressFailure> {
185    PhotoValue::parse(raw).map_err(|_| AddressFailure::SchemaViolation)?;
186    crate::json::address_keccak256(raw).map_err(|e| match e {
187        crate::json::AddressFailure::InvalidJson => AddressFailure::SchemaViolation,
188        crate::json::AddressFailure::PipelineFailure => AddressFailure::PipelineFailure,
189    })
190}
191
192/// As [`address`], but binds the `sha512` σ-axis ([`crate::hash`]).
193///
194/// # Errors
195///
196/// As [`address`].
197pub fn address_sha512(raw: &[u8]) -> Result<crate::AddressOutcome<135, 64>, AddressFailure> {
198    PhotoValue::parse(raw).map_err(|_| AddressFailure::SchemaViolation)?;
199    crate::json::address_sha512(raw).map_err(|e| match e {
200        crate::json::AddressFailure::InvalidJson => AddressFailure::SchemaViolation,
201        crate::json::AddressFailure::PipelineFailure => AddressFailure::PipelineFailure,
202    })
203}
204
205/// Failure modes from [`address`].
206#[derive(Debug, Clone, Copy, PartialEq, Eq)]
207pub enum AddressFailure {
208    /// Input did not conform to schema.org/Photograph.
209    SchemaViolation,
210    /// Defensive: substrate-level shape violation.
211    PipelineFailure,
212}
213
214/// **Available only under the `alloc` feature.** Canonical-bytes
215/// accessor — the schema admission applies at ingress; the canonical
216/// bytes are JCS-RFC8785 + NFC per the JSON realization.
217#[cfg(feature = "alloc")]
218pub fn canonicalize(raw: &[u8]) -> Result<alloc::vec::Vec<u8>, AddressFailure> {
219    extern crate alloc;
220    PhotoValue::parse(raw).map_err(|_| AddressFailure::SchemaViolation)?;
221    crate::json::canonicalize(raw).map_err(|_| AddressFailure::PipelineFailure)
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227
228    const VALID_PHOTO: &[u8] = br#"{
229        "@context": "https://schema.org",
230        "@type": "Photograph",
231        "contentUrl": "https://example.org/photo.jpg",
232        "creator": {"@type": "Person", "name": "Ada Lovelace"}
233    }"#;
234
235    #[test]
236    fn admits_valid_schema_org_photograph() {
237        let p = PhotoValue::parse(VALID_PHOTO).expect("valid");
238        assert!(!p.tagged_bytes().is_empty());
239    }
240
241    #[test]
242    fn admits_string_creator() {
243        let raw = br#"{
244            "@context": "https://schema.org",
245            "@type": "Photograph",
246            "contentUrl": "https://example.org/photo.jpg",
247            "creator": "Ada Lovelace"
248        }"#;
249        let p = PhotoValue::parse(raw).expect("valid");
250        assert!(!p.tagged_bytes().is_empty());
251    }
252
253    #[test]
254    fn admits_http_context() {
255        let raw = br#"{
256            "@context": "http://schema.org",
257            "@type": "Photograph",
258            "contentUrl": "https://example.org/photo.jpg",
259            "creator": "Ada Lovelace"
260        }"#;
261        PhotoValue::parse(raw).expect("valid");
262    }
263
264    #[test]
265    fn rejects_wrong_context() {
266        let raw = br#"{
267            "@context": "https://example.org/custom",
268            "@type": "Photograph",
269            "contentUrl": "https://example.org/photo.jpg",
270            "creator": "Ada Lovelace"
271        }"#;
272        let err = PhotoValue::parse(raw).expect_err("not schema.org");
273        assert_eq!(err.constraint_iri, PHOTO_SCHEMA_VIOLATION.constraint_iri);
274    }
275
276    #[test]
277    fn rejects_wrong_type() {
278        let raw = br#"{
279            "@context": "https://schema.org",
280            "@type": "Article",
281            "contentUrl": "https://example.org/photo.jpg",
282            "creator": "Ada Lovelace"
283        }"#;
284        let err = PhotoValue::parse(raw).expect_err("not Photograph");
285        assert_eq!(err.constraint_iri, PHOTO_SCHEMA_VIOLATION.constraint_iri);
286    }
287
288    #[test]
289    fn rejects_missing_content_url() {
290        let raw = br#"{
291            "@context": "https://schema.org",
292            "@type": "Photograph",
293            "creator": "Ada Lovelace"
294        }"#;
295        let err = PhotoValue::parse(raw).expect_err("missing contentUrl");
296        assert_eq!(err.constraint_iri, PHOTO_SCHEMA_VIOLATION.constraint_iri);
297    }
298
299    #[test]
300    fn rejects_creator_with_unsupported_type() {
301        let raw = br#"{
302            "@context": "https://schema.org",
303            "@type": "Photograph",
304            "contentUrl": "https://example.org/photo.jpg",
305            "creator": {"@type": "Robot", "name": "A.L.I.C.E."}
306        }"#;
307        let err = PhotoValue::parse(raw).expect_err("unsupported creator @type");
308        assert_eq!(err.constraint_iri, PHOTO_SCHEMA_VIOLATION.constraint_iri);
309    }
310
311    #[test]
312    fn address_matches_json_realization_for_admitted_input() {
313        let from_photo = address(VALID_PHOTO).expect("κ-label").address;
314        let from_json = crate::json::address(VALID_PHOTO).expect("κ-label").address;
315        assert_eq!(from_photo, from_json);
316    }
317}