mod unit { use crank_core::HttpMethod; use crank_import::rest::{ImportParseError, preview_document, preview_document_legacy_v1}; use crank_schema::SchemaKind; use serde_json::json; const OPENAPI3: &str = r#" openapi: 3.0.3 info: title: Frankfurter API servers: - url: https://api.frankfurter.dev paths: /v2/latest: get: operationId: getLatestRates summary: Получить последние курсы description: Возвращает последние курсы валют для базовой валюты. tags: [currency] parameters: - name: base in: query required: true schema: type: string - name: symbols in: query schema: type: string responses: '200': description: OK content: application/json: schema: type: object required: [amount, base] properties: amount: type: number base: type: string "#; const SWAGGER2: &str = r#" swagger: "2.0" info: title: Pet API host: petstore.example.com basePath: /api schemes: [https] paths: /pets/{id}: get: operationId: getPet summary: Получить питомца tags: [pets] parameters: - name: id in: path required: true type: string responses: '200': description: OK schema: $ref: '#/definitions/Pet' definitions: Pet: type: object required: [id, name] properties: id: type: string name: type: string "#; #[test] fn previews_openapi3_rest_operations_grouped_by_tag() { let preview = preview_document(OPENAPI3).unwrap(); assert_eq!(preview.source.format, "openapi"); assert_eq!(preview.source.servers, vec!["https://api.frankfurter.dev"]); assert_eq!(preview.groups.len(), 1); assert_eq!(preview.groups[0].key, "currency"); let operation = &preview.groups[0].operations[0]; assert_eq!(operation.method, HttpMethod::Get); assert_eq!(operation.suggested_name, "get_latest_rates"); assert_eq!(operation.input_fields, 2); assert_eq!(operation.output_fields, 2); assert_eq!(operation.draft.target.path_template, "/v2/latest"); assert_eq!(operation.draft.input_mapping.rules.len(), 2); } #[test] fn previews_swagger2_and_resolves_local_definitions() { let preview = preview_document(SWAGGER2).unwrap(); assert_eq!(preview.source.format, "swagger"); assert_eq!( preview.source.servers, vec!["https://petstore.example.com/api"] ); let operation = &preview.groups[0].operations[0]; assert_eq!(operation.suggested_name, "get_pet"); assert_eq!(operation.input_fields, 1); assert_eq!(operation.output_fields, 2); assert_eq!(operation.draft.target.path_template, "/pets/{id}"); assert_eq!( operation.draft.input_mapping.rules[0].target, "$.request.path.id" ); } #[test] fn normalized_schema_descriptions_round_trip_to_draft_fields_and_arrays_default_to_string_items() { let document = r#" openapi: 3.1.0 info: { title: Schema descriptions } paths: /items: post: operationId: createItem parameters: - name: filter in: query schema: type: object description: Filter input properties: nested: type: object description: Nested filter properties: labels: type: array description: Labels without item schema requestBody: content: application/json: schema: type: object description: Request root properties: payload: type: object description: Request payload properties: enabled: { type: boolean, description: Enable the item } responses: '201': description: created content: application/json: schema: type: object description: Created item response properties: id: { type: string, description: New item identifier } "#; let preview = preview_document(document).unwrap(); let operation = &preview.groups[0].operations[0]; let filter = operation.draft.input_schema.field("filter").unwrap(); assert_eq!(filter.description.as_deref(), Some("Filter input")); let nested = filter.field("nested").unwrap(); assert_eq!(nested.description.as_deref(), Some("Nested filter")); let labels = nested.field("labels").unwrap(); assert_eq!( labels.description.as_deref(), Some("Labels without item schema") ); assert_eq!(labels.kind, SchemaKind::Array); assert_eq!(labels.items.as_ref().unwrap().kind, SchemaKind::String); let payload = operation.draft.input_schema.field("payload").unwrap(); assert_eq!(payload.description.as_deref(), Some("Request payload")); assert_eq!( payload.field("enabled").unwrap().description.as_deref(), Some("Enable the item") ); assert_eq!( operation.draft.output_schema.description.as_deref(), Some("Ответ API") ); assert_eq!( operation .draft .output_schema .field("id") .unwrap() .description .as_deref(), Some("New item identifier") ); } #[test] fn legacy_v1_keeps_frozen_response_priority_when_v2_selects_later_success_code() { let document = r#" openapi: 3.1.0 info: { title: Response replay priority } paths: /items: get: operationId: listItems responses: '200': { description: no schema } '203': { description: selected by v2, content: { application/json: { schema: { type: string } } } } '206': { description: later success, content: { application/json: { schema: { type: integer } } } } default: { description: legacy fallback, content: { application/json: { schema: { type: boolean } } } } "#; let v2 = preview_document(document).unwrap(); assert_eq!( v2.groups[0].operations[0].draft.output_schema.kind, SchemaKind::String ); let legacy = preview_document_legacy_v1(document).unwrap(); assert_eq!( legacy.groups[0].operations[0].draft.output_schema.kind, SchemaKind::Boolean ); } #[test] fn legacy_v1_projection_matches_baseline_local_ref_and_composition_behavior() { let document = r#" swagger: '2.0' info: { title: Legacy } paths: /pets: get: responses: '200': { description: OK, schema: { $ref: '#/definitions/Pet' } } definitions: Pet: allOf: - type: object properties: { id: { type: string }, name: { type: string } } "#; let preview = preview_document_legacy_v1(document).unwrap(); assert_eq!(preview.groups[0].operations[0].output_fields, 2); } #[test] fn legacy_v1_resolves_object_level_local_refs_before_projection() { let document = r#" openapi: 3.0.3 info: { title: Legacy object refs } paths: /items: get: operationId: getItem parameters: [{ $ref: '#/components/parameters/Id' }] requestBody: { $ref: '#/components/requestBodies/Body' } responses: '200': { $ref: '#/components/responses/Ok' } components: parameters: Id: name: id in: query schema: { type: string } requestBodies: Body: content: application/json: schema: { $ref: '#/components/schemas/Input' } responses: Ok: description: ok content: application/json: schema: { $ref: '#/components/schemas/Output' } schemas: Input: type: object properties: { name: { type: string } } Output: type: object properties: { result: { type: string } } "#; let preview = preview_document_legacy_v1(document).unwrap(); let operation = &preview.groups[0].operations[0]; assert_eq!(operation.input_fields, 2); assert_eq!(operation.output_fields, 1); assert!(operation.draft.input_schema.fields.contains_key("id")); assert!(operation.draft.input_schema.fields.contains_key("name")); assert!(operation.draft.output_schema.fields.contains_key("result")); } #[test] fn legacy_v1_rejects_hostile_alias_input_before_decode() { let document = format!( "openapi: 3.0.3\ninfo: {{ title: aliases }}\npaths: {{}}\nitems: [{}]", std::iter::repeat_n("*bomb", 129) .collect::>() .join(", ") ); assert_eq!( preview_document_legacy_v1(&document), Err(ImportParseError::LimitExceeded) ); } #[test] fn legacy_v1_keeps_baseline_version_dispatch_and_does_not_expand_path_item_refs() { let document = r#" openapi: 9.9.0 info: { title: Legacy dispatch } paths: /valid: get: operationId: valid responses: { '200': { description: ok } } /referenced: $ref: '#/x-path-items/referenced' x-path-items: referenced: post: operationId: mustNotAppear responses: { '201': { description: ok } } "#; let preview = preview_document_legacy_v1(document).unwrap(); assert_eq!(preview.source.version.as_deref(), Some("9.9.0")); let keys = preview .groups .iter() .flat_map(|group| group.operations.iter()) .map(|operation| operation.key.as_str()) .collect::>(); assert_eq!(keys, vec!["GET /valid"]); } #[test] fn legacy_v1_preview_exactly_matches_baseline_for_path_refs_servers_and_malformed_operations() { let document = r#" openapi: 3.0.3 info: { title: Legacy parity } servers: [{ url: https://document.test }] paths: /items: parameters: - { $ref: '#/components/parameters/Query' } - { name: session, in: cookie } servers: [{ url: https://path.test }] head: { responses: {} } get: false components: parameters: Query: { name: q, in: query, schema: { type: string } } "#; let preview = preview_document_legacy_v1(document).unwrap(); assert_eq!( serde_json::to_value(preview).unwrap(), json!({ "source": { "format": "openapi", "version": "3.0.3", "title": "Legacy parity", "servers": ["https://document.test"] }, "groups": [{ "key": "imported_operation", "title": "Без группы", "operations": [{ "key": "GET /items", "method": "GET", "path": "/items", "suggested_name": "g_e_t_items", "suggested_display_name": "G E T Items", "description": "Выполняет GET /items", "category": "imported", "input_fields": 1, "output_fields": 0, "server_urls": ["https://document.test"], "findings": [ { "code": "missing_operation_id", "severity": "warning", "message": "У метода нет operationId, имя инструмента будет сгенерировано из метода и пути.", "operation_key": "GET /items" }, { "code": "missing_summary", "severity": "warning", "message": "У метода нет summary, отображаемое имя будет сгенерировано автоматически.", "operation_key": "GET /items" }, { "code": "missing_description", "severity": "warning", "message": "У метода нет description. Перед публикацией лучше описать, когда агенту стоит вызывать этот инструмент.", "operation_key": "GET /items" }, { "code": "missing_response_schema", "severity": "warning", "message": "У метода не найдена схема успешного ответа, результат будет описан как общий объект.", "operation_key": "GET /items" }, { "code": "parameter_descriptions_missing", "severity": "warning", "message": "У 1 входных параметров нет описания. Модели будет сложнее понять, какие значения туда передавать.", "operation_key": "GET /items" }, { "code": "empty_output_schema", "severity": "warning", "message": "В ответе не найдено отдельных полей. Перед публикацией проверьте схему ответа и маппинг результата.", "operation_key": "GET /items" }, { "code": "weak_tool_description", "severity": "warning", "message": "Описание инструмента слишком короткое или техническое. Перед публикацией добавьте, когда агент должен вызывать инструмент и что будет в успешном ответе.", "operation_key": "GET /items" }, { "code": "weak_tool_name", "severity": "warning", "message": "Имя инструмента `g_e_t_items` выглядит слишком общим. Лучше использовать имя с конкретным действием и объектом.", "operation_key": "GET /items" } ], "draft": { "name": "g_e_t_items", "display_name": "G E T Items", "category": "imported", "target": { "base_url": "https://document.test", "method": "GET", "path_template": "/items" }, "input_schema": { "type": "object", "description": "Входные параметры MCP-инструмента", "required": true, "nullable": false, "fields": { "q": { "type": "string", "required": false, "nullable": false } } }, "output_schema": { "type": "object", "description": "Ответ API", "required": true, "nullable": false }, "input_mapping": { "rules": [{ "source": "$.mcp.q", "target": "$.request.query.q", "required": false }] }, "output_mapping": { "rules": [{ "source": "$.response.body", "target": "$.output", "required": true }] }, "tool_description": { "title": "G E T Items", "description": "Выполняет GET /items", "examples": [{ "input": {} }] }, "wizard_state": {} } }] }], "findings": [] }) ); } #[test] fn reports_missing_descriptions_as_recommendations() { let document = r#" openapi: 3.0.3 info: { title: Minimal API } paths: /items: get: responses: '204': { description: Empty } "#; let preview = preview_document(document).unwrap(); let operation = &preview.groups[0].operations[0]; let codes = operation .findings .iter() .map(|finding| finding.code.as_str()) .collect::>(); assert!(codes.contains(&"missing_operation_id")); assert!(codes.contains(&"missing_summary")); assert!(codes.contains(&"missing_description")); assert!(codes.contains(&"missing_response_schema")); } #[test] fn expands_json_request_body_object_into_tool_inputs() { let document = r#" openapi: 3.0.3 info: { title: CRM API } servers: - url: https://crm.example.test paths: /leads: post: operationId: createLead summary: Создать лид description: Создает лид в CRM. tags: [crm] requestBody: required: true content: application/json: schema: type: object required: [email] properties: email: { type: string } name: { type: string } responses: '201': description: Created content: application/json: schema: type: object properties: id: { type: string } "#; let preview = preview_document(document).unwrap(); let operation = &preview.groups[0].operations[0]; let input_fields = &operation.draft.input_schema.fields; let targets = operation .draft .input_mapping .rules .iter() .map(|rule| rule.target.as_str()) .collect::>(); assert!(input_fields.contains_key("email")); assert!(input_fields.contains_key("name")); assert!(targets.contains(&"$.request.body.email")); assert!(targets.contains(&"$.request.body.name")); } #[test] fn reports_tool_quality_recommendations_for_imported_operations() { let document = r#" openapi: 3.0.3 info: { title: Wide API } paths: /items: get: operationId: getItems summary: Get items description: Get items. parameters: - name: page in: query schema: { type: integer } responses: '200': description: OK content: application/json: schema: type: object properties: field01: { type: string } field02: { type: string } field03: { type: string } field04: { type: string } field05: { type: string } field06: { type: string } field07: { type: string } field08: { type: string } field09: { type: string } field10: { type: string } field11: { type: string } field12: { type: string } field13: { type: string } "#; let preview = preview_document(document).unwrap(); let operation = &preview.groups[0].operations[0]; let codes = operation .findings .iter() .map(|finding| finding.code.as_str()) .collect::>(); assert!(codes.contains(&"parameter_descriptions_missing")); assert!(codes.contains(&"weak_tool_description")); assert!(codes.contains(&"weak_tool_name")); assert!(codes.contains(&"too_many_output_fields")); } }