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#[derive(Clone, Debug, Default, PartialEq)]
12pub struct Catalog {
13 pub name: String,
15 pub display_name: String,
17 pub description: String,
19 pub icon_svg: String,
21 pub operations: Vec<CatalogOperation>,
23}
24
25#[derive(Clone, Debug, Default, PartialEq)]
27pub struct CatalogOperation {
28 pub id: String,
30 pub method: String,
32 pub title: String,
34 pub description: String,
36 pub input_schema: String,
38 pub output_schema: String,
40 pub response: Option<OperationResponseSpec>,
42 pub annotations: Option<OperationAnnotations>,
44 pub parameters: Vec<CatalogParameter>,
46 pub required_scopes: Vec<String>,
48 pub tags: Vec<String>,
50 pub read_only: bool,
52 pub visible: Option<bool>,
54 pub transport: String,
56 pub allowed_roles: Vec<String>,
58}
59
60#[derive(Clone, Debug, Default, PartialEq)]
62pub struct CatalogParameter {
63 pub name: String,
65 pub r#type: String,
67 pub description: String,
69 pub required: bool,
71 pub default: Option<JsonValue>,
73}
74
75#[derive(Clone, Debug, Default, Eq, PartialEq)]
77pub struct OperationAnnotations {
78 pub read_only_hint: Option<bool>,
80 pub idempotent_hint: Option<bool>,
82 pub destructive_hint: Option<bool>,
84 pub open_world_hint: Option<bool>,
86}
87
88#[derive(Clone, Debug, Default, PartialEq)]
91pub struct UnaryResponseSpec {
92 pub schema: String,
94}
95
96#[derive(Clone, Debug, Default, PartialEq)]
100pub struct StreamResponseSpec {
101 pub media_type: String,
103 pub item_schema: String,
105}
106
107#[derive(Clone, Debug, Default, PartialEq)]
111pub struct OperationResponseSpec {
112 pub unary: Option<UnaryResponseSpec>,
114 pub stream: Option<StreamResponseSpec>,
116}
117
118impl OperationResponseSpec {
119 pub fn is_stream(&self) -> bool {
121 self.stream.is_some()
122 }
123}
124
125impl Catalog {
126 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
136pub 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
159fn 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
201fn 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
218fn 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 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
394fn 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}