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(params, &operation.request_body_schema);
101
102        let output_schema = if self.config.include_output_schemas {
103            build_output_schema(&operation.response_schemas)
104        } else {
105            None
106        };
107
108        let has_side_effects = DefaultPolicy::has_side_effects(method, extensions);
109
110        let annotations = ToolAnnotations {
111            read_only: !has_side_effects,
112            destructive: method == HttpMethod::Delete,
113            idempotent: matches!(
114                method,
115                HttpMethod::Get | HttpMethod::Put | HttpMethod::Delete
116            ),
117            requires_approval: extensions.approval_required.unwrap_or(false),
118            estimated_duration_ms: None,
119        };
120
121        ToolDefinition {
122            name,
123            description,
124            input_schema,
125            output_schema,
126            pricing: None,
127            annotations,
128        }
129    }
130}
131
132/// Parse an uppercase method string into an `HttpMethod`.
133fn parse_method(s: &str) -> Option<HttpMethod> {
134    match s {
135        "GET" => Some(HttpMethod::Get),
136        "POST" => Some(HttpMethod::Post),
137        "PUT" => Some(HttpMethod::Put),
138        "PATCH" => Some(HttpMethod::Patch),
139        "DELETE" => Some(HttpMethod::Delete),
140        "HEAD" => Some(HttpMethod::Head),
141        "OPTIONS" => Some(HttpMethod::Options),
142        _ => None,
143    }
144}
145
146/// Merge path-level and operation-level parameters. Operation-level parameters
147/// override path-level parameters with the same name and location.
148fn merge_parameters(path_params: &[Parameter], op_params: &[Parameter]) -> Vec<Parameter> {
149    let mut merged: Vec<Parameter> = path_params.to_vec();
150
151    for op_param in op_params {
152        // Replace any path-level param with the same name+location.
153        let existing = merged
154            .iter()
155            .position(|p| p.name == op_param.name && p.location == op_param.location);
156        if let Some(idx) = existing {
157            merged[idx] = op_param.clone();
158        } else {
159            merged.push(op_param.clone());
160        }
161    }
162
163    merged
164}
165
166/// Build a JSON Schema object from path/query parameters and an optional
167/// request body schema.
168fn build_input_schema(params: &[Parameter], request_body: &Option<Value>) -> Value {
169    let mut properties = serde_json::Map::new();
170    let mut required = Vec::new();
171
172    // Add path and query parameters as top-level properties.
173    for param in params {
174        // Skip header and cookie params from the tool input schema.
175        if param.location == ParameterLocation::Header
176            || param.location == ParameterLocation::Cookie
177        {
178            continue;
179        }
180
181        let schema = param
182            .schema
183            .clone()
184            .unwrap_or_else(|| serde_json::json!({"type": "string"}));
185
186        let mut prop = if let Value::Object(m) = schema {
187            m
188        } else {
189            let mut m = serde_json::Map::new();
190            m.insert("type".to_string(), serde_json::json!("string"));
191            m
192        };
193
194        if let Some(desc) = &param.description {
195            prop.insert("description".to_string(), Value::String(desc.clone()));
196        }
197
198        properties.insert(param.name.clone(), Value::Object(prop));
199
200        if param.required {
201            required.push(Value::String(param.name.clone()));
202        }
203    }
204
205    // If there is a request body, add it as a "body" property.
206    if let Some(body_schema) = request_body {
207        properties.insert("body".to_string(), body_schema.clone());
208        required.push(Value::String("body".to_string()));
209    }
210
211    let mut schema = serde_json::Map::new();
212    schema.insert("type".to_string(), Value::String("object".to_string()));
213    schema.insert("properties".to_string(), Value::Object(properties));
214    if !required.is_empty() {
215        schema.insert("required".to_string(), Value::Array(required));
216    }
217
218    Value::Object(schema)
219}
220
221/// Build an output schema from the response schemas. Uses the first successful
222/// (2xx) response schema found.
223fn build_output_schema(responses: &[(String, Option<Value>)]) -> Option<Value> {
224    for preferred in &["200", "201"] {
225        if let Some(schema) = responses.iter().find_map(|(code, schema)| {
226            if code == preferred {
227                schema.as_ref()
228            } else {
229                None
230            }
231        }) {
232            return Some(schema.clone());
233        }
234    }
235
236    responses
237        .iter()
238        .find_map(|(code, schema)| code.starts_with('2').then_some(schema.as_ref()).flatten())
239        .cloned()
240}
241
242#[cfg(test)]
243#[allow(clippy::unwrap_used, clippy::expect_used)]
244mod tests {
245    use super::*;
246    use crate::parser::OpenApiSpec;
247
248    fn petstore_spec() -> &'static str {
249        r##"{
250            "openapi": "3.0.3",
251            "info": {
252                "title": "Petstore",
253                "description": "A sample API for pets",
254                "version": "1.0.0"
255            },
256            "paths": {
257                "/pets": {
258                    "get": {
259                        "operationId": "listPets",
260                        "summary": "List all pets",
261                        "tags": ["pets"],
262                        "parameters": [
263                            {
264                                "name": "limit",
265                                "in": "query",
266                                "required": false,
267                                "schema": { "type": "integer", "format": "int32" },
268                                "description": "How many items to return"
269                            }
270                        ],
271                        "responses": {
272                            "200": {
273                                "description": "A list of pets",
274                                "content": {
275                                    "application/json": {
276                                        "schema": {
277                                            "type": "array",
278                                            "items": { "$ref": "#/components/schemas/Pet" }
279                                        }
280                                    }
281                                }
282                            }
283                        }
284                    },
285                    "post": {
286                        "operationId": "createPet",
287                        "summary": "Create a pet",
288                        "tags": ["pets"],
289                        "requestBody": {
290                            "required": true,
291                            "content": {
292                                "application/json": {
293                                    "schema": {
294                                        "type": "object",
295                                        "properties": {
296                                            "name": { "type": "string" },
297                                            "tag": { "type": "string" }
298                                        },
299                                        "required": ["name"]
300                                    }
301                                }
302                            }
303                        },
304                        "responses": {
305                            "201": { "description": "Pet created" }
306                        }
307                    }
308                },
309                "/pets/{petId}": {
310                    "get": {
311                        "operationId": "showPetById",
312                        "summary": "Info for a specific pet",
313                        "tags": ["pets"],
314                        "parameters": [
315                            {
316                                "name": "petId",
317                                "in": "path",
318                                "required": true,
319                                "schema": { "type": "string" },
320                                "description": "The id of the pet to retrieve"
321                            }
322                        ],
323                        "responses": {
324                            "200": {
325                                "description": "Expected response to a valid request",
326                                "content": {
327                                    "application/json": {
328                                        "schema": { "$ref": "#/components/schemas/Pet" }
329                                    }
330                                }
331                            }
332                        }
333                    },
334                    "delete": {
335                        "operationId": "deletePet",
336                        "summary": "Delete a pet",
337                        "tags": ["pets"],
338                        "parameters": [
339                            {
340                                "name": "petId",
341                                "in": "path",
342                                "required": true,
343                                "schema": { "type": "string" }
344                            }
345                        ],
346                        "responses": {
347                            "204": { "description": "Pet deleted" }
348                        }
349                    }
350                }
351            },
352            "components": {
353                "schemas": {
354                    "Pet": {
355                        "type": "object",
356                        "properties": {
357                            "id": { "type": "integer", "format": "int64" },
358                            "name": { "type": "string" },
359                            "tag": { "type": "string" }
360                        },
361                        "required": ["id", "name"]
362                    }
363                }
364            }
365        }"##
366    }
367
368    #[test]
369    fn petstore_generates_four_tools() {
370        let spec = OpenApiSpec::parse(petstore_spec()).unwrap();
371        let gen = ManifestGenerator::new(GeneratorConfig::default());
372        let tools = gen.generate_tools(&spec);
373
374        assert_eq!(tools.len(), 4);
375
376        let names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect();
377        assert!(names.contains(&"listPets"));
378        assert!(names.contains(&"createPet"));
379        assert!(names.contains(&"showPetById"));
380        assert!(names.contains(&"deletePet"));
381    }
382
383    #[test]
384    fn get_operations_are_read_only() {
385        let spec = OpenApiSpec::parse(petstore_spec()).unwrap();
386        let gen = ManifestGenerator::new(GeneratorConfig::default());
387        let tools = gen.generate_tools(&spec);
388
389        let list_pets = tools.iter().find(|t| t.name == "listPets").unwrap();
390        assert!(list_pets.annotations.read_only);
391        assert!(!list_pets.annotations.destructive);
392        assert!(list_pets.annotations.idempotent);
393    }
394
395    #[test]
396    fn post_operations_have_side_effects() {
397        let spec = OpenApiSpec::parse(petstore_spec()).unwrap();
398        let gen = ManifestGenerator::new(GeneratorConfig::default());
399        let tools = gen.generate_tools(&spec);
400
401        let create_pet = tools.iter().find(|t| t.name == "createPet").unwrap();
402        assert!(!create_pet.annotations.read_only);
403        assert!(!create_pet.annotations.destructive);
404        assert!(!create_pet.annotations.idempotent);
405    }
406
407    #[test]
408    fn delete_operations_are_destructive() {
409        let spec = OpenApiSpec::parse(petstore_spec()).unwrap();
410        let gen = ManifestGenerator::new(GeneratorConfig::default());
411        let tools = gen.generate_tools(&spec);
412
413        let delete_pet = tools.iter().find(|t| t.name == "deletePet").unwrap();
414        assert!(!delete_pet.annotations.read_only);
415        assert!(delete_pet.annotations.destructive);
416        assert!(delete_pet.annotations.idempotent);
417    }
418
419    #[test]
420    fn input_schema_includes_query_params() {
421        let spec = OpenApiSpec::parse(petstore_spec()).unwrap();
422        let gen = ManifestGenerator::new(GeneratorConfig::default());
423        let tools = gen.generate_tools(&spec);
424
425        let list_pets = tools.iter().find(|t| t.name == "listPets").unwrap();
426        let props = list_pets
427            .input_schema
428            .get("properties")
429            .and_then(|p| p.as_object())
430            .unwrap();
431        assert!(props.contains_key("limit"));
432    }
433
434    #[test]
435    fn input_schema_includes_path_params_as_required() {
436        let spec = OpenApiSpec::parse(petstore_spec()).unwrap();
437        let gen = ManifestGenerator::new(GeneratorConfig::default());
438        let tools = gen.generate_tools(&spec);
439
440        let show_pet = tools.iter().find(|t| t.name == "showPetById").unwrap();
441        let required = show_pet
442            .input_schema
443            .get("required")
444            .and_then(|r| r.as_array())
445            .unwrap();
446        let required_names: Vec<&str> = required.iter().filter_map(|v| v.as_str()).collect();
447        assert!(required_names.contains(&"petId"));
448    }
449
450    #[test]
451    fn input_schema_includes_request_body() {
452        let spec = OpenApiSpec::parse(petstore_spec()).unwrap();
453        let gen = ManifestGenerator::new(GeneratorConfig::default());
454        let tools = gen.generate_tools(&spec);
455
456        let create_pet = tools.iter().find(|t| t.name == "createPet").unwrap();
457        let props = create_pet
458            .input_schema
459            .get("properties")
460            .and_then(|p| p.as_object())
461            .unwrap();
462        assert!(props.contains_key("body"));
463    }
464
465    #[test]
466    fn output_schema_from_200_response() {
467        let spec = OpenApiSpec::parse(petstore_spec()).unwrap();
468        let gen = ManifestGenerator::new(GeneratorConfig::default());
469        let tools = gen.generate_tools(&spec);
470
471        let list_pets = tools.iter().find(|t| t.name == "listPets").unwrap();
472        assert!(list_pets.output_schema.is_some());
473        let output = list_pets.output_schema.as_ref().unwrap();
474        assert_eq!(output.get("type").and_then(|v| v.as_str()), Some("array"));
475    }
476
477    #[test]
478    fn fallback_name_when_no_operation_id() {
479        let input = r##"{
480            "openapi": "3.0.3",
481            "info": { "title": "T", "version": "1" },
482            "paths": {
483                "/health": {
484                    "get": {
485                        "responses": { "200": { "description": "OK" } }
486                    }
487                }
488            }
489        }"##;
490
491        let spec = OpenApiSpec::parse(input).unwrap();
492        let gen = ManifestGenerator::new(GeneratorConfig::default());
493        let tools = gen.generate_tools(&spec);
494
495        assert_eq!(tools.len(), 1);
496        assert_eq!(tools[0].name, "GET /health");
497    }
498
499    #[test]
500    fn x_chio_publish_false_excludes_operation() {
501        let input = r##"{
502            "openapi": "3.0.3",
503            "info": { "title": "T", "version": "1" },
504            "paths": {
505                "/internal": {
506                    "get": {
507                        "operationId": "internalEndpoint",
508                        "x-chio-publish": false,
509                        "responses": { "200": { "description": "OK" } }
510                    }
511                },
512                "/public": {
513                    "get": {
514                        "operationId": "publicEndpoint",
515                        "responses": { "200": { "description": "OK" } }
516                    }
517                }
518            }
519        }"##;
520
521        let spec = OpenApiSpec::parse(input).unwrap();
522        let gen = ManifestGenerator::new(GeneratorConfig::default());
523        let tools = gen.generate_tools(&spec);
524
525        assert_eq!(tools.len(), 1);
526        assert_eq!(tools[0].name, "publicEndpoint");
527    }
528
529    #[test]
530    fn approval_required_annotation() {
531        let input = r##"{
532            "openapi": "3.0.3",
533            "info": { "title": "T", "version": "1" },
534            "paths": {
535                "/danger": {
536                    "post": {
537                        "operationId": "dangerousAction",
538                        "x-chio-approval-required": true,
539                        "responses": { "200": { "description": "OK" } }
540                    }
541                }
542            }
543        }"##;
544
545        let spec = OpenApiSpec::parse(input).unwrap();
546        let gen = ManifestGenerator::new(GeneratorConfig::default());
547        let tools = gen.generate_tools(&spec);
548
549        assert_eq!(tools.len(), 1);
550        assert!(tools[0].annotations.requires_approval);
551    }
552
553    #[test]
554    fn path_level_parameters_merged() {
555        let input = r##"{
556            "openapi": "3.0.3",
557            "info": { "title": "T", "version": "1" },
558            "paths": {
559                "/orgs/{orgId}/members": {
560                    "parameters": [
561                        { "name": "orgId", "in": "path", "required": true, "schema": { "type": "string" } }
562                    ],
563                    "get": {
564                        "operationId": "listMembers",
565                        "parameters": [
566                            { "name": "page", "in": "query", "schema": { "type": "integer" } }
567                        ],
568                        "responses": { "200": { "description": "OK" } }
569                    }
570                }
571            }
572        }"##;
573
574        let spec = OpenApiSpec::parse(input).unwrap();
575        let gen = ManifestGenerator::new(GeneratorConfig::default());
576        let tools = gen.generate_tools(&spec);
577
578        let tool = &tools[0];
579        let props = tool
580            .input_schema
581            .get("properties")
582            .and_then(|p| p.as_object())
583            .unwrap();
584        assert!(props.contains_key("orgId"));
585        assert!(props.contains_key("page"));
586    }
587}