1use 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#[derive(Debug, Clone)]
16pub struct GeneratorConfig {
17 pub server_id: String,
21 pub include_output_schemas: bool,
23 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
37pub struct ManifestGenerator {
39 config: GeneratorConfig,
40}
41
42impl ManifestGenerator {
43 #[must_use]
45 pub fn new(config: GeneratorConfig) -> Self {
46 Self { config }
47 }
48
49 #[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 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 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
132fn 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
146fn 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 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
166fn 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 for param in params {
174 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) = ¶m.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 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
221fn 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}