Files
crank/crates/crank-import/tests/preview.rs
T

616 lines
22 KiB
Rust

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::<Vec<_>>()
.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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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"));
}
}