from __future__ import annotations
import json
import unittest
from pathlib import Path
from tools.api_contracts.codegen_ir import FieldDef, TypePrimitive, parse_api_schema_to_ir
from tools.api_contracts.codegen_render import (
_emit_token_decl,
_field_lines,
render_api_file,
render_endpoint_const_snippet,
)
from tools.api_contracts.models import ApiIdentity
SAMPLES = Path(__file__).resolve().parent.parent / "schema_cache" / "samples"
def _api(meta_name="create", resource="message", name="发送消息") -> ApiIdentity:
return ApiIdentity(
api_id="1",
name=name,
biz_tag="im",
meta_project="im",
meta_version="v1",
meta_resource=resource,
meta_name=meta_name,
url="POST:/open-apis/im/v1/messages",
doc_path="https://open.feishu.cn/document/server-docs/im-v1/message/create",
expected_file="im/im/v1/message/create.rs",
full_path="/document/x",
)
SCHEMA_POST = {
"httpMethod": "post",
"path": "/open-apis/im/v1/messages",
"parameters": [
{"in": "query", "name": "receive_id_type", "required": True,
"schema": {"type": "string", "options": [{"name": "open_id", "value": "open_id"}]}},
{"in": "query", "name": "page_size", "required": False, "schema": {"type": "integer"}},
],
"requestBody": {"content": {"application/json": {"schema": {"properties": [
{"name": "receive_id", "type": "string", "required": True, "description": "接收者"},
{"name": "msg_type", "type": "string", "required": True},
{"name": "uuid", "type": "string", "required": False},
]}}}},
"responses": {"200": {"content": {"application/json": {"schema": {"properties": [
{"name": "code", "type": "integer"},
{"name": "msg", "type": "string"},
{"name": "data", "type": "object", "objectName": "CreateMessageResp",
"properties": [{"name": "message_id", "type": "string"}]},
]}}}}},
}
SCHEMA_GET_PATH = {
"httpMethod": "get",
"path": "/open-apis/im/v1/messages/:message_id",
"parameters": [
{"in": "path", "name": "message_id", "required": True, "schema": {"type": "string"}}
],
"responses": {"200": {"content": {"application/json": {"schema": {"properties": [
{"name": "code", "type": "integer"},
{"name": "msg", "type": "string"},
{"name": "data", "type": "object", "properties": [{"name": "message_id", "type": "string"}]},
]}}}}},
}
SCHEMA_NO_VALIDATE = {
"httpMethod": "get",
"path": "/open-apis/im/v1/messages",
"parameters": [
{"in": "query", "name": "page_size", "required": False, "schema": {"type": "integer"}}
],
"responses": {"200": {"content": {"application/json": {"schema": {"properties": [
{"name": "code", "type": "integer"}, {"name": "msg", "type": "string"}]}}}}},
}
def _assert_core_contracts(testcase, code: str) -> None:
testcase.assertIn("pub async fn execute_with_options", code)
testcase.assertIn("RequestOption::default()", code)
testcase.assertIn("Transport::request(req, &self.config, Some(option))", code)
testcase.assertTrue(
"response.data.ok_or_else" in code or "extract_response_data(resp," in code
)
testcase.assertIn("endpoints::", code)
testcase.assertNotIn("Arc<Config>", code)
testcase.assertNotIn("reqwest::Client", code)
testcase.assertNotIn(", None).await", code)
testcase.assertIn("由 codegen 自动生成", code)
SCHEMA_TENANT_ONLY = {
"httpMethod": "post",
"path": "/open-apis/contact/v3/departments",
"parameters": [],
"requestBody": {"content": {"application/json": {"schema": {"properties": [
{"name": "name", "type": "string", "required": True},
]}}}},
"responses": {"200": {"content": {"application/json": {"schema": {"properties": [
{"name": "code", "type": "integer"}, {"name": "msg", "type": "string"}]}}}}},
"security": {"supportedAccessToken": ["tenant_access_token"]},
}
class EndpointSnippetTest(unittest.TestCase):
def test_snippet(self):
ir = parse_api_schema_to_ir(_api(), SCHEMA_POST)
snippet = render_endpoint_const_snippet(ir)
self.assertEqual(snippet, 'pub const IM_V1_MESSAGES: &str = "/open-apis/im/v1/messages";')
class RenderPostTest(unittest.TestCase):
def setUp(self) -> None:
self.ir = parse_api_schema_to_ir(_api(), SCHEMA_POST)
self.code = render_api_file(self.ir)
def test_core_contracts(self):
_assert_core_contracts(self, self.code)
def test_body_struct(self):
self.assertIn("pub struct CreateMessageBody {", self.code)
self.assertIn("pub receive_id: String,", self.code)
self.assertIn("pub msg_type: String,", self.code)
self.assertIn(' #[serde(skip_serializing_if = "Option::is_none")]', self.code)
self.assertIn("pub uuid: Option<String>,", self.code)
self.assertIn('#[serde(rename_all = "camelCase")]', self.code)
def test_required_body_validation(self):
self.assertIn('validate_required!(body.receive_id, "receive_id 不能为空");', self.code)
self.assertIn('validate_required!(body.msg_type, "msg_type 不能为空");', self.code)
def test_required_query_ok_or_else(self):
self.assertIn("let receive_id_type = self.receive_id_type.ok_or_else", self.code)
self.assertIn('openlark_core::error::validation_error(', self.code)
self.assertIn('.query("receive_id_type", receive_id_type)', self.code)
def test_optional_query_opt(self):
self.assertIn('.query_opt("page_size", self.page_size)', self.code)
def test_endpoint_const_usage(self):
self.assertIn("ApiRequest::post(IM_V1_MESSAGES)", self.code)
def test_execute_delegates(self):
self.assertIn("pub async fn execute(self, body: CreateMessageBody)", self.code)
self.assertIn("self.execute_with_options(body,", self.code)
self.assertIn("RequestOption::default())", self.code)
self.assertIn(".await", self.code)
def test_no_response_wrapper(self):
self.assertNotIn("struct CreateMessageResponse", self.code)
self.assertNotIn("data: Option<", self.code)
def test_tests_block(self):
self.assertIn("#[cfg(test)]", self.code)
self.assertIn("fn test_body_construct()", self.code)
def test_typed_response(self):
self.assertIn("pub struct CreateMessageResp {", self.code)
self.assertIn("impl ApiResponseTrait for CreateMessageResp {", self.code)
self.assertIn(
"pub async fn execute(self, body: CreateMessageBody) -> SDKResult<CreateMessageResp>",
self.code,
)
self.assertIn("ApiRequest<CreateMessageResp>", self.code)
self.assertIn("response.data.ok_or_else", self.code)
class RenderGetPathTest(unittest.TestCase):
def setUp(self) -> None:
self.ir = parse_api_schema_to_ir(_api(meta_name="get"), SCHEMA_GET_PATH)
self.code = render_api_file(self.ir)
def test_core_contracts(self):
_assert_core_contracts(self, self.code)
def test_no_body(self):
self.assertIn("pub async fn execute(self) -> SDKResult<GetMessageResponse>", self.code)
self.assertNotIn("serialize_params", self.code)
self.assertNotIn("Body", self.code)
def test_path_format_url(self):
self.assertIn('format!("{}/{}", IM_V1_MESSAGES, self.message_id)', self.code)
self.assertIn("ApiRequest::get(", self.code)
def test_path_param_validation(self):
self.assertIn('validate_required!(self.message_id, "message_id 不能为空");', self.code)
class RenderNoValidateTest(unittest.TestCase):
def test_no_validate_required_import(self):
ir = parse_api_schema_to_ir(_api(meta_name="list"), SCHEMA_NO_VALIDATE)
code = render_api_file(ir)
self.assertNotIn("validate_required", code)
self.assertNotIn("Body", code)
class RealSampleTest(unittest.TestCase):
def test_render_im_message_create(self):
sample = SAMPLES / "6946222931479527425.json"
if not sample.exists():
self.skipTest("Step 0 dump 样本不存在")
api_schema = (
json.loads(sample.read_text(encoding="utf-8"))
.get("data", {}).get("schema", {}).get("apiSchema")
)
ir = parse_api_schema_to_ir(
ApiIdentity(
api_id="6946222931479527425", name="发送消息", biz_tag="im",
meta_project="im", meta_version="v1", meta_resource="message", meta_name="create",
url="POST:/open-apis/im/v1/messages",
doc_path="https://open.feishu.cn/document/server-docs/im-v1/message/create",
expected_file="im/im/v1/message/create.rs",
full_path="/document/x",
),
api_schema,
)
code = render_api_file(ir)
_assert_core_contracts(self, code)
self.assertIn("pub struct CreateMessageBody {", code)
self.assertIn("pub receive_id: String,", code)
self.assertIn("pub content: String,", code)
Path("/tmp/codegen_create.rs").write_text(code, encoding="utf-8")
class TokenDeclTest(unittest.TestCase):
def test_empty(self):
self.assertIsNone(_emit_token_decl(()))
def test_default_set_unordered(self):
self.assertIsNone(_emit_token_decl(("tenant_access_token", "user_access_token")))
self.assertIsNone(_emit_token_decl(("user_access_token", "tenant_access_token")))
def test_tenant_only(self):
self.assertEqual(
_emit_token_decl(("tenant_access_token",)),
"vec![AccessTokenType::Tenant]",
)
def test_user_only(self):
self.assertEqual(
_emit_token_decl(("user_access_token",)),
"vec![AccessTokenType::User]",
)
def test_app_only(self):
self.assertEqual(
_emit_token_decl(("app_access_token",)),
"vec![AccessTokenType::App]",
)
def test_unknown_degrades_to_default(self):
self.assertIsNone(_emit_token_decl(("lambda_access_token",)))
def test_unknown_skipped_keeps_known(self):
self.assertEqual(
_emit_token_decl(("tenant_access_token", "lambda_access_token")),
"vec![AccessTokenType::Tenant]",
)
class RenderTokenTest(unittest.TestCase):
def test_tenant_only_emits_decl_and_import(self):
ir = parse_api_schema_to_ir(_api(resource="department"), SCHEMA_TENANT_ONLY)
code = render_api_file(ir)
self.assertIn(
".with_supported_access_token_types(vec![AccessTokenType::Tenant])",
code,
)
self.assertIn("constants::AccessTokenType", code)
def test_default_no_token_decl(self):
ir = parse_api_schema_to_ir(_api(), SCHEMA_POST)
code = render_api_file(ir)
self.assertNotIn("with_supported_access_token_types", code)
self.assertNotIn("AccessTokenType", code)
class RenderNestedArrayStructTest(unittest.TestCase):
def test_array_of_object_struct_rendered(self):
schema = {
"httpMethod": "post",
"path": "/open-apis/im/v1/messages",
"requestBody": {"content": {"application/json": {"schema": {"properties": [
{"name": "tags", "type": "array", "required": True,
"items": {"type": "object", "properties": [{"name": "key", "type": "string"}]}},
]}}}},
"responses": {"200": {"content": {"application/json": {"schema": {"properties": [
{"name": "code", "type": "integer"}]}}}}},
}
ir = parse_api_schema_to_ir(_api(), schema)
code = render_api_file(ir)
self.assertIn("pub struct CreateMessageBodyTagsItem {", code)
self.assertIn("Vec<CreateMessageBodyTagsItem>", code)
class FieldLinesFallbackDocTest(unittest.TestCase):
def test_empty_description_falls_back_to_rust_name(self):
field = FieldDef(
name="user_id",
rust_name="user_id",
type_expr=TypePrimitive(rust="String"),
required=True,
description="",
)
lines = _field_lines(field)
self.assertIn(" /// user_id", lines)
for line in lines:
self.assertNotIn("/// ", line) self.assertNotIn("待补充", line)
self.assertNotIn("TODO", line)
def test_present_description_uses_oneliner(self):
field = FieldDef(
name="user_id",
rust_name="user_id",
type_expr=TypePrimitive(rust="String"),
required=True,
description="用户标识",
)
lines = _field_lines(field)
self.assertIn(" /// 用户标识", lines)
def test_every_field_gets_doc_with_schema_post(self):
ir = parse_api_schema_to_ir(_api(), SCHEMA_POST)
code = render_api_file(ir)
self.assertIn(" /// msg_type", code)
self.assertIn(" /// uuid", code)
self.assertIn(" /// 接收者", code)
if __name__ == "__main__":
unittest.main()