openlark 0.20.0

飞书开放平台 Rust SDK - 企业级高覆盖率 API 客户端,极简依赖一条命令
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
import tempfile
import unittest
from pathlib import Path

from tools.api_contracts.rust_source import (
    EndpointResolver,
    extract_access_token_types,
    extract_endpoint_calls,
    extract_manual_auth_token,
    extract_rust_response_fields,
    extract_rust_fields,
    load_endpoint_constants,
    load_enum_endpoints,
    load_enum_methods,
    resolve_format_expression,
    scan_api_file,
)

REPO_ROOT = Path(__file__).resolve().parents[2]


class RustSourceContractTests(unittest.TestCase):
    def test_load_endpoint_constants_resolves_aliases(self):
        with tempfile.TemporaryDirectory() as temp_dir:
            src = Path(temp_dir)
            (src / "endpoints").mkdir()
            (src / "endpoints" / "mod.rs").write_text(
                '\n'.join(
                    [
                        'pub const BANK_CARD: &str = "/open-apis/document_ai/v1/bank_card/recognize";',
                        "pub const BANK_CARD_ALIAS: &str = BANK_CARD;",
                    ]
                ),
                encoding="utf-8",
            )

            constants = load_endpoint_constants(src)

        self.assertEqual(
            constants["BANK_CARD_ALIAS"],
            "/open-apis/document_ai/v1/bank_card/recognize",
        )

    def test_extract_endpoint_calls_resolves_direct_constant(self):
        text = """
        let req: ApiRequest<Response> =
            ApiRequest::post(DOCUMENT_AI_BANK_CARD_RECOGNIZE)
                .body(body);
        """
        resolver = EndpointResolver(
            {"DOCUMENT_AI_BANK_CARD_RECOGNIZE": "/open-apis/document_ai/v1/bank_card/recognize"}
        )

        calls = extract_endpoint_calls(text, resolver)

        self.assertEqual(len(calls), 1)
        self.assertEqual(calls[0].method, "POST")
        self.assertEqual(calls[0].resolved_path, "/open-apis/document_ai/v1/bank_card/recognize")

    def test_extract_endpoint_calls_marks_to_url_unresolved(self):
        text = "let req: ApiRequest<Response> = ApiRequest::get(&api_endpoint.to_url());"

        calls = extract_endpoint_calls(text, EndpointResolver({}))

        self.assertEqual(len(calls), 1)
        self.assertFalse(calls[0].is_resolved)
        self.assertIn("to_url", calls[0].unresolved_reason)

    def test_resolve_format_expression_with_constant_and_parameter(self):
        resolved = resolve_format_expression(
            'format!("{}/{}", IM_V1_CHATS, self.chat_id)',
            {"IM_V1_CHATS": "/open-apis/im/v1/chats"},
        )

        self.assertEqual(resolved, "/open-apis/im/v1/chats/{param}")

    def test_resolve_format_expression_with_captured_constant(self):
        resolved = resolve_format_expression(
            'format!("{IM_V1_CHATS}/search")',
            {"IM_V1_CHATS": "/open-apis/im/v1/chats"},
        )

        self.assertEqual(resolved, "/open-apis/im/v1/chats/search")

    def test_scan_api_file_extracts_endpoint_contract(self):
        with tempfile.TemporaryDirectory() as temp_dir:
            src = Path(temp_dir)
            (src / "ai" / "document_ai" / "v1" / "bank_card").mkdir(parents=True)
            (src / "endpoints.rs").write_text(
                'pub const BANK_CARD: &str = "/open-apis/document_ai/v1/bank_card/recognize";',
                encoding="utf-8",
            )
            (src / "ai" / "document_ai" / "v1" / "bank_card" / "recognize.rs").write_text(
                "let req: ApiRequest<Response> = ApiRequest::post(BANK_CARD);",
                encoding="utf-8",
            )

            contract = scan_api_file(src, "ai/document_ai/v1/bank_card/recognize.rs")

        self.assertIsNotNone(contract)
        assert contract is not None
        self.assertEqual(contract.endpoint_calls[0].resolved_path, "/open-apis/document_ai/v1/bank_card/recognize")

    def test_extract_rust_fields_uses_serde_rename_and_optional_type(self):
        text = """
        #[derive(Debug, Clone, Serialize)]
        pub struct BankCardRecognizeBody {
            #[serde(rename = "file")]
            pub file_token: String,
            #[serde(skip_serializing_if = "Option::is_none")]
            pub is_async: Option<bool>,
        }

        pub struct BankCardRecognizeRequest {
            pub config: Config,
        }
        """

        fields = extract_rust_fields(text)

        self.assertEqual([field.serialized_name for field in fields], ["file", "is_async"])
        self.assertFalse(fields[0].optional)
        self.assertTrue(fields[1].optional)

    def test_extract_rust_fields_applies_camel_case_rename_all(self):
        text = """
        #[serde(rename_all = "camelCase")]
        pub struct ListQuery {
            pub page_size: Option<i32>,
        }
        """

        fields = extract_rust_fields(text)

        self.assertEqual(fields[0].serialized_name, "pageSize")

    def test_extract_rust_fields_maps_file_content_to_multipart_file_field(self):
        text = """
        pub struct BankCardRecognizeBody {
            #[serde(skip_serializing)]
            pub file: Vec<u8>,
        }

        let req: ApiRequest<Response> = ApiRequest::post(BANK_CARD)
            .body(body)
            .file_content(body.file.clone());
        """

        fields = extract_rust_fields(text)

        self.assertEqual([field.serialized_name for field in fields], ["file"])
        self.assertEqual(fields[0].struct_name, "MultipartFile")
        self.assertFalse(fields[0].optional)

    def test_extract_rust_response_fields_reads_response_and_result_structs(self):
        text = """
        pub struct BankCardRecognizeResponse {
            pub data: Option<BankCardRecognizeResult>,
        }

        pub struct BankCardRecognizeResult {
            pub parsing_result: Option<ParsingResult>,
        }

        pub struct BankCardRecognizeBody {
            pub file_token: String,
        }
        """

        fields = extract_rust_response_fields(text)

        self.assertEqual([field.serialized_name for field in fields], ["data", "parsing_result"])

    def test_extract_rust_response_fields_reads_resp_suffix_struct(self):
        # baike 的 MatchEntityResp 等用 Resp 后缀命名响应 struct
        text = """
        pub struct MatchEntityResp {
            #[serde(default)]
            pub results: Vec<MatchEntityResult>,
        }
        """

        fields = extract_rust_response_fields(text)

        self.assertEqual([field.serialized_name for field in fields], ["results"])

    def test_extract_rust_fields_collects_multipart_meta_struct_fields(self):
        # drive 上传:局部 UploadMeta 结构体组织 multipart 表单字段
        text = """
        pub struct UploadAllResponse {
            pub file_token: String,
        }

        pub async fn execute(self) -> SDKResult<UploadAllResponse> {
            #[derive(Serialize)]
            struct UploadMeta {
                file_name: String,
                parent_type: String,
                parent_node: String,
                size: usize,
                #[serde(skip_serializing_if = "Option::is_none")]
                checksum: Option<String>,
            }

            let request = ApiRequest::<UploadAllResponse>::post(&api_endpoint.to_url())
                .json_body(&meta)
                .file_content(self.file);
        }
        """

        fields = extract_rust_fields(text)

        names = {field.serialized_name for field in fields}
        self.assertIn("file", names)
        self.assertIn("file_name", names)
        self.assertIn("parent_type", names)
        self.assertIn("parent_node", names)
        self.assertIn("size", names)
        self.assertIn("checksum", names)

    def test_extract_rust_fields_collects_json_literal_multipart_keys(self):
        # baike 上传:serde_json::json!({"name": ..., "__file_name": ...})
        text = """
        pub struct UploadFileResponse {
            pub file_token: String,
        }

        let body = serde_json::json!({
            "name": name,
            "__file_name": name,
        });

        let api_request: ApiRequest<UploadFileResponse> =
            ApiRequest::post(&BaikeApiV1::FileUpload.to_url())
                .body(body)
                .file_content(self.file);
        """

        fields = extract_rust_fields(text)

        names = {field.serialized_name for field in fields}
        self.assertIn("file", names)
        self.assertIn("name", names)
        # 内部字段(下划线前缀)不应作为表单字段
        self.assertNotIn("__file_name", names)

    def test_scan_api_file_detects_flatten_value_passthrough(self):
        # docx block patch:#[serde(flatten)] update: serde_json::Value(透传写法)
        text = """
        pub struct UpdateDocumentBlockParams {
            #[serde(skip_serializing)]
            pub document_id: String,
            #[serde(flatten)]
            pub update: serde_json::Value,
        }

        let req: ApiRequest<Response> = ApiRequest::post(BANK_CARD);
        """

        with tempfile.TemporaryDirectory() as temp_dir:
            src = Path(temp_dir)
            (src / "endpoints.rs").write_text(
                'pub const BANK_CARD: &str = "/open-apis/x/v1/y";',
                encoding="utf-8",
            )
            (src / "docx").mkdir(parents=True)
            (src / "docx" / "patch.rs").write_text(text, encoding="utf-8")

            contract = scan_api_file(src, "docx/patch.rs")

        self.assertIsNotNone(contract)
        assert contract is not None
        self.assertTrue(contract.has_flatten_value_passthrough)

    def test_scan_api_file_detects_flatten_typed_enum_passthrough(self):
        # docx block patch:#[serde(flatten)] update: BlockUpdateOperation(typed 枚举写法)
        text = """
        pub struct UpdateDocumentBlockParams {
            #[serde(skip_serializing)]
            pub document_id: String,
            #[serde(flatten)]
            pub update: BlockUpdateOperation,
        }

        let req: ApiRequest<Response> = ApiRequest::post(BANK_CARD);
        """

        with tempfile.TemporaryDirectory() as temp_dir:
            src = Path(temp_dir)
            (src / "endpoints.rs").write_text(
                'pub const BANK_CARD: &str = "/open-apis/x/v1/y";',
                encoding="utf-8",
            )
            (src / "docx").mkdir(parents=True)
            (src / "docx" / "patch.rs").write_text(text, encoding="utf-8")

            contract = scan_api_file(src, "docx/patch.rs")

        self.assertIsNotNone(contract)
        assert contract is not None
        self.assertTrue(contract.has_flatten_value_passthrough)




class DocsCatalogEndpointResolverTests(unittest.TestCase):
    """#568:docs 域 CatalogEndpoint / .to_request() 解析盲区。"""

    def test_load_enum_endpoints_reads_api_endpoints_submodules(self):
        src = REPO_ROOT / "crates" / "openlark-docs" / "src"
        endpoints = load_enum_endpoints(src, load_endpoint_constants(src))
        self.assertIn("LingoApiV1::RepoList", endpoints)
        self.assertEqual(endpoints["LingoApiV1::RepoList"], "/open-apis/lingo/v1/repos")
        self.assertIn("BaseApiV2::RoleCreate", endpoints)
        self.assertEqual(
            endpoints["BaseApiV2::RoleCreate"],
            "/open-apis/base/v2/apps/{param}/roles",
        )

    def test_load_enum_endpoints_keeps_baike_and_lingo_path_prefixes_distinct(self):
        src = REPO_ROOT / "crates" / "openlark-docs" / "src"
        endpoints = load_enum_endpoints(src, load_endpoint_constants(src))
        self.assertEqual(
            endpoints["BaikeApiV1::DraftUpdate"],
            "/open-apis/baike/v1/drafts/{param}",
        )
        self.assertEqual(
            endpoints["LingoApiV1::DraftUpdate"],
            "/open-apis/lingo/v1/drafts/{param}",
        )
        self.assertEqual(
            endpoints["LingoApiV1::EntityMatch"],
            "/open-apis/lingo/v1/entities/match",
        )
        self.assertEqual(
            endpoints["BaikeApiV1::EntityMatch"],
            "/open-apis/baike/v1/entities/match",
        )

    def test_load_enum_methods_from_catalog_endpoint_impl(self):
        src = REPO_ROOT / "crates" / "openlark-docs" / "src"
        methods = load_enum_methods(src)
        self.assertEqual(methods.get("LingoApiV1::RepoList"), "GET")
        self.assertEqual(methods.get("LingoApiV1::DraftUpdate"), "PUT")
        self.assertEqual(methods.get("LingoApiV1::EntityDelete"), "DELETE")
        self.assertEqual(methods.get("BaseApiV2::RoleCreate"), "POST")
        self.assertEqual(methods.get("BaikeApiV1::DraftUpdate"), "PUT")
        self.assertEqual(methods.get("MinutesExtraApiV1::Search"), "POST")

    def test_extract_endpoint_calls_resolves_direct_to_request(self):
        text = """
        let api_request: ApiRequest<ListRepoResp> = LingoApiV1::RepoList.to_request();
        """
        resolver = EndpointResolver(
            constants={},
            enum_endpoints={"LingoApiV1::RepoList": "/open-apis/lingo/v1/repos"},
            enum_methods={"LingoApiV1::RepoList": "GET"},
        )
        calls = extract_endpoint_calls(text, resolver)
        self.assertEqual(len(calls), 1)
        self.assertTrue(calls[0].is_resolved)
        self.assertEqual(calls[0].method, "GET")
        self.assertEqual(calls[0].resolved_path, "/open-apis/lingo/v1/repos")

    def test_extract_endpoint_calls_resolves_to_request_with_variant_args(self):
        text = """
        let mut api_request: ApiRequest<UpdateDraftResp> = BaikeApiV1::DraftUpdate(self.draft_id)
            .to_request()
            .body(serde_json::to_value(&self.req)?);
        """
        resolver = EndpointResolver(
            constants={},
            enum_endpoints={"BaikeApiV1::DraftUpdate": "/open-apis/baike/v1/drafts/{param}"},
            enum_methods={"BaikeApiV1::DraftUpdate": "PUT"},
        )
        calls = extract_endpoint_calls(text, resolver)
        self.assertEqual(len(calls), 1)
        self.assertTrue(calls[0].is_resolved)
        self.assertEqual(calls[0].method, "PUT")
        self.assertEqual(calls[0].resolved_path, "/open-apis/baike/v1/drafts/{param}")

    def test_extract_endpoint_calls_resolves_variable_to_request(self):
        text = """
        let api_endpoint = SheetsApiV3::GetFilter(spreadsheet_token.to_string(), sheet_id.to_string());
        let api_request: ApiRequest<GetFilterResponse> = api_endpoint.to_request();
        """
        resolver = EndpointResolver(
            constants={},
            enum_endpoints={
                "SheetsApiV3::GetFilter": (
                    "/open-apis/sheets/v3/spreadsheets/{param}/sheets/{param}/filter"
                )
            },
            enum_methods={"SheetsApiV3::GetFilter": "GET"},
        )
        calls = extract_endpoint_calls(text, resolver)
        self.assertEqual(len(calls), 1)
        self.assertTrue(calls[0].is_resolved)
        self.assertEqual(calls[0].method, "GET")

    def test_scan_api_file_resolves_docs_catalog_to_request(self):
        src = REPO_ROOT / "crates" / "openlark-docs" / "src"
        contract = scan_api_file(src, "baike/lingo/v1/repo/list.rs")
        self.assertIsNotNone(contract)
        assert contract is not None
        self.assertTrue(contract.endpoint_calls)
        self.assertTrue(contract.endpoint_calls[0].is_resolved)
        self.assertEqual(contract.endpoint_calls[0].method, "GET")
        self.assertEqual(
            contract.endpoint_calls[0].resolved_path,
            "/open-apis/lingo/v1/repos",
        )


class ExtractAccessTokensTests(unittest.TestCase):
    """token 契约:解析 .with_supported_access_token_types 声明(未声明回落默认)。"""

    def test_explicit_single_app(self):
        source = (
            "let req = ApiRequest::get(&path)"
            ".with_supported_access_token_types(vec![AccessTokenType::App]);"
        )
        self.assertEqual(extract_access_token_types(source), ("app_access_token",))

    def test_explicit_none(self):
        source = ".with_supported_access_token_types(vec![AccessTokenType::None]);"
        self.assertEqual(extract_access_token_types(source), ("none_access_token",))

    def test_explicit_multiple_variants(self):
        source = (
            ".with_supported_access_token_types("
            "vec![AccessTokenType::User, AccessTokenType::Tenant]);"
        )
        self.assertEqual(
            extract_access_token_types(source),
            ("user_access_token", "tenant_access_token"),
        )

    def test_multiline_vec_literal(self):
        source = (
            ".with_supported_access_token_types(vec![\n"
            "    AccessTokenType::User,\n"
            "    AccessTokenType::Tenant,\n"
            "]);"
        )
        self.assertEqual(
            extract_access_token_types(source),
            ("user_access_token", "tenant_access_token"),
        )

    def test_no_call_returns_default(self):
        # ApiRequest 默认 supported_access_token_types = [User, Tenant](见 api/mod.rs)
        self.assertEqual(
            extract_access_token_types("let req = ApiRequest::get(&path);"),
            ("user_access_token", "tenant_access_token"),
        )

    def test_real_auth_token_endpoint_declares_none(self):
        # 锁定提取器对真实 .rs 源码的解析能力。选用 #512 明确不动(保持 App/None)
        # 的 auth/v3 token 端点,避免被 #515 的 acs/security 修正连带改坏。
        source = (
            REPO_ROOT
            / "crates/openlark-auth/src/auth/auth/v3/auth/tenant_access_token_internal.rs"
        ).read_text(encoding="utf-8")
        self.assertEqual(extract_access_token_types(source), ("none_access_token",))


class ExtractManualAuthTokenTests(unittest.TestCase):
    """token 契约:识别声明 None 但手动注入 ``Authorization: Bearer`` 的端点(OIDC userinfo)。

    声明 ``AccessTokenType::None`` 表示自行管理鉴权(bypass token cache)。validator 据此
    把 ``none_access_token`` 替换为实际注入的 token 类型,避免误报 disjoint ERROR(#515)。
    """

    def test_detects_manual_user_token_bearer_injection(self):
        source = (
            "ApiRequest::get(&path)"
            '.header("Authorization", format!("Bearer {}", self.user_access_token))'
            ".with_supported_access_token_types(vec![AccessTokenType::None]);"
        )
        self.assertEqual(extract_manual_auth_token(source), "user_access_token")

    def test_detects_multiline_header_injection(self):
        # 真实 userinfo 写法:header 调用跨多行
        source = (
            "ApiRequest::get(api_endpoint.path())\n"
            "    .header(\n"
            '        "Authorization",\n'
            '        format!("Bearer {}", self.user_access_token),\n'
            "    )\n"
            "    .with_supported_access_token_types(vec![AccessTokenType::None]);"
        )
        self.assertEqual(extract_manual_auth_token(source), "user_access_token")

    def test_no_bearer_injection_returns_empty(self):
        # 真正无鉴权的 token 签发端点(tenant_access_token_internal):声明 None 但不注入 Bearer
        source = (
            "ApiRequest::post(path)"
            ".with_supported_access_token_types(vec![AccessTokenType::None]);"
        )
        self.assertEqual(extract_manual_auth_token(source), "")

    def test_real_userinfo_source_detected(self):
        source = (
            REPO_ROOT / "crates/openlark-auth/src/auth/authen/v1/user_info/get.rs"
        ).read_text(encoding="utf-8")
        self.assertEqual(extract_manual_auth_token(source), "user_access_token")


if __name__ == "__main__":
    unittest.main()