Skip to main content

chio_openapi/
generator.rs

1//! Chio `ToolManifest` generator from parsed OpenAPI specs.
2//!
3//! Each route + method pair becomes a `ToolDefinition` with an input schema
4//! derived from path, query, and body parameters.
5
6use chio_core_types::manifest::{ToolAnnotations, ToolDefinition};
7use chio_http_core::HttpMethod;
8use serde_json::Value;
9
10use crate::extensions::ChioExtensions;
11use crate::parser::{OpenApiSpec, Operation, Parameter, ParameterLocation};
12use crate::policy::DefaultPolicy;
13
14/// Configuration for the manifest generator.
15#[derive(Debug, Clone)]
16pub struct GeneratorConfig {
17    /// Server ID to use in the generated manifest body. The manifest itself is
18    /// not signed here (the caller signs it with a keypair), so we only
19    /// produce `ToolDefinition` values.
20    pub server_id: String,
21    /// Whether to include response schemas as output_schema on each tool.
22    pub include_output_schemas: bool,
23    /// Whether to skip operations that have `x-chio-publish: false`.
24    pub respect_publish_flag: bool,
25}
26
27impl Default for GeneratorConfig {
28    fn default() -> Self {
29        Self {
30            server_id: "openapi-server".to_string(),
31            include_output_schemas: true,
32            respect_publish_flag: true,
33        }
34    }
35}
36
37/// Generates Chio `ToolDefinition` values from a parsed OpenAPI spec.
38pub struct ManifestGenerator {
39    config: GeneratorConfig,
40}
41
42impl ManifestGenerator {
43    /// Create a new generator with the given configuration.
44    #[must_use]
45    pub fn new(config: GeneratorConfig) -> Self {
46        Self { config }
47    }
48
49    /// Generate `ToolDefinition` values for all operations in the spec.
50    #[must_use]
51    pub fn generate_tools(&self, spec: &OpenApiSpec) -> Vec<ToolDefinition> {
52        let mut tools = Vec::new();
53
54        for (path, path_item) in &spec.paths {
55            for (method_str, operation) in &path_item.operations {
56                let extensions = ChioExtensions::from_operation(&operation.raw);
57
58                // Skip operations that opt out of publishing.
59                if self.config.respect_publish_flag && !extensions.should_publish() {
60                    continue;
61                }
62
63                let method = match parse_method(method_str) {
64                    Some(m) => m,
65                    None => continue,
66                };
67
68                // Merge path-level and operation-level parameters.
69                let all_params =
70                    merge_parameters(&path_item.common_parameters, &operation.parameters);
71
72                let tool =
73                    self.build_tool_definition(path, method, operation, &all_params, &extensions);
74                tools.push(tool);
75            }
76        }
77
78        tools
79    }
80
81    fn build_tool_definition(
82        &self,
83        path: &str,
84        method: HttpMethod,
85        operation: &Operation,
86        params: &[Parameter],
87        extensions: &ChioExtensions,
88    ) -> ToolDefinition {
89        let name = operation
90            .operation_id
91            .clone()
92            .unwrap_or_else(|| format!("{} {}", method, path));
93
94        let description = operation
95            .summary
96            .clone()
97            .or_else(|| operation.description.clone())
98            .unwrap_or_else(|| format!("{} {}", method, path));
99
100        let input_schema = build_input_schema(
101            params,
102            &operation.request_body_schema,
103            operation.request_body_required,
104        );
105
106        let output_schema = if self.config.include_output_schemas {
107            build_output_schema(&operation.response_schemas)
108        } else {
109            None
110        };
111
112        let has_side_effects = DefaultPolicy::has_side_effects(method, extensions);
113
114        let annotations = ToolAnnotations {
115            read_only: !has_side_effects,
116            destructive: method == HttpMethod::Delete,
117            idempotent: matches!(
118                method,
119                HttpMethod::Get | HttpMethod::Put | HttpMethod::Delete
120            ),
121            requires_approval: extensions.approval_required.unwrap_or(false),
122            estimated_duration_ms: None,
123        };
124
125        ToolDefinition {
126            name,
127            description,
128            input_schema,
129            output_schema,
130            pricing: None,
131            annotations,
132        }
133    }
134}
135
136/// Parse an uppercase method string into an `HttpMethod`.
137fn parse_method(s: &str) -> Option<HttpMethod> {
138    match s {
139        "GET" => Some(HttpMethod::Get),
140        "POST" => Some(HttpMethod::Post),
141        "PUT" => Some(HttpMethod::Put),
142        "PATCH" => Some(HttpMethod::Patch),
143        "DELETE" => Some(HttpMethod::Delete),
144        "HEAD" => Some(HttpMethod::Head),
145        "OPTIONS" => Some(HttpMethod::Options),
146        _ => None,
147    }
148}
149
150/// Merge path-level and operation-level parameters. Operation-level parameters
151/// override path-level parameters with the same name and location.
152fn merge_parameters(path_params: &[Parameter], op_params: &[Parameter]) -> Vec<Parameter> {
153    let mut merged: Vec<Parameter> = path_params.to_vec();
154
155    for op_param in op_params {
156        // Replace any path-level param with the same name+location.
157        let existing = merged
158            .iter()
159            .position(|p| p.name == op_param.name && p.location == op_param.location);
160        if let Some(idx) = existing {
161            merged[idx] = op_param.clone();
162        } else {
163            merged.push(op_param.clone());
164        }
165    }
166
167    merged
168}
169
170/// Build a JSON Schema object from path/query parameters and an optional
171/// request body schema.
172fn build_input_schema(
173    params: &[Parameter],
174    request_body: &Option<Value>,
175    request_body_required: bool,
176) -> Value {
177    let mut properties = serde_json::Map::new();
178    let mut required = Vec::new();
179
180    // Add path and query parameters as top-level properties.
181    for param in params {
182        // Skip header and cookie params from the tool input schema.
183        if param.location == ParameterLocation::Header
184            || param.location == ParameterLocation::Cookie
185        {
186            continue;
187        }
188
189        let schema = param
190            .schema
191            .clone()
192            .unwrap_or_else(|| serde_json::json!({"type": "string"}));
193
194        let mut prop = if let Value::Object(m) = schema {
195            m
196        } else {
197            let mut m = serde_json::Map::new();
198            m.insert("type".to_string(), serde_json::json!("string"));
199            m
200        };
201
202        if let Some(desc) = &param.description {
203            prop.insert("description".to_string(), Value::String(desc.clone()));
204        }
205
206        properties.insert(param.name.clone(), Value::Object(prop));
207
208        if param.required {
209            required.push(Value::String(param.name.clone()));
210        }
211    }
212
213    // If there is a request body, add it as a "body" property.
214    if let Some(body_schema) = request_body {
215        properties.insert("body".to_string(), body_schema.clone());
216        if request_body_required {
217            required.push(Value::String("body".to_string()));
218        }
219    }
220
221    let mut schema = serde_json::Map::new();
222    schema.insert("type".to_string(), Value::String("object".to_string()));
223    schema.insert("properties".to_string(), Value::Object(properties));
224    if !required.is_empty() {
225        schema.insert("required".to_string(), Value::Array(required));
226    }
227
228    Value::Object(schema)
229}
230
231/// Build an output schema from the response schemas. Uses the first successful
232/// (2xx) response schema found.
233fn build_output_schema(responses: &[(String, Option<Value>)]) -> Option<Value> {
234    for preferred in &["200", "201"] {
235        if let Some(schema) = responses.iter().find_map(|(code, schema)| {
236            if code == preferred {
237                schema.as_ref()
238            } else {
239                None
240            }
241        }) {
242            return Some(schema.clone());
243        }
244    }
245
246    responses
247        .iter()
248        .find_map(|(code, schema)| code.starts_with('2').then_some(schema.as_ref()).flatten())
249        .cloned()
250}
251
252#[cfg(test)]
253#[allow(clippy::unwrap_used, clippy::expect_used)]
254mod tests {
255    use super::*;
256    use crate::parser::OpenApiSpec;
257
258    fn petstore_spec() -> &'static str {
259        r##"{
260            "openapi": "3.0.3",
261            "info": {
262                "title": "Petstore",
263                "description": "A sample API for pets",
264                "version": "1.0.0"
265            },
266            "paths": {
267                "/pets": {
268                    "get": {
269                        "operationId": "listPets",
270                        "summary": "List all pets",
271                        "tags": ["pets"],
272                        "parameters": [
273                            {
274                                "name": "limit",
275                                "in": "query",
276                                "required": false,
277                                "schema": { "type": "integer", "format": "int32" },
278                                "description": "How many items to return"
279                            }
280                        ],
281                        "responses": {
282                            "200": {
283                                "description": "A list of pets",
284                                "content": {
285                                    "application/json": {
286                                        "schema": {
287                                            "type": "array",
288                                            "items": { "$ref": "#/components/schemas/Pet" }
289                                        }
290                                    }
291                                }
292                            }
293                        }
294                    },
295                    "post": {
296                        "operationId": "createPet",
297                        "summary": "Create a pet",
298                        "tags": ["pets"],
299                        "requestBody": {
300                            "required": true,
301                            "content": {
302                                "application/json": {
303                                    "schema": {
304                                        "type": "object",
305                                        "properties": {
306                                            "name": { "type": "string" },
307                                            "tag": { "type": "string" }
308                                        },
309                                        "required": ["name"]
310                                    }
311                                }
312                            }
313                        },
314                        "responses": {
315                            "201": { "description": "Pet created" }
316                        }
317                    }
318                },
319                "/pets/{petId}": {
320                    "get": {
321                        "operationId": "showPetById",
322                        "summary": "Info for a specific pet",
323                        "tags": ["pets"],
324                        "parameters": [
325                            {
326                                "name": "petId",
327                                "in": "path",
328                                "required": true,
329                                "schema": { "type": "string" },
330                                "description": "The id of the pet to retrieve"
331                            }
332                        ],
333                        "responses": {
334                            "200": {
335                                "description": "Expected response to a valid request",
336                                "content": {
337                                    "application/json": {
338                                        "schema": { "$ref": "#/components/schemas/Pet" }
339                                    }
340                                }
341                            }
342                        }
343                    },
344                    "delete": {
345                        "operationId": "deletePet",
346                        "summary": "Delete a pet",
347                        "tags": ["pets"],
348                        "parameters": [
349                            {
350                                "name": "petId",
351                                "in": "path",
352                                "required": true,
353                                "schema": { "type": "string" }
354                            }
355                        ],
356                        "responses": {
357                            "204": { "description": "Pet deleted" }
358                        }
359                    }
360                }
361            },
362            "components": {
363                "schemas": {
364                    "Pet": {
365                        "type": "object",
366                        "properties": {
367                            "id": { "type": "integer", "format": "int64" },
368                            "name": { "type": "string" },
369                            "tag": { "type": "string" }
370                        },
371                        "required": ["id", "name"]
372                    }
373                }
374            }
375        }"##
376    }
377
378    #[test]
379    fn petstore_generates_four_tools() {
380        let spec = OpenApiSpec::parse(petstore_spec()).unwrap();
381        let gen = ManifestGenerator::new(GeneratorConfig::default());
382        let tools = gen.generate_tools(&spec);
383
384        assert_eq!(tools.len(), 4);
385
386        let names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect();
387        assert!(names.contains(&"listPets"));
388        assert!(names.contains(&"createPet"));
389        assert!(names.contains(&"showPetById"));
390        assert!(names.contains(&"deletePet"));
391    }
392
393    #[test]
394    fn get_operations_are_read_only() {
395        let spec = OpenApiSpec::parse(petstore_spec()).unwrap();
396        let gen = ManifestGenerator::new(GeneratorConfig::default());
397        let tools = gen.generate_tools(&spec);
398
399        let list_pets = tools.iter().find(|t| t.name == "listPets").unwrap();
400        assert!(list_pets.annotations.read_only);
401        assert!(!list_pets.annotations.destructive);
402        assert!(list_pets.annotations.idempotent);
403    }
404
405    #[test]
406    fn post_operations_have_side_effects() {
407        let spec = OpenApiSpec::parse(petstore_spec()).unwrap();
408        let gen = ManifestGenerator::new(GeneratorConfig::default());
409        let tools = gen.generate_tools(&spec);
410
411        let create_pet = tools.iter().find(|t| t.name == "createPet").unwrap();
412        assert!(!create_pet.annotations.read_only);
413        assert!(!create_pet.annotations.destructive);
414        assert!(!create_pet.annotations.idempotent);
415    }
416
417    #[test]
418    fn delete_operations_are_destructive() {
419        let spec = OpenApiSpec::parse(petstore_spec()).unwrap();
420        let gen = ManifestGenerator::new(GeneratorConfig::default());
421        let tools = gen.generate_tools(&spec);
422
423        let delete_pet = tools.iter().find(|t| t.name == "deletePet").unwrap();
424        assert!(!delete_pet.annotations.read_only);
425        assert!(delete_pet.annotations.destructive);
426        assert!(delete_pet.annotations.idempotent);
427    }
428
429    #[test]
430    fn input_schema_includes_query_params() {
431        let spec = OpenApiSpec::parse(petstore_spec()).unwrap();
432        let gen = ManifestGenerator::new(GeneratorConfig::default());
433        let tools = gen.generate_tools(&spec);
434
435        let list_pets = tools.iter().find(|t| t.name == "listPets").unwrap();
436        let props = list_pets
437            .input_schema
438            .get("properties")
439            .and_then(|p| p.as_object())
440            .unwrap();
441        assert!(props.contains_key("limit"));
442    }
443
444    #[test]
445    fn input_schema_includes_path_params_as_required() {
446        let spec = OpenApiSpec::parse(petstore_spec()).unwrap();
447        let gen = ManifestGenerator::new(GeneratorConfig::default());
448        let tools = gen.generate_tools(&spec);
449
450        let show_pet = tools.iter().find(|t| t.name == "showPetById").unwrap();
451        let required = show_pet
452            .input_schema
453            .get("required")
454            .and_then(|r| r.as_array())
455            .unwrap();
456        let required_names: Vec<&str> = required.iter().filter_map(|v| v.as_str()).collect();
457        assert!(required_names.contains(&"petId"));
458    }
459
460    #[test]
461    fn input_schema_includes_request_body() {
462        let spec = OpenApiSpec::parse(petstore_spec()).unwrap();
463        let gen = ManifestGenerator::new(GeneratorConfig::default());
464        let tools = gen.generate_tools(&spec);
465
466        let create_pet = tools.iter().find(|t| t.name == "createPet").unwrap();
467        let props = create_pet
468            .input_schema
469            .get("properties")
470            .and_then(|p| p.as_object())
471            .unwrap();
472        assert!(props.contains_key("body"));
473        let required = create_pet
474            .input_schema
475            .get("required")
476            .and_then(Value::as_array)
477            .unwrap();
478        let required_names: Vec<&str> = required.iter().filter_map(Value::as_str).collect();
479        assert!(required_names.contains(&"body"));
480    }
481
482    #[test]
483    fn input_schema_keeps_optional_request_body_optional() {
484        let input = r##"{
485            "openapi": "3.0.3",
486            "info": { "title": "T", "version": "1" },
487            "paths": {
488                "/pets": {
489                    "post": {
490                        "operationId": "createPet",
491                        "requestBody": {
492                            "content": {
493                                "application/json": {
494                                    "schema": { "type": "object" }
495                                }
496                            }
497                        },
498                        "responses": { "201": { "description": "Created" } }
499                    }
500                }
501            }
502        }"##;
503
504        let spec = OpenApiSpec::parse(input).unwrap();
505        let gen = ManifestGenerator::new(GeneratorConfig::default());
506        let tools = gen.generate_tools(&spec);
507
508        let create_pet = tools.iter().find(|tool| tool.name == "createPet").unwrap();
509        let props = create_pet
510            .input_schema
511            .get("properties")
512            .and_then(Value::as_object)
513            .unwrap();
514        assert!(props.contains_key("body"));
515        let required_names: Vec<&str> = create_pet
516            .input_schema
517            .get("required")
518            .and_then(Value::as_array)
519            .map(|required| required.iter().filter_map(Value::as_str).collect())
520            .unwrap_or_default();
521        assert!(!required_names.contains(&"body"));
522    }
523
524    #[test]
525    fn output_schema_from_200_response() {
526        let spec = OpenApiSpec::parse(petstore_spec()).unwrap();
527        let gen = ManifestGenerator::new(GeneratorConfig::default());
528        let tools = gen.generate_tools(&spec);
529
530        let list_pets = tools.iter().find(|t| t.name == "listPets").unwrap();
531        assert!(list_pets.output_schema.is_some());
532        let output = list_pets.output_schema.as_ref().unwrap();
533        assert_eq!(output.get("type").and_then(|v| v.as_str()), Some("array"));
534    }
535
536    #[test]
537    fn fallback_name_when_no_operation_id() {
538        let input = r##"{
539            "openapi": "3.0.3",
540            "info": { "title": "T", "version": "1" },
541            "paths": {
542                "/health": {
543                    "get": {
544                        "responses": { "200": { "description": "OK" } }
545                    }
546                }
547            }
548        }"##;
549
550        let spec = OpenApiSpec::parse(input).unwrap();
551        let gen = ManifestGenerator::new(GeneratorConfig::default());
552        let tools = gen.generate_tools(&spec);
553
554        assert_eq!(tools.len(), 1);
555        assert_eq!(tools[0].name, "GET /health");
556    }
557
558    #[test]
559    fn x_chio_publish_false_excludes_operation() {
560        let input = r##"{
561            "openapi": "3.0.3",
562            "info": { "title": "T", "version": "1" },
563            "paths": {
564                "/internal": {
565                    "get": {
566                        "operationId": "internalEndpoint",
567                        "x-chio-publish": false,
568                        "responses": { "200": { "description": "OK" } }
569                    }
570                },
571                "/public": {
572                    "get": {
573                        "operationId": "publicEndpoint",
574                        "responses": { "200": { "description": "OK" } }
575                    }
576                }
577            }
578        }"##;
579
580        let spec = OpenApiSpec::parse(input).unwrap();
581        let gen = ManifestGenerator::new(GeneratorConfig::default());
582        let tools = gen.generate_tools(&spec);
583
584        assert_eq!(tools.len(), 1);
585        assert_eq!(tools[0].name, "publicEndpoint");
586    }
587
588    #[test]
589    fn approval_required_annotation() {
590        let input = r##"{
591            "openapi": "3.0.3",
592            "info": { "title": "T", "version": "1" },
593            "paths": {
594                "/danger": {
595                    "post": {
596                        "operationId": "dangerousAction",
597                        "x-chio-approval-required": true,
598                        "responses": { "200": { "description": "OK" } }
599                    }
600                }
601            }
602        }"##;
603
604        let spec = OpenApiSpec::parse(input).unwrap();
605        let gen = ManifestGenerator::new(GeneratorConfig::default());
606        let tools = gen.generate_tools(&spec);
607
608        assert_eq!(tools.len(), 1);
609        assert!(tools[0].annotations.requires_approval);
610    }
611
612    #[test]
613    fn path_level_parameters_merged() {
614        let input = r##"{
615            "openapi": "3.0.3",
616            "info": { "title": "T", "version": "1" },
617            "paths": {
618                "/orgs/{orgId}/members": {
619                    "parameters": [
620                        { "name": "orgId", "in": "path", "required": true, "schema": { "type": "string" } }
621                    ],
622                    "get": {
623                        "operationId": "listMembers",
624                        "parameters": [
625                            { "name": "page", "in": "query", "schema": { "type": "integer" } }
626                        ],
627                        "responses": { "200": { "description": "OK" } }
628                    }
629                }
630            }
631        }"##;
632
633        let spec = OpenApiSpec::parse(input).unwrap();
634        let gen = ManifestGenerator::new(GeneratorConfig::default());
635        let tools = gen.generate_tools(&spec);
636
637        let tool = &tools[0];
638        let props = tool
639            .input_schema
640            .get("properties")
641            .and_then(|p| p.as_object())
642            .unwrap();
643        assert!(props.contains_key("orgId"));
644        assert!(props.contains_key("page"));
645    }
646}