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(
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
136fn 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
150fn 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 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
170fn 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 for param in params {
182 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) = ¶m.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 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
231fn 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}