Skip to main content

golem_schema/schema/
unstructured.rs

1// Copyright 2024-2026 Golem Cloud
2//
3// Licensed under the Golem Source License v1.1 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://license.golem.cloud/LICENSE
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Canonical "unstructured" text/binary carriers.
16//!
17//! Unstructured text/binary fields are represented as a role-marked
18//! `variant { inline, url }` wrapper (or, accepted on input, a bare `Text` /
19//! `Binary` rich scalar). This module is the single home for building,
20//! detecting, and decoding that canonical shape.
21
22use crate::schema::graph::{RefResolutionError, SchemaGraph};
23use crate::schema::metadata::Role;
24use crate::schema::schema_type::{
25    BinaryRestrictions, SchemaType, TextRestrictions, UrlRestrictions, VariantCaseType,
26};
27use crate::schema::schema_value::{SchemaValue, VariantValuePayload};
28
29/// Error produced while building, detecting, or decoding a canonical
30/// unstructured text/binary carrier.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct UnstructuredError(pub String);
33
34impl std::fmt::Display for UnstructuredError {
35    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36        write!(f, "{}", self.0)
37    }
38}
39
40impl std::error::Error for UnstructuredError {}
41
42impl From<RefResolutionError> for UnstructuredError {
43    fn from(err: RefResolutionError) -> Self {
44        UnstructuredError(err.to_string())
45    }
46}
47
48pub const INLINE_CASE: u32 = 0;
49/// The case index of the `url` alternative in the canonical variant.
50pub const URL_CASE: u32 = 1;
51
52const INLINE_CASE_NAME: &str = "inline";
53const URL_CASE_NAME: &str = "url";
54
55/// Build the canonical role-marked unstructured-text variant
56/// `variant { inline: text<restrictions>, url: url } with Role::UnstructuredText`.
57pub fn unstructured_text_schema_type(restrictions: TextRestrictions) -> SchemaType {
58    let mut ty = SchemaType::variant(vec![
59        VariantCaseType {
60            name: INLINE_CASE_NAME.to_string(),
61            payload: Some(SchemaType::text(restrictions)),
62            metadata: Default::default(),
63        },
64        VariantCaseType {
65            name: URL_CASE_NAME.to_string(),
66            payload: Some(SchemaType::url(UrlRestrictions::default())),
67            metadata: Default::default(),
68        },
69    ]);
70    ty.metadata_mut().role = Some(Role::UnstructuredText);
71    ty
72}
73
74/// Build the canonical role-marked unstructured-binary variant
75/// `variant { inline: binary<restrictions>, url: url } with Role::UnstructuredBinary`.
76pub fn unstructured_binary_schema_type(restrictions: BinaryRestrictions) -> SchemaType {
77    let mut ty = SchemaType::variant(vec![
78        VariantCaseType {
79            name: INLINE_CASE_NAME.to_string(),
80            payload: Some(SchemaType::binary(restrictions)),
81            metadata: Default::default(),
82        },
83        VariantCaseType {
84            name: URL_CASE_NAME.to_string(),
85            payload: Some(SchemaType::url(UrlRestrictions::default())),
86            metadata: Default::default(),
87        },
88    ]);
89    ty.metadata_mut().role = Some(Role::UnstructuredBinary);
90    ty
91}
92
93/// Whether `ty` is *itself* a role-marked unstructured-text/binary variant.
94///
95/// Graph-free: it inspects the node's own `metadata.role` and does **not**
96/// resolve [`SchemaType::Ref`]s. Use it where no [`SchemaGraph`] is available
97/// (e.g. the type-naming leaf/name classifiers); the guest SDKs and the legacy
98/// adapter always publish the wrapper inline, so refs do not arise in practice.
99pub fn is_unstructured_variant(ty: &SchemaType) -> bool {
100    matches!(
101        ty,
102        SchemaType::Variant { metadata, .. }
103            if matches!(
104                metadata.role,
105                Some(Role::UnstructuredText) | Some(Role::UnstructuredBinary)
106            )
107    )
108}
109
110/// If `ty` (resolved against `graph`) is the canonical role-marked
111/// unstructured-**text** variant, return the inline text restrictions.
112///
113/// The role marker is authoritative, but the structural shape is also checked
114/// (case 0 is an inline `text`, case 1 is a `url`) so a mis-marked variant is
115/// rejected rather than silently mis-rendered.
116pub fn unstructured_text_restrictions<'a>(
117    graph: &'a SchemaGraph,
118    ty: &'a SchemaType,
119) -> Result<Option<&'a TextRestrictions>, UnstructuredError> {
120    let resolved = graph.resolve_ref(ty)?;
121    if resolved.metadata().role != Some(Role::UnstructuredText) {
122        return Ok(None);
123    }
124    // The role marker is authoritative: a node tagged `Role::UnstructuredText`
125    // that is not the canonical `variant { inline, url }` shape is a hard error,
126    // not "merely not unstructured".
127    let SchemaType::Variant { cases, .. } = resolved else {
128        return Err(UnstructuredError(
129            "Role::UnstructuredText must mark a variant type".into(),
130        ));
131    };
132    let [inline, url] = cases.as_slice() else {
133        return Err(UnstructuredError(
134            "Role::UnstructuredText variant must have exactly an `inline` and a `url` case".into(),
135        ));
136    };
137    if inline.name != INLINE_CASE_NAME || url.name != URL_CASE_NAME {
138        return Err(UnstructuredError(
139            "Role::UnstructuredText variant cases must be exactly `inline`, `url` in that order"
140                .into(),
141        ));
142    }
143    let inline_payload = inline.payload.as_ref().ok_or_else(|| {
144        UnstructuredError("Role::UnstructuredText `inline` case must carry a text payload".into())
145    })?;
146    let SchemaType::Text { restrictions, .. } = graph.resolve_ref(inline_payload)? else {
147        return Err(UnstructuredError(
148            "Role::UnstructuredText `inline` case payload must be a text type".into(),
149        ));
150    };
151    check_url_case(graph, url, "Role::UnstructuredText")?;
152    Ok(Some(restrictions))
153}
154
155/// If `ty` (resolved against `graph`) is the canonical role-marked
156/// unstructured-**binary** variant, return the inline binary restrictions.
157pub fn unstructured_binary_restrictions<'a>(
158    graph: &'a SchemaGraph,
159    ty: &'a SchemaType,
160) -> Result<Option<&'a BinaryRestrictions>, UnstructuredError> {
161    let resolved = graph.resolve_ref(ty)?;
162    if resolved.metadata().role != Some(Role::UnstructuredBinary) {
163        return Ok(None);
164    }
165    // The role marker is authoritative: a node tagged `Role::UnstructuredBinary`
166    // that is not the canonical `variant { inline, url }` shape is a hard error,
167    // not "merely not unstructured".
168    let SchemaType::Variant { cases, .. } = resolved else {
169        return Err(UnstructuredError(
170            "Role::UnstructuredBinary must mark a variant type".into(),
171        ));
172    };
173    let [inline, url] = cases.as_slice() else {
174        return Err(UnstructuredError(
175            "Role::UnstructuredBinary variant must have exactly an `inline` and a `url` case"
176                .into(),
177        ));
178    };
179    if inline.name != INLINE_CASE_NAME || url.name != URL_CASE_NAME {
180        return Err(UnstructuredError(
181            "Role::UnstructuredBinary variant cases must be exactly `inline`, `url` in that order"
182                .into(),
183        ));
184    }
185    let inline_payload = inline.payload.as_ref().ok_or_else(|| {
186        UnstructuredError(
187            "Role::UnstructuredBinary `inline` case must carry a binary payload".into(),
188        )
189    })?;
190    let SchemaType::Binary { restrictions, .. } = graph.resolve_ref(inline_payload)? else {
191        return Err(UnstructuredError(
192            "Role::UnstructuredBinary `inline` case payload must be a binary type".into(),
193        ));
194    };
195    check_url_case(graph, url, "Role::UnstructuredBinary")?;
196    Ok(Some(restrictions))
197}
198
199fn check_url_case(
200    graph: &SchemaGraph,
201    url: &VariantCaseType,
202    role: &str,
203) -> Result<(), UnstructuredError> {
204    let url_payload = url
205        .payload
206        .as_ref()
207        .ok_or_else(|| UnstructuredError(format!("{role} `url` case must carry a url payload")))?;
208    if !matches!(graph.resolve_ref(url_payload)?, SchemaType::Url { .. }) {
209        return Err(UnstructuredError(format!(
210            "{role} `url` case payload must be a url type"
211        )));
212    }
213    Ok(())
214}
215
216/// Which flavour of unstructured wrapper a type is, together with the inline
217/// case's restrictions.
218pub enum UnstructuredKind<'a> {
219    /// `variant { inline: text<…>, url } with Role::UnstructuredText`.
220    Text(&'a TextRestrictions),
221    /// `variant { inline: binary<…>, url } with Role::UnstructuredBinary`.
222    Binary(&'a BinaryRestrictions),
223}
224
225/// If `ty` (resolved against `graph`) is a canonical role-marked unstructured
226/// variant, classify it as text or binary and return the inline restrictions.
227///
228/// This is the single classifier the request/response and MCP seams branch on,
229/// so they never re-match the variant shape (or its role marker) by hand.
230pub fn unstructured_kind<'a>(
231    graph: &'a SchemaGraph,
232    ty: &'a SchemaType,
233) -> Result<Option<UnstructuredKind<'a>>, UnstructuredError> {
234    if let Some(restrictions) = unstructured_text_restrictions(graph, ty)? {
235        Ok(Some(UnstructuredKind::Text(restrictions)))
236    } else if let Some(restrictions) = unstructured_binary_restrictions(graph, ty)? {
237        Ok(Some(UnstructuredKind::Binary(restrictions)))
238    } else {
239        Ok(None)
240    }
241}
242
243/// Read the binary restrictions from a request/response body root that is
244/// *known* to carry unstructured binary — either the canonical
245/// `variant { inline, url }` wrapper or a bare `Binary` rich scalar.
246///
247/// Unlike [`unstructured_binary_restrictions`] (which returns `None` for a
248/// non-wrapper), this errors if the root is neither a binary wrapper nor a bare
249/// binary scalar, so a malformed compiled `BinaryBody` schema fails loudly
250/// rather than silently degrading to "unrestricted". Used by the HTTP request
251/// decoder and the OpenAPI emitter so both read restrictions identically.
252pub fn binary_body_restrictions<'a>(
253    graph: &'a SchemaGraph,
254    ty: &'a SchemaType,
255) -> Result<&'a BinaryRestrictions, UnstructuredError> {
256    if let Some(restrictions) = unstructured_binary_restrictions(graph, ty)? {
257        return Ok(restrictions);
258    }
259    match graph.resolve_ref(ty)? {
260        SchemaType::Binary { restrictions, .. } => Ok(restrictions),
261        _ => Err(UnstructuredError(
262            "binary body schema root is neither an unstructured-binary wrapper nor a bare binary type"
263                .into(),
264        )),
265    }
266}
267
268/// Read the text restrictions from a request/response body root that is *known*
269/// to carry unstructured text — either the canonical `variant { inline, url }`
270/// wrapper or a bare `Text` rich scalar.
271///
272/// The text analogue of [`binary_body_restrictions`]; errors if the root is
273/// neither a text wrapper nor a bare text scalar.
274pub fn text_body_restrictions<'a>(
275    graph: &'a SchemaGraph,
276    ty: &'a SchemaType,
277) -> Result<&'a TextRestrictions, UnstructuredError> {
278    if let Some(restrictions) = unstructured_text_restrictions(graph, ty)? {
279        return Ok(restrictions);
280    }
281    match graph.resolve_ref(ty)? {
282        SchemaType::Text { restrictions, .. } => Ok(restrictions),
283        _ => Err(UnstructuredError(
284            "text body schema root is neither an unstructured-text wrapper nor a bare text type"
285                .into(),
286        )),
287    }
288}
289
290/// Which flavour of unstructured payload a type is, irrespective of whether it
291/// is carried by a canonical `variant { inline, url }` wrapper or a bare
292/// `Text` / `Binary` rich scalar.
293#[derive(Debug, Clone, Copy, PartialEq, Eq)]
294pub enum UnstructuredPayloadKind {
295    Text,
296    Binary,
297}
298
299/// Classify `ty` (resolved against `graph`) as an unstructured text/binary
300/// payload — either a canonical `variant { inline, url }` wrapper or a bare
301/// `Text` / `Binary` rich scalar — returning which flavour, or `None`.
302///
303/// This is the single classifier the request/response and MCP seams branch on
304/// when they need to treat *both* shapes uniformly (the wrapper is the
305/// canonical form, but a bare rich scalar is still accepted). Callers that need
306/// the inline restrictions use [`unstructured_kind`] directly.
307pub fn unstructured_or_raw_kind(
308    graph: &SchemaGraph,
309    ty: &SchemaType,
310) -> Result<Option<UnstructuredPayloadKind>, UnstructuredError> {
311    match unstructured_kind(graph, ty)? {
312        Some(UnstructuredKind::Text(_)) => return Ok(Some(UnstructuredPayloadKind::Text)),
313        Some(UnstructuredKind::Binary(_)) => return Ok(Some(UnstructuredPayloadKind::Binary)),
314        None => {}
315    }
316    Ok(match graph.resolve_ref(ty)? {
317        SchemaType::Text { .. } => Some(UnstructuredPayloadKind::Text),
318        SchemaType::Binary { .. } => Some(UnstructuredPayloadKind::Binary),
319        _ => None,
320    })
321}
322
323/// Wrap a raw `text` / `binary` value as the canonical unstructured `inline`
324/// case (case 0) when `ty` (resolved against `graph`) is an unstructured
325/// `variant { inline, url }` wrapper; otherwise return the raw value unchanged
326/// (for a bare `Text` / `Binary` rich scalar).
327///
328/// This is the inverse of [`decode_unstructured_value`]'s inline case: callers
329/// that turn a raw transport payload into a schema value (e.g. an HTTP request
330/// body) use it so the produced value matches the field's schema regardless of
331/// whether that schema is the wrapper or the bare rich scalar.
332pub fn wrap_unstructured_inline_for_schema(
333    graph: &SchemaGraph,
334    ty: &SchemaType,
335    raw: SchemaValue,
336) -> Result<SchemaValue, UnstructuredError> {
337    if unstructured_kind(graph, ty)?.is_some() {
338        Ok(unstructured_inline_value(raw))
339    } else {
340        Ok(raw)
341    }
342}
343
344/// A decoded unstructured *output* value, classified against its schema.
345pub enum UnstructuredOutput<'a> {
346    /// An inline payload, validated to match the schema's kind: guaranteed to
347    /// be a `SchemaValue::Text` for a text output or `SchemaValue::Binary` for a
348    /// binary output (whether the schema is the canonical wrapper or a bare
349    /// rich scalar).
350    Inline(&'a SchemaValue),
351    /// A URL reference (only the canonical wrapper's `url` case yields this).
352    Url(&'a str),
353}
354
355/// Decode an unstructured *output* value against its schema `ty`, accepting both
356/// the canonical `variant { inline, url }` wrapper and a bare `Text` / `Binary`
357/// rich scalar, and validating that the runtime `value` matches the declared
358/// schema kind.
359///
360/// Returns `Ok(None)` when `ty` is not an unstructured carrier, so the caller
361/// falls back to component-model rendering. Returns an error when `ty` *is* an
362/// unstructured carrier but `value` does not match it (e.g. an
363/// unstructured-text wrapper whose `inline` payload is binary, or a bare `Text`
364/// schema paired with a non-text value) — the boundary surfaces the mismatch
365/// rather than silently rendering by value shape.
366///
367/// This is the single schema-driven classifier the HTTP-response and MCP
368/// tool/resource output seams branch on, so none of them re-derive the
369/// wrapper-vs-bare / inline-vs-url / value-kind logic by hand.
370pub fn decode_unstructured_output<'a>(
371    graph: &SchemaGraph,
372    ty: &SchemaType,
373    value: &'a SchemaValue,
374) -> Result<Option<UnstructuredOutput<'a>>, UnstructuredError> {
375    // Canonical wrapper schema: accept *either* a wrapper runtime value
376    // (`variant { inline, url }`) or a raw `Text` / `Binary` rich scalar (DE:
377    // both runtime representations are valid under a wrapper schema). The `url`
378    // case can only ever arrive via the wrapper value path — a bare scalar
379    // never yields a redirect.
380    if let Some(kind) = unstructured_kind(graph, ty)? {
381        let expected = match kind {
382            UnstructuredKind::Text(_) => UnstructuredPayloadKind::Text,
383            UnstructuredKind::Binary(_) => UnstructuredPayloadKind::Binary,
384        };
385        return Ok(Some(match value {
386            SchemaValue::Variant(_) => match decode_unstructured_value(value)? {
387                UnstructuredValueCase::Url(url) => UnstructuredOutput::Url(url),
388                UnstructuredValueCase::Inline(inline) => {
389                    check_unstructured_value_kind(inline, expected)?;
390                    UnstructuredOutput::Inline(inline)
391                }
392            },
393            raw => {
394                check_unstructured_value_kind(raw, expected)?;
395                UnstructuredOutput::Inline(raw)
396            }
397        }));
398    }
399
400    // Bare `Text` / `Binary` rich scalar: the value must match the schema kind.
401    let expected = match graph.resolve_ref(ty)? {
402        SchemaType::Text { .. } => UnstructuredPayloadKind::Text,
403        SchemaType::Binary { .. } => UnstructuredPayloadKind::Binary,
404        _ => return Ok(None),
405    };
406    check_unstructured_value_kind(value, expected)?;
407    Ok(Some(UnstructuredOutput::Inline(value)))
408}
409
410fn check_unstructured_value_kind(
411    value: &SchemaValue,
412    expected: UnstructuredPayloadKind,
413) -> Result<(), UnstructuredError> {
414    let matches = match expected {
415        UnstructuredPayloadKind::Text => matches!(value, SchemaValue::Text(_)),
416        UnstructuredPayloadKind::Binary => matches!(value, SchemaValue::Binary(_)),
417    };
418    if matches {
419        Ok(())
420    } else {
421        let kind = match expected {
422            UnstructuredPayloadKind::Text => "text",
423            UnstructuredPayloadKind::Binary => "binary",
424        };
425        Err(UnstructuredError(format!(
426            "expected a {kind} value for an unstructured {kind} output"
427        )))
428    }
429}
430
431/// The selected case of a canonical unstructured `variant { inline, url }` value.
432pub enum UnstructuredValueCase<'a> {
433    /// The `inline` case (case 0): the inner `text` / `binary` value payload.
434    Inline(&'a SchemaValue),
435    /// The `url` case (case 1): the referenced URL.
436    Url(&'a str),
437}
438
439/// Interpret a [`SchemaValue`] as a canonical unstructured `variant { inline, url }`
440/// value, returning the selected case.
441///
442/// Used by every consumer that turns an unstructured value into a transport
443/// representation (HTTP body / redirect, MCP content / resource link) so the
444/// inline-vs-url dispatch lives in exactly one place.
445pub fn decode_unstructured_value(
446    value: &SchemaValue,
447) -> Result<UnstructuredValueCase<'_>, UnstructuredError> {
448    let SchemaValue::Variant(VariantValuePayload { case, payload }) = value else {
449        return Err(UnstructuredError(
450            "expected a variant value for an unstructured text/binary type".into(),
451        ));
452    };
453    match *case {
454        INLINE_CASE => {
455            let payload = payload.as_deref().ok_or_else(|| {
456                UnstructuredError("unstructured `inline` value must carry a payload".into())
457            })?;
458            Ok(UnstructuredValueCase::Inline(payload))
459        }
460        URL_CASE => {
461            let payload = payload.as_deref().ok_or_else(|| {
462                UnstructuredError("unstructured `url` value must carry a payload".into())
463            })?;
464            let SchemaValue::Url { url } = payload else {
465                return Err(UnstructuredError(
466                    "unstructured `url` value payload must be a url".into(),
467                ));
468            };
469            Ok(UnstructuredValueCase::Url(url))
470        }
471        other => Err(UnstructuredError(format!(
472            "unknown unstructured variant case {other}"
473        ))),
474    }
475}
476
477/// Build a canonical unstructured `inline` value (case 0) wrapping the inner
478/// `text` / `binary` value payload.
479pub fn unstructured_inline_value(inline: SchemaValue) -> SchemaValue {
480    SchemaValue::Variant(VariantValuePayload {
481        case: INLINE_CASE,
482        payload: Some(Box::new(inline)),
483    })
484}
485
486/// Build a canonical unstructured `url` value (case 1).
487pub fn unstructured_url_value(url: String) -> SchemaValue {
488    SchemaValue::Variant(VariantValuePayload {
489        case: URL_CASE,
490        payload: Some(Box::new(SchemaValue::Url { url })),
491    })
492}