616 lines
22 KiB
Rust
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_preserves_unresolved_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, 0);
|
|
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"));
|
|
}
|
|
}
|