Skip to main content

gestalt/
catalog.rs

1use std::collections::BTreeSet;
2use std::path::Path;
3
4use schemars::JsonSchema;
5use serde_json::{Value as JsonValue, json};
6
7use crate::error::{Error, Result};
8use crate::generated::v1;
9
10/// Catalog schema used by the provider runtime.
11#[derive(Clone, Debug, Default, PartialEq)]
12pub struct Catalog {
13    /// The `name` field.
14    pub name: String,
15    /// The `display_name` field.
16    pub display_name: String,
17    /// The `description` field.
18    pub description: String,
19    /// The `icon_svg` field.
20    pub icon_svg: String,
21    /// The `operations` field.
22    pub operations: Vec<CatalogOperation>,
23}
24
25/// One operation exposed by a catalog.
26#[derive(Clone, Debug, Default, PartialEq)]
27pub struct CatalogOperation {
28    /// The `id` field.
29    pub id: String,
30    /// The `method` field.
31    pub method: String,
32    /// The `title` field.
33    pub title: String,
34    /// The `description` field.
35    pub description: String,
36    /// The `input_schema` field.
37    pub input_schema: String,
38    /// The `output_schema` field (deprecated; use `response`).
39    pub output_schema: String,
40    /// The `response` field; when set, takes precedence over `output_schema`.
41    pub response: Option<OperationResponseSpec>,
42    /// The `annotations` field.
43    pub annotations: Option<OperationAnnotations>,
44    /// The `parameters` field.
45    pub parameters: Vec<CatalogParameter>,
46    /// The `required_scopes` field.
47    pub required_scopes: Vec<String>,
48    /// The `tags` field.
49    pub tags: Vec<String>,
50    /// The `read_only` field.
51    pub read_only: bool,
52    /// The `visible` field.
53    pub visible: Option<bool>,
54    /// The `transport` field.
55    pub transport: String,
56    /// The `allowed_roles` field.
57    pub allowed_roles: Vec<String>,
58}
59
60/// One input parameter surfaced in a generated catalog operation.
61#[derive(Clone, Debug, Default, PartialEq)]
62pub struct CatalogParameter {
63    /// The `name` field.
64    pub name: String,
65    /// The `type` field.
66    pub r#type: String,
67    /// The `description` field.
68    pub description: String,
69    /// The `required` field.
70    pub required: bool,
71    /// The `default` field.
72    pub default: Option<JsonValue>,
73}
74
75/// Optional host hints attached to a catalog operation.
76#[derive(Clone, Debug, Default, Eq, PartialEq)]
77pub struct OperationAnnotations {
78    /// The `read_only_hint` field.
79    pub read_only_hint: Option<bool>,
80    /// The `idempotent_hint` field.
81    pub idempotent_hint: Option<bool>,
82    /// The `destructive_hint` field.
83    pub destructive_hint: Option<bool>,
84    /// The `open_world_hint` field.
85    pub open_world_hint: Option<bool>,
86}
87
88/// UnaryResponseSpec describes a unary (fully materialized) operation response.
89/// `schema` is a JSON-encoded schema object (JSON Schema shape).
90#[derive(Clone, Debug, Default, PartialEq)]
91pub struct UnaryResponseSpec {
92    /// The `schema` field; a JSON-encoded schema string.
93    pub schema: String,
94}
95
96/// StreamResponseSpec describes a streaming operation response. `media_type`
97/// names the representation (for example `application/x-ndjson`); `item_schema`
98/// is an optional JSON-encoded schema describing one yielded item.
99#[derive(Clone, Debug, Default, PartialEq)]
100pub struct StreamResponseSpec {
101    /// The `media_type` field.
102    pub media_type: String,
103    /// The `item_schema` field; a JSON-encoded schema string.
104    pub item_schema: String,
105}
106
107/// OperationResponseSpec declares how an operation responds. Either `unary` or
108/// `stream` is set; both `None` means unary with no schema. When set on a
109/// `CatalogOperation`, it takes precedence over the legacy `output_schema`.
110#[derive(Clone, Debug, Default, PartialEq)]
111pub struct OperationResponseSpec {
112    /// The `unary` variant.
113    pub unary: Option<UnaryResponseSpec>,
114    /// The `stream` variant.
115    pub stream: Option<StreamResponseSpec>,
116}
117
118impl OperationResponseSpec {
119    /// Reports whether this response spec declares a streaming response.
120    pub fn is_stream(&self) -> bool {
121        self.stream.is_some()
122    }
123}
124
125impl Catalog {
126    /// Returns a copy of the catalog with a non-empty name override applied.
127    pub fn with_name(mut self, name: impl Into<String>) -> Self {
128        let name = name.into();
129        if !name.trim().is_empty() {
130            self.name = name;
131        }
132        self
133    }
134}
135
136/// Writes catalog to path using the JSON shape expected by `gestaltd`.
137pub fn write_catalog(catalog: &Catalog, path: impl AsRef<Path>) -> Result<()> {
138    let path = path.as_ref();
139    if let Some(parent) = path.parent()
140        && !parent.as_os_str().is_empty()
141    {
142        std::fs::create_dir_all(parent)?;
143    }
144    let json = serde_json::to_string_pretty(&catalog_to_json_value(catalog))?;
145    std::fs::write(path, json)?;
146    Ok(())
147}
148
149pub(crate) fn catalog_to_proto(catalog: &Catalog) -> v1::Catalog {
150    v1::Catalog {
151        name: catalog.name.clone(),
152        display_name: catalog.display_name.clone(),
153        description: catalog.description.clone(),
154        icon_svg: catalog.icon_svg.clone(),
155        operations: catalog.operations.iter().map(operation_to_proto).collect(),
156    }
157}
158
159/// response_to_proto converts the authoring `OperationResponseSpec` to its proto
160/// form. When `response` is `None` but `legacy_output_schema` is non-empty, it
161/// is mapped to a unary response with that schema for backward compatibility.
162/// An empty/unparseable schema yields `None`.
163fn response_to_proto(
164    response: &Option<OperationResponseSpec>,
165    legacy_output_schema: &str,
166) -> Option<v1::OperationResponseSpec> {
167    if let Some(spec) = response {
168        if let Some(stream) = &spec.stream {
169            return Some(v1::OperationResponseSpec {
170                kind: Some(v1::operation_response_spec::Kind::Stream(
171                    v1::StreamResponseSpec {
172                        media_type: stream.media_type.clone(),
173                        item_schema: schema_string_to_struct(&stream.item_schema),
174                    },
175                )),
176            });
177        }
178        if let Some(unary) = &spec.unary {
179            return Some(v1::OperationResponseSpec {
180                kind: Some(v1::operation_response_spec::Kind::Unary(
181                    v1::UnaryResponseSpec {
182                        schema: schema_string_to_struct(&unary.schema),
183                    },
184                )),
185            });
186        }
187    }
188    if legacy_output_schema.trim().is_empty() {
189        return None;
190    }
191    let struct_value = schema_string_to_struct(legacy_output_schema)?;
192    Some(v1::OperationResponseSpec {
193        kind: Some(v1::operation_response_spec::Kind::Unary(
194            v1::UnaryResponseSpec {
195                schema: Some(struct_value),
196            },
197        )),
198    })
199}
200
201/// schema_string_to_struct parses a JSON-encoded schema string into a
202/// protobuf Struct. An empty string or parse error yields None.
203fn schema_string_to_struct(schema: &str) -> Option<prost_types::Struct> {
204    let schema = schema.trim();
205    if schema.is_empty() {
206        return None;
207    }
208    let parsed = serde_json::from_str::<JsonValue>(schema).ok()?;
209    let mut fields = std::collections::BTreeMap::new();
210    if let JsonValue::Object(map) = parsed {
211        for (k, v) in map {
212            fields.insert(k, json_value_to_prost_value(v));
213        }
214    }
215    Some(prost_types::Struct { fields })
216}
217
218/// json_value_to_prost_value converts a serde_json::Value into a prost_types::Value.
219fn json_value_to_prost_value(value: JsonValue) -> prost_types::Value {
220    use prost_types::value::Kind;
221    let kind = match value {
222        JsonValue::Null => Kind::NullValue(0),
223        JsonValue::Bool(b) => Kind::BoolValue(b),
224        JsonValue::Number(n) => {
225            if let Some(i) = n.as_i64() {
226                Kind::NumberValue(i as f64)
227            } else {
228                Kind::NumberValue(n.as_f64().unwrap_or(0.0))
229            }
230        }
231        JsonValue::String(s) => Kind::StringValue(s),
232        JsonValue::Array(arr) => Kind::ListValue(prost_types::ListValue {
233            values: arr.into_iter().map(json_value_to_prost_value).collect(),
234        }),
235        JsonValue::Object(map) => {
236            let mut fields = std::collections::BTreeMap::new();
237            for (k, v) in map {
238                fields.insert(k, json_value_to_prost_value(v));
239            }
240            Kind::StructValue(prost_types::Struct { fields })
241        }
242    };
243    prost_types::Value { kind: Some(kind) }
244}
245
246fn operation_to_proto(operation: &CatalogOperation) -> v1::CatalogOperation {
247    v1::CatalogOperation {
248        id: operation.id.clone(),
249        method: operation.method.clone(),
250        title: operation.title.clone(),
251        description: operation.description.clone(),
252        input_schema: operation.input_schema.clone(),
253        response: response_to_proto(&operation.response, &operation.output_schema),
254        annotations: operation.annotations.as_ref().map(annotations_to_proto),
255        parameters: operation
256            .parameters
257            .iter()
258            .map(parameter_to_proto)
259            .collect(),
260        required_scopes: operation.required_scopes.clone(),
261        tags: operation.tags.clone(),
262        read_only: operation.read_only,
263        visible: operation.visible,
264        transport: operation.transport.clone(),
265        allowed_roles: operation.allowed_roles.clone(),
266    }
267}
268
269fn annotations_to_proto(annotations: &OperationAnnotations) -> v1::OperationAnnotations {
270    v1::OperationAnnotations {
271        read_only_hint: annotations.read_only_hint,
272        idempotent_hint: annotations.idempotent_hint,
273        destructive_hint: annotations.destructive_hint,
274        open_world_hint: annotations.open_world_hint,
275    }
276}
277
278fn parameter_to_proto(parameter: &CatalogParameter) -> v1::CatalogParameter {
279    v1::CatalogParameter {
280        name: parameter.name.clone(),
281        r#type: parameter.r#type.clone(),
282        description: parameter.description.clone(),
283        required: parameter.required,
284        default: parameter.default.as_ref().map(json_value_to_proto_value),
285    }
286}
287
288fn catalog_to_json_value(catalog: &Catalog) -> JsonValue {
289    let mut obj = serde_json::Map::new();
290    obj.insert("name".to_owned(), json!(catalog.name));
291    if !catalog.display_name.is_empty() {
292        obj.insert("displayName".to_owned(), json!(catalog.display_name));
293    }
294    if !catalog.description.is_empty() {
295        obj.insert("description".to_owned(), json!(catalog.description));
296    }
297    if !catalog.icon_svg.is_empty() {
298        obj.insert("iconSvg".to_owned(), json!(catalog.icon_svg));
299    }
300    let ops: Vec<JsonValue> = catalog
301        .operations
302        .iter()
303        .map(operation_to_json_value)
304        .collect();
305    obj.insert("operations".to_owned(), json!(ops));
306    JsonValue::Object(obj)
307}
308
309fn operation_to_json_value(op: &CatalogOperation) -> JsonValue {
310    let mut obj = serde_json::Map::new();
311    obj.insert("id".to_owned(), json!(op.id));
312    obj.insert("method".to_owned(), json!(op.method));
313    if !op.title.is_empty() {
314        obj.insert("title".to_owned(), json!(op.title));
315    }
316    if !op.description.is_empty() {
317        obj.insert("description".to_owned(), json!(op.description));
318    }
319    if !op.input_schema.is_empty() {
320        if let Ok(schema) = serde_json::from_str::<JsonValue>(&op.input_schema) {
321            obj.insert("inputSchema".to_owned(), schema);
322        }
323    }
324    if let Some(ref spec) = op.response {
325        obj.insert("response".to_owned(), response_to_json_value(spec));
326    }
327    // Legacy outputSchema is emitted when response is unset, for backward-compatible catalogs.
328    if op.response.is_none() && !op.output_schema.is_empty() {
329        if let Ok(schema) = serde_json::from_str::<JsonValue>(&op.output_schema) {
330            obj.insert("outputSchema".to_owned(), schema);
331        }
332    }
333    if !op.tags.is_empty() {
334        obj.insert("tags".to_owned(), json!(op.tags));
335    }
336    if !op.required_scopes.is_empty() {
337        obj.insert("requiredScopes".to_owned(), json!(op.required_scopes));
338    }
339    if op.read_only {
340        obj.insert("readOnly".to_owned(), json!(true));
341    }
342    if let Some(visible) = op.visible {
343        obj.insert("visible".to_owned(), json!(visible));
344    }
345    if !op.transport.is_empty() {
346        obj.insert("transport".to_owned(), json!(op.transport));
347    }
348    if !op.allowed_roles.is_empty() {
349        obj.insert("allowedRoles".to_owned(), json!(op.allowed_roles));
350    }
351    if !op.parameters.is_empty() {
352        let params: Vec<JsonValue> = op
353            .parameters
354            .iter()
355            .map(|p| {
356                let mut m = serde_json::Map::new();
357                m.insert("name".to_owned(), json!(p.name));
358                m.insert("type".to_owned(), json!(p.r#type));
359                if !p.description.is_empty() {
360                    m.insert("description".to_owned(), json!(p.description));
361                }
362                if p.required {
363                    m.insert("required".to_owned(), json!(true));
364                }
365                if let Some(ref default) = p.default {
366                    m.insert("default".to_owned(), default.clone());
367                }
368                JsonValue::Object(m)
369            })
370            .collect();
371        obj.insert("parameters".to_owned(), json!(params));
372    }
373    if let Some(ref ann) = op.annotations {
374        let mut a = serde_json::Map::new();
375        if let Some(v) = ann.read_only_hint {
376            a.insert("readOnlyHint".to_owned(), json!(v));
377        }
378        if let Some(v) = ann.idempotent_hint {
379            a.insert("idempotentHint".to_owned(), json!(v));
380        }
381        if let Some(v) = ann.destructive_hint {
382            a.insert("destructiveHint".to_owned(), json!(v));
383        }
384        if let Some(v) = ann.open_world_hint {
385            a.insert("openWorldHint".to_owned(), json!(v));
386        }
387        if !a.is_empty() {
388            obj.insert("annotations".to_owned(), JsonValue::Object(a));
389        }
390    }
391    JsonValue::Object(obj)
392}
393
394/// response_to_json_value serializes an authoring OperationResponseSpec to the
395/// JSON catalog shape (unary/stream with JSON-parsed schema objects).
396fn response_to_json_value(spec: &OperationResponseSpec) -> JsonValue {
397    let mut m = serde_json::Map::new();
398    if let Some(unary) = &spec.unary {
399        if let Ok(schema) = serde_json::from_str::<JsonValue>(&unary.schema) {
400            let mut u = serde_json::Map::new();
401            u.insert("schema".to_owned(), schema);
402            m.insert("unary".to_owned(), JsonValue::Object(u));
403        }
404    }
405    if let Some(stream) = &spec.stream {
406        let mut s = serde_json::Map::new();
407        s.insert("mediaType".to_owned(), json!(stream.media_type));
408        if let Ok(schema) = serde_json::from_str::<JsonValue>(&stream.item_schema) {
409            s.insert("itemSchema".to_owned(), schema);
410        }
411        m.insert("stream".to_owned(), JsonValue::Object(s));
412    }
413    JsonValue::Object(m)
414}
415
416pub(crate) fn schema_json<T: JsonSchema>() -> Result<JsonValue> {
417    serde_json::to_value(schemars::schema_for!(T)).map_err(Error::from)
418}
419
420pub(crate) fn schema_parameters(schema: &JsonValue) -> Vec<CatalogParameter> {
421    let required = schema
422        .get("required")
423        .and_then(JsonValue::as_array)
424        .map(|items| {
425            items
426                .iter()
427                .filter_map(JsonValue::as_str)
428                .map(ToOwned::to_owned)
429                .collect::<BTreeSet<_>>()
430        })
431        .unwrap_or_default();
432
433    let Some(properties) = schema.get("properties").and_then(JsonValue::as_object) else {
434        return Vec::new();
435    };
436
437    properties
438        .iter()
439        .map(|(name, property)| CatalogParameter {
440            name: name.clone(),
441            r#type: schema_type(property),
442            description: property
443                .get("description")
444                .and_then(JsonValue::as_str)
445                .unwrap_or_default()
446                .trim()
447                .to_owned(),
448            required: required.contains(name),
449            default: property.get("default").cloned(),
450        })
451        .collect()
452}
453
454fn json_value_to_proto_value(value: &JsonValue) -> prost_types::Value {
455    match value {
456        JsonValue::Null => prost_types::Value {
457            kind: Some(prost_types::value::Kind::NullValue(0)),
458        },
459        JsonValue::Bool(b) => prost_types::Value {
460            kind: Some(prost_types::value::Kind::BoolValue(*b)),
461        },
462        JsonValue::Number(n) => prost_types::Value {
463            kind: Some(prost_types::value::Kind::NumberValue(
464                n.as_f64().unwrap_or(0.0),
465            )),
466        },
467        JsonValue::String(s) => prost_types::Value {
468            kind: Some(prost_types::value::Kind::StringValue(s.clone())),
469        },
470        JsonValue::Array(items) => prost_types::Value {
471            kind: Some(prost_types::value::Kind::ListValue(
472                prost_types::ListValue {
473                    values: items.iter().map(json_value_to_proto_value).collect(),
474                },
475            )),
476        },
477        JsonValue::Object(map) => prost_types::Value {
478            kind: Some(prost_types::value::Kind::StructValue(prost_types::Struct {
479                fields: map
480                    .iter()
481                    .map(|(k, v)| (k.clone(), json_value_to_proto_value(v)))
482                    .collect(),
483            })),
484        },
485    }
486}
487
488pub(crate) fn object_map(value: Option<prost_types::Struct>) -> serde_json::Map<String, JsonValue> {
489    value
490        .map(|structure| {
491            structure
492                .fields
493                .into_iter()
494                .map(|(key, value)| (key, proto_value_to_json(value)))
495                .collect::<serde_json::Map<_, _>>()
496        })
497        .unwrap_or_default()
498}
499
500pub(crate) fn proto_value_to_json(value: prost_types::Value) -> JsonValue {
501    match value.kind {
502        Some(prost_types::value::Kind::NullValue(_)) | None => JsonValue::Null,
503        Some(prost_types::value::Kind::NumberValue(number)) => json!(number),
504        Some(prost_types::value::Kind::StringValue(text)) => json!(text),
505        Some(prost_types::value::Kind::BoolValue(flag)) => json!(flag),
506        Some(prost_types::value::Kind::StructValue(structure)) => {
507            JsonValue::Object(object_map(Some(structure)))
508        }
509        Some(prost_types::value::Kind::ListValue(list)) => {
510            JsonValue::Array(list.values.into_iter().map(proto_value_to_json).collect())
511        }
512    }
513}
514
515fn schema_type(schema: &JsonValue) -> String {
516    if schema.get("properties").is_some() {
517        return "object".to_owned();
518    }
519    if schema.get("items").is_some() {
520        return "array".to_owned();
521    }
522    match schema.get("type") {
523        Some(JsonValue::String(value)) => normalize_type(value).to_owned(),
524        Some(JsonValue::Array(values)) => values
525            .iter()
526            .filter_map(JsonValue::as_str)
527            .find(|value| *value != "null")
528            .map(|value| normalize_type(value).to_owned())
529            .unwrap_or_else(|| "object".to_owned()),
530        _ => "object".to_owned(),
531    }
532}
533
534fn normalize_type(value: &str) -> &'static str {
535    match value {
536        "integer" => "integer",
537        "number" => "number",
538        "boolean" => "boolean",
539        "array" => "array",
540        "object" => "object",
541        _ => "string",
542    }
543}
544
545#[cfg(test)]
546mod tests {
547    use super::*;
548
549    #[derive(serde::Deserialize, schemars::JsonSchema)]
550    struct SampleInput {
551        #[allow(dead_code)]
552        #[schemars(description = "Search query")]
553        query: String,
554        #[allow(dead_code)]
555        #[serde(default)]
556        max_items: Option<u32>,
557    }
558
559    fn op_with_response(response: Option<OperationResponseSpec>) -> CatalogOperation {
560        CatalogOperation {
561            id: "search".to_owned(),
562            method: "POST".to_owned(),
563            output_schema: String::new(),
564            response,
565            ..Default::default()
566        }
567    }
568
569    #[test]
570    fn response_stream_produces_stream_proto_kind() {
571        let op = op_with_response(Some(OperationResponseSpec {
572            stream: Some(StreamResponseSpec {
573                media_type: "application/x-ndjson".to_owned(),
574                item_schema: r#"{"type":"object"}"#.to_owned(),
575            }),
576            unary: None,
577        }));
578        let proto = operation_to_proto(&op);
579        let kind = proto
580            .response
581            .expect("response set")
582            .kind
583            .expect("kind set");
584        match kind {
585            v1::operation_response_spec::Kind::Stream(s) => {
586                assert_eq!(s.media_type, "application/x-ndjson");
587                assert!(s.item_schema.is_some());
588            }
589            other => panic!("expected stream, got {other:?}"),
590        }
591    }
592
593    #[test]
594    fn response_unary_produces_unary_proto_kind() {
595        let op = op_with_response(Some(OperationResponseSpec {
596            unary: Some(UnaryResponseSpec {
597                schema: r#"{"type":"object"}"#.to_owned(),
598            }),
599            stream: None,
600        }));
601        let proto = operation_to_proto(&op);
602        let kind = proto
603            .response
604            .expect("response set")
605            .kind
606            .expect("kind set");
607        assert!(matches!(kind, v1::operation_response_spec::Kind::Unary(_)));
608    }
609
610    #[test]
611    fn legacy_output_schema_falls_back_to_unary_when_response_is_none() {
612        let op = CatalogOperation {
613            id: "get".to_owned(),
614            method: "POST".to_owned(),
615            output_schema: r#"{"type":"object"}"#.to_owned(),
616            response: None,
617            ..Default::default()
618        };
619        let proto = operation_to_proto(&op);
620        let kind = proto
621            .response
622            .expect("response set")
623            .kind
624            .expect("kind set");
625        assert!(matches!(kind, v1::operation_response_spec::Kind::Unary(_)));
626    }
627
628    #[test]
629    fn empty_response_and_empty_output_schema_yield_none() {
630        let op = op_with_response(None);
631        let proto = operation_to_proto(&op);
632        assert!(proto.response.is_none());
633    }
634
635    #[test]
636    fn json_value_emits_response_and_skips_legacy_output_schema() {
637        let op = CatalogOperation {
638            id: "search".to_owned(),
639            method: "POST".to_owned(),
640            output_schema: r#"{"type":"object"}"#.to_owned(),
641            response: Some(OperationResponseSpec {
642                stream: Some(StreamResponseSpec {
643                    media_type: "application/x-ndjson".to_owned(),
644                    item_schema: r#"{"type":"object"}"#.to_owned(),
645                }),
646                unary: None,
647            }),
648            ..Default::default()
649        };
650        let json = operation_to_json_value(&op);
651        assert!(json.get("response").is_some(), "response should be emitted");
652        assert!(
653            json.get("outputSchema").is_none(),
654            "legacy outputSchema should be skipped when response is set"
655        );
656        let stream = json
657            .get("response")
658            .and_then(|v| v.get("stream"))
659            .expect("stream variant");
660        assert_eq!(stream["mediaType"], "application/x-ndjson");
661    }
662
663    #[test]
664    fn is_stream_reports_stream_variant() {
665        let stream_spec = OperationResponseSpec {
666            stream: Some(StreamResponseSpec::default()),
667            unary: None,
668        };
669        assert!(stream_spec.is_stream());
670        let unary_spec = OperationResponseSpec {
671            unary: Some(UnaryResponseSpec::default()),
672            stream: None,
673        };
674        assert!(!unary_spec.is_stream());
675    }
676
677    #[test]
678    fn schema_parameters_derive_required_and_optional_fields() {
679        let schema = schema_json::<SampleInput>().expect("schema");
680        let mut params = schema_parameters(&schema);
681        params.sort_by(|left, right| left.name.cmp(&right.name));
682
683        assert_eq!(params.len(), 2);
684        assert_eq!(params[0].name, "max_items");
685        assert!(!params[0].required);
686        assert_eq!(params[1].name, "query");
687        assert!(params[1].required);
688        assert_eq!(params[1].description, "Search query");
689    }
690}