diff --git a/examples/fit-example/07-http-client-proxy/plugin-http-client/src/main/java/modelengine/fit/example/client/TestAuthClient.java b/examples/fit-example/07-http-client-proxy/plugin-http-client/src/main/java/modelengine/fit/example/client/TestAuthClient.java index 61df588a4..f02f6aa7c 100644 --- a/examples/fit-example/07-http-client-proxy/plugin-http-client/src/main/java/modelengine/fit/example/client/TestAuthClient.java +++ b/examples/fit-example/07-http-client-proxy/plugin-http-client/src/main/java/modelengine/fit/example/client/TestAuthClient.java @@ -27,86 +27,105 @@ @HttpProxy @RequestAddress(protocol = "http", host = "localhost", port = "8080") @RequestMapping(path = "/http-server/auth") -/** - * 接口级别的默认鉴权:API Key - */ @RequestAuth(type = AuthType.API_KEY, name = "X-Service-Key", value = "service-default-key") public interface TestAuthClient extends TestAuthInterface { - @Override - @GetMapping(path = "/bearer-static") /** * 方法级别覆盖:使用 Bearer Token */ + @Override + @GetMapping(path = "/bearer-static") @RequestAuth(type = AuthType.BEARER, value = "static-bearer-token-12345") String testBearerStatic(); - @Override - @GetMapping(path = "/bearer-dynamic") /** * 方法级别覆盖:使用参数驱动的 Bearer Token */ + @Override + @GetMapping(path = "/bearer-dynamic") String testBearerDynamic(@RequestAuth(type = AuthType.BEARER) String token); - @Override - @GetMapping(path = "/basic-static") /** * 方法级别覆盖:使用 Basic Auth */ + @Override + @GetMapping(path = "/basic-static") @RequestAuth(type = AuthType.BASIC, username = "admin", password = "secret123") String testBasicStatic(); - @Override - @GetMapping(path = "/apikey-header-static") /** * 方法级别覆盖:API Key 在 Header 中 */ + @Override + @GetMapping(path = "/apikey-header-static") @RequestAuth(type = AuthType.API_KEY, name = "X-API-Key", value = "static-api-key-67890") String testApiKeyHeaderStatic(); - @Override - @GetMapping(path = "/apikey-query-static") /** * 方法级别覆盖:API Key 在 Query 参数中 */ + + @Override + @GetMapping(path = "/apikey-query-static") @RequestAuth(type = AuthType.API_KEY, name = "api_key", value = "query-api-key-111", location = Source.QUERY) String testApiKeyQueryStatic(); - @Override - @GetMapping(path = "/apikey-dynamic") /** * 参数驱动的 API Key */ + @Override + @GetMapping(path = "/apikey-dynamic") String testApiKeyDynamic(@RequestAuth(type = AuthType.API_KEY, name = "X-Dynamic-Key") String apiKey); - @Override - @GetMapping(path = "/dynamic-provider") /** * 方法级别覆盖:使用动态 Token Provider */ + @Override + @GetMapping(path = "/dynamic-provider") @RequestAuth(type = AuthType.BEARER, provider = DynamicTokenProvider.class) String testDynamicProvider(); - @Override - @GetMapping(path = "/custom-provider") /** * 方法级别覆盖:使用自定义签名 Provider */ + @Override + @GetMapping(path = "/custom-provider") @RequestAuth(type = AuthType.CUSTOM, provider = CustomSignatureProvider.class) String testCustomProvider(); - @Override - @GetMapping(path = "/method-override") /** * 方法级别覆盖:使用 API Key Provider */ + @Override + @GetMapping(path = "/method-override") @RequestAuth(type = AuthType.API_KEY, provider = ApiKeyProvider.class) String testMethodOverride(); - @Override - @GetMapping(path = "/combined-auth") /** * 组合鉴权:服务级 API Key + 用户 Token */ + @Override + @GetMapping(path = "/combined-auth") @RequestAuth(type = AuthType.BEARER, provider = DynamicTokenProvider.class) String testCombinedAuth(@RequestAuth(type = AuthType.API_KEY, name = "X-User-Context") String userToken); + + /** + * 参数级别的 Basic Auth - 使用参数覆盖静态配置的 username + *

演示:方法级别提供完整的 BASIC 认证(username + password), + * 参数级别动态覆盖 username 字段(不指定 name 时默认更新 username)

+ */ + @Override + @GetMapping(path = "/basic-dynamic-username") + @RequestAuth(type = AuthType.BASIC, username = "static-user", password = "static-password") + String testBasicDynamicUsername(@RequestAuth(type = AuthType.BASIC) String username); + + /** + * 参数级别的 Basic Auth - 使用参数分别覆盖 username 和 password + *

演示:方法级别提供完整的 BASIC 认证作为基础, + * 参数级别使用 name 属性明确指定要覆盖的字段(username 或 password)

+ */ + @Override + @GetMapping(path = "/basic-dynamic-both") + @RequestAuth(type = AuthType.BASIC, username = "base-user", password = "base-password") + String testBasicDynamicBoth(@RequestAuth(type = AuthType.BASIC, name = "username") String username, + @RequestAuth(type = AuthType.BASIC, name = "password") String password); } \ No newline at end of file diff --git a/examples/fit-example/07-http-client-proxy/plugin-http-client/src/main/java/modelengine/fit/example/client/TestAuthInterface.java b/examples/fit-example/07-http-client-proxy/plugin-http-client/src/main/java/modelengine/fit/example/client/TestAuthInterface.java index acecf93ba..46c3583cc 100644 --- a/examples/fit-example/07-http-client-proxy/plugin-http-client/src/main/java/modelengine/fit/example/client/TestAuthInterface.java +++ b/examples/fit-example/07-http-client-proxy/plugin-http-client/src/main/java/modelengine/fit/example/client/TestAuthInterface.java @@ -86,4 +86,21 @@ public interface TestAuthInterface { * @return 鉴权测试结果 */ String testCombinedAuth(String userToken); + + /** + * 测试参数级别的 Basic Auth - 单参数更新 username(向后兼容)。 + * + * @param username 用户名 + * @return 鉴权测试结果 + */ + String testBasicDynamicUsername(String username); + + /** + * 测试参数级别的 Basic Auth - 双参数分别更新 username 和 password。 + * + * @param username 用户名 + * @param password 密码 + * @return 鉴权测试结果 + */ + String testBasicDynamicBoth(String username, String password); } \ No newline at end of file diff --git a/examples/fit-example/07-http-client-proxy/plugin-http-client/src/main/java/modelengine/fit/example/controller/TestClientController.java b/examples/fit-example/07-http-client-proxy/plugin-http-client/src/main/java/modelengine/fit/example/controller/TestClientController.java index f5cf101e6..f69a50618 100644 --- a/examples/fit-example/07-http-client-proxy/plugin-http-client/src/main/java/modelengine/fit/example/controller/TestClientController.java +++ b/examples/fit-example/07-http-client-proxy/plugin-http-client/src/main/java/modelengine/fit/example/controller/TestClientController.java @@ -104,7 +104,9 @@ public Object test(@RequestQuery("type") String type, @RequestQuery("method") St */ @GetMapping(path = "/auth-test") public Object authTest(@RequestQuery("method") String method, - @RequestQuery(value = "token", required = false) String token) { + @RequestQuery(value = "token", required = false) String token, + @RequestQuery(value = "username", required = false) String username, + @RequestQuery(value = "password", required = false) String password) { switch (method) { case "bearerStatic": return authClient.testBearerStatic(); @@ -112,6 +114,11 @@ public Object authTest(@RequestQuery("method") String method, return authClient.testBearerDynamic(token != null ? token : "dynamic-test-token"); case "basicStatic": return authClient.testBasicStatic(); + case "basicDynamicUsername": + return authClient.testBasicDynamicUsername(username != null ? username : "testuser"); + case "basicDynamicBoth": + return authClient.testBasicDynamicBoth(username != null ? username : "testuser", + password != null ? password : "testpass"); case "apiKeyHeaderStatic": return authClient.testApiKeyHeaderStatic(); case "apiKeyQueryStatic": diff --git a/examples/fit-example/07-http-client-proxy/plugin-http-server/src/main/java/modelengine/fit/example/controller/TestAuthServerController.java b/examples/fit-example/07-http-client-proxy/plugin-http-server/src/main/java/modelengine/fit/example/controller/TestAuthServerController.java index 55f4bda4b..57f4f8848 100644 --- a/examples/fit-example/07-http-client-proxy/plugin-http-server/src/main/java/modelengine/fit/example/controller/TestAuthServerController.java +++ b/examples/fit-example/07-http-client-proxy/plugin-http-server/src/main/java/modelengine/fit/example/controller/TestAuthServerController.java @@ -106,4 +106,14 @@ public String testCombinedAuth(@RequestHeader(name = "Authorization") String aut } return result; } + + @GetMapping(path = "/basic-dynamic-username") + public String testBasicDynamicUsername(@RequestHeader(name = "Authorization") String authorization) { + return "Basic Dynamic Username: " + authorization; + } + + @GetMapping(path = "/basic-dynamic-both") + public String testBasicDynamicBoth(@RequestHeader(name = "Authorization") String authorization) { + return "Basic Dynamic Both: " + authorization; + } } \ No newline at end of file diff --git a/examples/fit-example/07-http-client-proxy/run_tests.sh b/examples/fit-example/07-http-client-proxy/run_tests.sh index 3c897edaa..b520feb86 100755 --- a/examples/fit-example/07-http-client-proxy/run_tests.sh +++ b/examples/fit-example/07-http-client-proxy/run_tests.sh @@ -174,6 +174,18 @@ run_basic_tests() { run_test "Basic Static Auth" \ "curl -s --max-time $TIMEOUT -X GET \"$BASE_URL/basic-static\" -H \"Authorization: Basic YWRtaW46c2VjcmV0MTIz\" -H \"X-Service-Key: service-default-key\"" \ "Basic Static Auth: Basic YWRtaW46c2VjcmV0MTIz" + + # testuser:static-password 的 base64 编码 (dGVzdHVzZXI6c3RhdGljLXBhc3N3b3Jk) + # 参数覆盖 username: testuser,保留方法级别的 password: static-password + run_test "Basic Dynamic Username" \ + "curl -s --max-time $TIMEOUT -X GET \"$BASE_URL/basic-dynamic-username\" -H \"Authorization: Basic dGVzdHVzZXI6c3RhdGljLXBhc3N3b3Jk\"" \ + "Basic Dynamic Username: Basic dGVzdHVzZXI6c3RhdGljLXBhc3N3b3Jk" + + # testuser:testpass 的 base64 编码 (dGVzdHVzZXI6dGVzdHBhc3M=) + # 参数分别覆盖 username 和 password + run_test "Basic Dynamic Both" \ + "curl -s --max-time $TIMEOUT -X GET \"$BASE_URL/basic-dynamic-both\" -H \"Authorization: Basic dGVzdHVzZXI6dGVzdHBhc3M=\"" \ + "Basic Dynamic Both: Basic dGVzdHVzZXI6dGVzdHBhc3M=" } # API Key 测试 diff --git a/framework/fit/java/fit-builtin/plugins/fit-client-http/src/main/java/modelengine/fit/client/http/support/HttpProxyCreator.java b/framework/fit/java/fit-builtin/plugins/fit-client-http/src/main/java/modelengine/fit/client/http/support/HttpProxyCreator.java index 8dda7d05c..7b41afca8 100644 --- a/framework/fit/java/fit-builtin/plugins/fit-client-http/src/main/java/modelengine/fit/client/http/support/HttpProxyCreator.java +++ b/framework/fit/java/fit-builtin/plugins/fit-client-http/src/main/java/modelengine/fit/client/http/support/HttpProxyCreator.java @@ -67,7 +67,7 @@ public void onBeanContainerInitialized(BeanContainer container) { } List> classes = this.scan(container, packages); for (Class clazz : classes) { - AnnotationParser annotationParser = new AnnotationParser(this.valueFetcher); + AnnotationParser annotationParser = new AnnotationParser(this.valueFetcher, container); Map httpInfoMap = annotationParser.parseInterface(clazz); // Scan all interfaces, create proxy objects for each, and register them in the container. container.registry() diff --git a/framework/fit/java/fit-builtin/services/fit-http-classic/definition/HTTP_CLIENT_AUTH_PRINCIPLES.md b/framework/fit/java/fit-builtin/services/fit-http-classic/definition/HTTP_CLIENT_AUTH_PRINCIPLES.md new file mode 100644 index 000000000..d1a70404a --- /dev/null +++ b/framework/fit/java/fit-builtin/services/fit-http-classic/definition/HTTP_CLIENT_AUTH_PRINCIPLES.md @@ -0,0 +1,1140 @@ +# HTTP 客户端认证原理手册 + +本文档深入解析 HTTP 客户端认证功能的设计原理和实现细节。 + +## 目录 + +1. [架构概览](#架构概览) +2. [核心组件](#核心组件) +3. [工作流程](#工作流程) +4. [关键设计决策](#关键设计决策) +5. [与 FEL Tool 系统的一致性](#与-fel-tool-系统的一致性) +6. [扩展指南](#扩展指南) + +--- + +## 架构概览 + +### 整体架构图 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ @RequestAuth 注解 │ +│ (接口/方法/参数级别) │ +└────────────────────┬────────────────────────────────────────┘ + │ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ AnnotationParser │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ parseInterface() / parseMethod() / parseParam() │ │ +│ └──────────────┬───────────────────────┬─────────────────┘ │ +│ │ │ │ +│ 静态认证解析 参数级别认证解析 │ +│ │ │ │ +│ ↓ ↓ │ +│ ┌────────────────────┐ ┌──────────────────────┐ │ +│ │StaticAuthApplier │ │RequestAuthResolver │ │ +│ │+ BeanContainer │ │+ AuthFieldMapper │ │ +│ └────────┬───────────┘ └──────────┬───────────┘ │ +│ │ │ │ +└───────────────┼───────────────────────────┼─────────────────┘ + │ │ + ↓ ↓ +┌───────────────────────────────────────────────────────────┐ +│ HttpInvocationHandler │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ 1. 应用静态认证 (staticAppliers) │ │ +│ │ 2. 应用参数级别认证 (paramAppliers) │ │ +│ └─────────────────────────────────────────────────────┘ │ +└────────────────────┬──────────────────────────────────────┘ + │ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ RequestBuilder │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ authorization(Authorization) // 设置 Authorization │ │ +│ │ authorizationInfo(key, value) // 更新字段 │ │ +│ └─────────────────────────────────────────────────────┘ │ +└────────────────────┬────────────────────────────────────────┘ + │ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ Authorization │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ - BearerAuthorization │ │ +│ │ - BasicAuthorization │ │ +│ │ - ApiKeyAuthorization │ │ +│ │ - CustomAuthorization │ │ +│ └─────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## 核心组件 + +### 1. `@RequestAuth` 注解 + +**位置**: `modelengine.fit.http.annotation.RequestAuth` + +**核心属性**: + +```java +public @interface RequestAuth { + AuthType type(); // 认证类型 + String value(); // 通用值(Bearer token / API key value) + String name(); // 名称(语义重载) + Source location(); // API Key 位置(HEADER/QUERY/COOKIE) + String username(); // BASIC 认证用户名 + String password(); // BASIC 认证密码 + Class provider(); // 动态 Provider +} +``` + +**`name` 属性的语义重载**: + +| 认证类型 | `name` 含义 | 示例 | +|---------|-----------|------| +| BASIC | Authorization 字段名 | `"username"` 或 `"password"` | +| API_KEY | HTTP Header/Query 名称 | `"X-API-Key"` | +| BEARER | 无效(被忽略) | - | + +**设计原因**: +- 复用现有属性,避免新增注解字段 +- 语义在不同场景下自然不同,符合直觉 + +--- + +### 2. `AnnotationParser` + +**位置**: `modelengine.fit.http.client.proxy.scanner.AnnotationParser` + +**职责**: 解析接口、方法和参数上的注解,生成 `PropertyValueApplier` + +**关键字段**: + +```java +private final ValueFetcher valueFetcher; +private final BeanContainer beanContainer; // 用于获取 AuthProvider +``` + +**核心方法**: + +#### `parseInterface(Class clazz)` + +解析接口级别的注解,生成 `HttpInfo` 列表(每个方法一个)。 + +**流程**: +1. 解析类级别的 `@RequestAuth` → `getClassLevelAuthAppliers()` +2. 遍历方法: + - 解析方法级别的 `@RequestAuth` → `getMethodLevelAuthAppliers()` + - 合并类级别和方法级别的 appliers + - 解析参数级别的注解 → `parseParam()` + +#### `getClassLevelAuthAppliers(Class clazz)` + +```java +private List getClassLevelAuthAppliers(Class clazz) { + List appliers = new ArrayList<>(); + RequestAuth[] authAnnotations = clazz.getAnnotationsByType(RequestAuth.class); + for (RequestAuth auth : authAnnotations) { + // 关键:传递 beanContainer 给 StaticAuthApplier + appliers.add(new StaticAuthApplier(auth, this.beanContainer)); + } + return appliers; +} +``` + +**关键点**: +- `getAnnotationsByType()` 支持 `@Repeatable`,可以有多个 `@RequestAuth` +- 直接在构造时传递 `beanContainer`,避免后续运行时注入 + +#### `getMethodLevelAuthAppliers(Method method)` + +与类级别类似,解析方法上的 `@RequestAuth`。 + +#### `parseParam(Parameter parameter)` + +解析参数上的注解,生成 `PropertyValueApplier`。 + +**针对 `@RequestAuth` 的处理**: + +```java +if (parameter.isAnnotationPresent(RequestAuth.class)) { + RequestAuth auth = parameter.getAnnotation(RequestAuth.class); + // 使用 RequestAuthResolver 处理 + DestinationSetterInfo setterInfo = + new RequestAuthResolver().resolve(auth, jsonPath); + // 创建 MultiDestinationsPropertyValueApplier + return new MultiDestinationsPropertyValueApplier(...); +} +``` + +--- + +### 3. `StaticAuthApplier` + +**位置**: `modelengine.fit.http.client.proxy.support.applier.StaticAuthApplier` + +**职责**: 处理静态认证(类级别和方法级别的 `@RequestAuth`) + +**核心实现**: + +```java +public class StaticAuthApplier implements PropertyValueApplier { + private final Authorization authorization; + + public StaticAuthApplier(RequestAuth authAnnotation, BeanContainer beanContainer) { + notNull(beanContainer, "The bean container cannot be null."); + // 构造时立即创建 Authorization + this.authorization = this.createAuthorizationFromAnnotation( + authAnnotation, beanContainer); + } + + @Override + public void apply(RequestBuilder requestBuilder, Object value) { + // 静态认证不需要参数值 + requestBuilder.authorization(this.authorization); + } +} +``` + +**关键设计**: +1. **构造函数注入**: 接受 `BeanContainer` 参数,由 `AnnotationParser` 传递 +2. **立即创建**: Authorization 在构造时创建,不是延迟初始化 +3. **不可变性**: `authorization` 字段为 `final`,线程安全 +4. **Fail-fast**: 使用 `notNull()` 在入口处验证参数 + +#### `createAuthorizationFromAnnotation()` 方法 + +```java +private Authorization createAuthorizationFromAnnotation( + RequestAuth annotation, BeanContainer beanContainer) { + + // 如果使用 Provider + if (annotation.provider() != AuthProvider.class) { + AuthProvider provider = beanContainer.beans().get(annotation.provider()); + if (provider == null) { + throw new IllegalStateException( + "AuthProvider not found: " + annotation.provider().getName()); + } + return provider.provide(); + } + + // 根据类型创建静态 Authorization + AuthType type = annotation.type(); + switch (type) { + case BEARER: + return Authorization.createBearer(annotation.value()); + case BASIC: + return Authorization.createBasic( + annotation.username(), annotation.password()); + case API_KEY: + return Authorization.createApiKey( + annotation.name(), annotation.value(), annotation.location()); + case CUSTOM: + throw new IllegalArgumentException("CUSTOM requires provider"); + } +} +``` + +**Provider 处理**: +- 通过 `beanContainer.beans().get()` 获取 Provider 实例 +- Provider 必须是 Spring Bean(通过 `@Component` 等注册) +- Provider 的 `provide()` 方法在构造时调用一次 + +--- + +### 4. `RequestAuthResolver` + +**位置**: `modelengine.fit.http.client.proxy.scanner.resolver.RequestAuthResolver` + +**职责**: 解析参数级别的 `@RequestAuth`,生成 `DestinationSetterInfo` + +**核心实现**: + +```java +public class RequestAuthResolver implements ParamResolver { + @Override + public DestinationSetterInfo resolve(RequestAuth annotation, String jsonPath) { + // 使用 AuthFieldMapper 获取字段名 + String authField = AuthFieldMapper.getParameterAuthField( + annotation.type(), annotation.name()); + + // 创建 AuthorizationDestinationSetter + return new DestinationSetterInfo( + new AuthorizationDestinationSetter(authField), jsonPath); + } +} +``` + +**关键点**: +- 复用 `AuthorizationDestinationSetter`,与 FEL Tool 系统一致 +- 通过 `AuthFieldMapper` 确定要更新的字段 + +--- + +### 5. `AuthFieldMapper` + +**位置**: `modelengine.fit.http.client.proxy.scanner.resolver.AuthFieldMapper` + +**职责**: 确定参数级别认证应该更新 `Authorization` 对象的哪个字段 + +**核心方法**: + +```java +public static String getParameterAuthField(AuthType type, String nameAttribute) { + return switch (type) { + case BEARER -> "token"; // BearerAuthorization.token + + case BASIC -> { + // 使用 name 属性指定字段 + if (StringUtils.isNotBlank(nameAttribute)) { + if (StringUtils.equals("username", nameAttribute)) { + yield "username"; + } else if (StringUtils.equals("password", nameAttribute)) { + yield "password"; + } else { + throw new IllegalArgumentException( + "For BASIC auth, name must be 'username' or 'password'"); + } + } + // 默认返回 username(向后兼容) + yield "username"; + } + + case API_KEY -> "value"; // ApiKeyAuthorization.value + + case CUSTOM -> throw new IllegalArgumentException( + "CUSTOM auth requires AuthProvider"); + }; +} +``` + +**字段映射表**: + +| AuthType | Authorization 类 | 可更新字段 | 字段含义 | +|----------|-----------------|-----------|---------| +| BEARER | BearerAuthorization | `token` | Bearer Token 值 | +| BASIC | BasicAuthorization | `username`
`password` | 用户名
密码 | +| API_KEY | ApiKeyAuthorization | `value` | API Key 值 | + +**重要说明**: + +**API_KEY 的两个概念**: +1. **key 字段**: HTTP Header/Query 的名称(如 "X-API-Key") + - 来自注解的 `name` 属性 + - 在静态认证时设置 +2. **value 字段**: 实际的 API Key 值 + - 来自参数传入 + - 参数级别更新此字段 + +**示例**: +```java +@RequestAuth(type = API_KEY, name = "X-API-Key", value = "static-key") +void api1(); + +void api2(@RequestAuth(type = API_KEY, name = "X-API-Key") String key); +``` + +- `api1`: `ApiKeyAuthorization.key = "X-API-Key"`, `value = "static-key"` +- `api2("dynamic-key")`: `ApiKeyAuthorization.key = "X-API-Key"`, `value = "dynamic-key"` + +--- + +### 6. `AuthorizationDestinationSetter` + +**位置**: `modelengine.fit.http.client.proxy.support.setter.AuthorizationDestinationSetter` + +**职责**: 更新 `Authorization` 对象的指定字段 + +**核心实现**: + +```java +public class AuthorizationDestinationSetter extends AbstractDestinationSetter { + public AuthorizationDestinationSetter(String key) { + super(key); // key 是字段名,如 "token", "username", "password", "value" + } + + @Override + public void set(RequestBuilder requestBuilder, Object value) { + // 调用 requestBuilder.authorizationInfo() 更新字段 + requestBuilder.authorizationInfo(this.key(), value); + } +} +``` + +**RequestBuilder 的处理**: + +```java +public interface RequestBuilder { + // 设置完整的 Authorization 对象(静态认证) + RequestBuilder authorization(Authorization authorization); + + // 更新 Authorization 对象的字段(参数级别认证) + RequestBuilder authorizationInfo(String key, Object value); +} +``` + +**DefaultRequestBuilder 实现**: + +```java +@Override +public RequestBuilder authorizationInfo(String key, Object value) { + if (this.authorization == null) { + throw new IllegalStateException("Authorization not set"); + } + // 调用 Authorization.setValue() 更新字段 + this.authorization.setValue(key, value); + return this; +} +``` + +--- + +### 7. `Authorization` 对象 + +**位置**: `modelengine.fit.http.client.proxy.Authorization` + +**职责**: 封装认证信息,提供统一的更新接口 + +**核心方法**: + +```java +public abstract class Authorization { + // 更新字段(供参数级别认证使用) + public abstract void setValue(String key, Object value); + + // 应用到 HTTP 请求(生成 HTTP Header) + public abstract void apply(HttpClassicClientRequest request); +} +``` + +#### `BearerAuthorization` + +```java +public class BearerAuthorization extends Authorization { + private String token; + + @Override + public void setValue(String key, Object value) { + if ("token".equals(key)) { + this.token = (String) value; + } + } + + @Override + public void apply(HttpClassicClientRequest request) { + request.setHeader("Authorization", "Bearer " + this.token); + } +} +``` + +#### `BasicAuthorization` + +```java +public class BasicAuthorization extends Authorization { + private String username; + private String password; + + @Override + public void setValue(String key, Object value) { + if ("username".equals(key)) { + this.username = (String) value; + } else if ("password".equals(key)) { + this.password = (String) value; + } + } + + @Override + public void apply(HttpClassicClientRequest request) { + String credentials = username + ":" + password; + String encoded = Base64.getEncoder().encodeToString( + credentials.getBytes(StandardCharsets.UTF_8)); + request.setHeader("Authorization", "Basic " + encoded); + } +} +``` + +#### `ApiKeyAuthorization` + +```java +public class ApiKeyAuthorization extends Authorization { + private String key; // HTTP Header/Query 名称 + private String value; // API Key 值 + private Source location; + + @Override + public void setValue(String fieldName, Object fieldValue) { + if ("key".equals(fieldName)) { + this.key = (String) fieldValue; + } else if ("value".equals(fieldName)) { + this.value = (String) fieldValue; + } + } + + @Override + public void apply(HttpClassicClientRequest request) { + switch (location) { + case HEADER: + request.setHeader(key, value); + break; + case QUERY: + request.addQueryParameter(key, value); + break; + case COOKIE: + request.setCookie(key, value); + break; + } + } +} +``` + +--- + +### 8. `HttpInvocationHandler` + +**位置**: `modelengine.fit.http.client.proxy.scanner.HttpInvocationHandler` + +**职责**: 拦截接口方法调用,构建 HTTP 请求并执行 + +**认证处理流程**: + +```java +@Override +public Object invoke(Object proxy, Method method, Object[] args) { + // 1. 获取 HttpInfo + HttpInfo httpInfo = this.httpInfoMap.get(method); + List staticAppliers = httpInfo.getStaticAppliers(); + List paramAppliers = httpInfo.getParamAppliers(); + + // 2. 创建 RequestBuilder + RequestBuilder requestBuilder = new DefaultRequestBuilder() + .client(this.client) + .method(httpInfo.getMethod()); + + // 3. 应用静态认证(包括类级别和方法级别) + for (PropertyValueApplier staticApplier : staticAppliers) { + staticApplier.apply(requestBuilder, null); + } + + // 4. 应用参数级别认证 + for (int i = 0; i < paramAppliers.size(); i++) { + paramAppliers.get(i).apply(requestBuilder, args[i]); + } + + // 5. 构建并执行请求 + HttpClassicClientRequest request = requestBuilder.build(); + return request.execute(); +} +``` + +**关键点**: +1. **静态优先**: 先应用静态认证(创建 Authorization 对象) +2. **参数更新**: 再应用参数级别认证(更新 Authorization 字段) +3. **简洁设计**: 不再需要 `instanceof` 检查或运行时注入 + +--- + +## 工作流程 + +### 流程 1: 静态认证 + +**场景**: 方法级别使用静态 Bearer Token + +```java +@GetMapping("/api/users") +@RequestAuth(type = AuthType.BEARER, value = "my-token") +List getUsers(); +``` + +**执行流程**: + +``` +1. AnnotationParser.parseMethod() + └─> 发现 @RequestAuth 注解 + └─> 调用 getMethodLevelAuthAppliers() + └─> 创建 StaticAuthApplier(authAnnotation, beanContainer) + └─> createAuthorizationFromAnnotation() + └─> Authorization.createBearer("my-token") + └─> 返回 BearerAuthorization(token="my-token") + +2. 存储到 HttpInfo.staticAppliers + +3. HttpInvocationHandler.invoke() + └─> 应用 staticAppliers + └─> StaticAuthApplier.apply(requestBuilder, null) + └─> requestBuilder.authorization(bearerAuth) + └─> DefaultRequestBuilder 存储 authorization 对象 + +4. requestBuilder.build() + └─> authorization.apply(request) + └─> BearerAuthorization.apply() + └─> request.setHeader("Authorization", "Bearer my-token") + +5. 发送 HTTP 请求 + GET /api/users HTTP/1.1 + Authorization: Bearer my-token +``` + +--- + +### 流程 2: 参数级别认证 + +**场景**: 参数驱动的 Bearer Token + +```java +@GetMapping("/api/users") +List getUsers(@RequestAuth(type = AuthType.BEARER) String token); +``` + +**执行流程**: + +``` +1. AnnotationParser.parseParam(parameter) + └─> 发现 @RequestAuth 注解 + └─> 调用 RequestAuthResolver.resolve() + └─> AuthFieldMapper.getParameterAuthField(BEARER, null) + └─> 返回 "token" + └─> 创建 AuthorizationDestinationSetter("token") + └─> 返回 DestinationSetterInfo + +2. 创建 MultiDestinationsPropertyValueApplier + └─> 存储到 HttpInfo.paramAppliers + +3. HttpInvocationHandler.invoke(proxy, method, ["user-token-123"]) + └─> 应用 paramAppliers + └─> MultiDestinationsPropertyValueApplier.apply(requestBuilder, "user-token-123") + └─> AuthorizationDestinationSetter.set(requestBuilder, "user-token-123") + └─> requestBuilder.authorizationInfo("token", "user-token-123") + └─> authorization.setValue("token", "user-token-123") + └─> BearerAuthorization.token = "user-token-123" + +4. requestBuilder.build() + └─> authorization.apply(request) + └─> request.setHeader("Authorization", "Bearer user-token-123") + +5. 发送 HTTP 请求 + GET /api/users HTTP/1.1 + Authorization: Bearer user-token-123 +``` + +--- + +### 流程 3: BASIC 认证双参数更新 + +**场景**: 参数分别更新 username 和 password + +```java +@RequestAuth(type = BASIC, username = "base-user", password = "base-pass") +void login( + @RequestAuth(type = BASIC, name = "username") String user, + @RequestAuth(type = BASIC, name = "password") String pass +); +``` + +**执行流程**: + +``` +1. AnnotationParser 阶段 + + 方法级别: + └─> getMethodLevelAuthAppliers() + └─> StaticAuthApplier(auth, beanContainer) + └─> Authorization.createBasic("base-user", "base-pass") + └─> BasicAuthorization(username="base-user", password="base-pass") + + 参数 0: + └─> parseParam(parameter[0]) // @RequestAuth(type=BASIC, name="username") + └─> RequestAuthResolver.resolve() + └─> AuthFieldMapper.getParameterAuthField(BASIC, "username") + └─> 返回 "username" + └─> AuthorizationDestinationSetter("username") + + 参数 1: + └─> parseParam(parameter[1]) // @RequestAuth(type=BASIC, name="password") + └─> RequestAuthResolver.resolve() + └─> AuthFieldMapper.getParameterAuthField(BASIC, "password") + └─> 返回 "password" + └─> AuthorizationDestinationSetter("password") + +2. HttpInvocationHandler.invoke(proxy, method, ["john", "secret"]) + + 应用静态认证: + └─> StaticAuthApplier.apply(requestBuilder, null) + └─> requestBuilder.authorization(basicAuth) + └─> BasicAuthorization(username="base-user", password="base-pass") + + 应用参数 0: + └─> paramAppliers[0].apply(requestBuilder, "john") + └─> requestBuilder.authorizationInfo("username", "john") + └─> basicAuth.setValue("username", "john") + └─> BasicAuthorization.username = "john" + + 应用参数 1: + └─> paramAppliers[1].apply(requestBuilder, "secret") + └─> requestBuilder.authorizationInfo("password", "secret") + └─> basicAuth.setValue("password", "secret") + └─> BasicAuthorization.password = "secret" + +3. 构建请求 + └─> authorization.apply(request) + └─> credentials = "john:secret" + └─> encoded = Base64.encode("john:secret") = "am9objpzZWNyZXQ=" + └─> request.setHeader("Authorization", "Basic am9objpzZWNyZXQ=") + +4. 发送 HTTP 请求 + POST /login HTTP/1.1 + Authorization: Basic am9objpzZWNyZXQ= +``` + +--- + +### 流程 4: Provider 动态认证 + +**场景**: 使用 Provider 动态获取 Token + +```java +@GetMapping("/api/users") +@RequestAuth(type = BEARER, provider = DynamicTokenProvider.class) +List getUsers(); +``` + +**执行流程**: + +``` +1. 应用启动时 + └─> Spring 容器扫描 + └─> 发现 @Component DynamicTokenProvider + └─> 创建实例并注册到 BeanContainer + +2. AnnotationParser.parseMethod() + └─> getMethodLevelAuthAppliers() + └─> StaticAuthApplier(authAnnotation, beanContainer) + └─> createAuthorizationFromAnnotation() + └─> 检测到 provider != AuthProvider.class + └─> beanContainer.beans().get(DynamicTokenProvider.class) + └─> 获取 Provider 实例 + └─> provider.provide() + └─> DynamicTokenProvider.provide() + └─> token = loadTokenFromCache() + └─> return Authorization.createBearer(token) + +3. 后续每次请求 + └─> StaticAuthApplier.apply() 使用已创建的 Authorization + └─> 注意:Token 在启动时获取一次,不会每次请求都刷新 + +4. 如果需要每次请求刷新 Token + └─> 需要在 Provider 中实现 ThreadLocal 或请求作用域机制 + └─> 或者使用参数级别认证动态传入 +``` + +**重要说明**: +- Provider 的 `provide()` 方法在 `StaticAuthApplier` 构造时调用**一次** +- 如果需要每次请求动态获取,Provider 内部需要实现缓存刷新机制 +- 或者考虑使用参数级别认证 + +--- + +## 关键设计决策 + +### 决策 1: 构造函数注入 vs Setter 注入 + +**原方案(已废弃)**: Setter 注入 + +```java +// 原来的设计 +public class StaticAuthApplier { + private Authorization cachedAuthorization; + + public StaticAuthApplier(RequestAuth authAnnotation) { + // 如果不使用 Provider,提前创建 + if (authAnnotation.provider() == AuthProvider.class) { + this.cachedAuthorization = createAuth(authAnnotation, null); + } + } + + public void setBeanContainer(BeanContainer beanContainer) { + // 延迟创建(如果使用了 Provider) + if (this.cachedAuthorization == null) { + this.cachedAuthorization = createAuth(authAnnotation, beanContainer); + } + } +} +``` + +**问题**: +1. 需要保存 `authAnnotation` 字段用于延迟创建 +2. `cachedAuthorization` 可能为 null,需要运行时检查 +3. `HttpInvocationHandler` 需要 `instanceof` 检查并调用 `setBeanContainer()` +4. 延迟初始化增加复杂度 + +**新方案**: 构造函数注入 + +```java +public class StaticAuthApplier { + private final Authorization authorization; + + public StaticAuthApplier(RequestAuth authAnnotation, BeanContainer beanContainer) { + notNull(beanContainer, "The bean container cannot be null."); + this.authorization = createAuth(authAnnotation, beanContainer); + } +} +``` + +**优势**: +1. ✅ 立即创建,不需要保存 `authAnnotation` +2. ✅ `authorization` 为 `final`,不可变,线程安全 +3. ✅ 不需要 null 检查 +4. ✅ `HttpInvocationHandler` 代码简化 +5. ✅ Fail-fast:错误在构造时暴露 + +**权衡**: +- `AnnotationParser` 已经有 `beanContainer`,直接传递更自然 + +--- + +### 决策 2: name 属性语义重载 + +**问题**: BASIC 认证需要指定更新 username 还是 password,但不想新增注解字段 + +**方案比较**: + +| 方案 | 示例 | 优点 | 缺点 | +|------|------|------|------| +| 1. 新增 `authField` 属性 | `@RequestAuth(type=BASIC, authField="username")` | 语义明确 | 增加注解复杂度 | +| 2. 从注解属性推断 | `@RequestAuth(type=BASIC, username="xxx")` 有值则更新 username | 无需新字段 | 逻辑复杂,不够灵活 | +| 3. 复用 `name` 属性 | `@RequestAuth(type=BASIC, name="username")` | 复用现有字段 | 语义重载 | + +**选择方案 3**: 复用 `name` 属性 + +**理由**: +1. `name` 在不同场景下含义本来就不同(API_KEY 的 Header 名 vs BASIC 的字段名) +2. 符合直觉:`name="username"` → 更新 username +3. 不增加注解复杂度 +4. 通过文档清晰说明语义 + +--- + +### 决策 3: 参数级别认证是覆盖还是更新? + +**问题**: 参数级别的认证应该完全替换静态认证,还是只更新部分字段? + +**选择**: **更新字段** + +**理由**: +1. **复用 Authorization 对象**: 静态认证已创建对象,参数只需更新字段 +2. **与 FEL Tool 一致**: Tool 系统也是通过 `authorizationInfo(key, value)` 更新字段 +3. **支持部分更新**: 如 BASIC 认证可以只更新 username,保留 password +4. **性能更好**: 不需要重新创建对象 + +**示例**: +```java +@RequestAuth(type = BASIC, username = "admin", password = "pass123") +void login(@RequestAuth(type = BASIC) String username); + +// 调用 login("john") +// 结果: BasicAuthorization(username="john", password="pass123") +``` + +**限制**: +- 方法级别必须提供完整的认证信息(如 BASIC 必须有 username + password) +- 参数只能更新已存在的字段 + +--- + +### 决策 4: AuthFieldMapper 为什么是静态工具类? + +**问题**: 是否需要将 `AuthFieldMapper` 设计为可扩展的接口? + +**选择**: **静态工具类** + +**理由**: +1. **固定映射**: 字段映射是由 `Authorization` 实现类决定的,不应该可变 +2. **简单性**: 不需要实例化,直接调用静态方法 +3. **性能**: 避免创建额外对象 +4. **一致性**: 与 FEL Tool 的设计保持一致 + +**扩展性**: +- 如果需要自定义 Authorization 实现,应该通过 `CUSTOM` 类型和 `AuthProvider` 实现 +- 不应该通过修改 `AuthFieldMapper` 的映射逻辑 + +--- + +## 与 FEL Tool 系统的一致性 + +### 统一的底层机制 + +**FEL Tool JSON 配置**: + +```json +{ + "mappings": { + "people": { + "name": { + "key": "token", + "httpSource": "AUTHORIZATION" + } + } + } +} +``` + +**注解方式**: + +```java +String api(@RequestAuth(type = BEARER) String token); +``` + +**底层执行**: + +两种方式最终都调用: +```java +requestBuilder.authorizationInfo("token", value); +↓ +authorization.setValue("token", value); +``` + +### 复用 AuthorizationDestinationSetter + +**原来的设计(已废弃)**: 使用单独的 `AuthDestinationSetter` + +**问题**: +- 与 FEL Tool 系统的 `AuthorizationDestinationSetter` 功能重复 +- 维护两套相同逻辑的代码 + +**重构后**: 复用 `AuthorizationDestinationSetter` + +**优势**: +- ✅ 代码复用,减少维护成本 +- ✅ 架构一致性,降低理解成本 +- ✅ 确保行为一致 + +--- + +## 扩展指南 + +### 扩展 1: 添加新的认证类型 + +**步骤**: + +1. **定义新的 AuthType** + +```java +public enum AuthType { + BEARER, + BASIC, + API_KEY, + CUSTOM, + OAUTH2 // 新增 +} +``` + +2. **创建 Authorization 实现** + +```java +public class OAuth2Authorization extends Authorization { + private String accessToken; + private String refreshToken; + + @Override + public void setValue(String key, Object value) { + switch (key) { + case "accessToken": + this.accessToken = (String) value; + break; + case "refreshToken": + this.refreshToken = (String) value; + break; + } + } + + @Override + public void apply(HttpClassicClientRequest request) { + request.setHeader("Authorization", "Bearer " + accessToken); + // 其他 OAuth2 特定逻辑 + } +} +``` + +3. **更新 StaticAuthApplier** + +```java +private Authorization createAuthorizationFromAnnotation(...) { + switch (type) { + // 现有类型... + case OAUTH2: + return Authorization.createOAuth2( + annotation.value(), // accessToken + annotation.password() // refreshToken (复用字段) + ); + } +} +``` + +4. **更新 AuthFieldMapper** + +```java +public static String getParameterAuthField(AuthType type, String nameAttribute) { + return switch (type) { + // 现有类型... + case OAUTH2 -> StringUtils.isNotBlank(nameAttribute) + ? nameAttribute // 支持指定字段 + : "accessToken"; // 默认 + }; +} +``` + +5. **添加 Authorization 工厂方法** + +```java +public abstract class Authorization { + public static Authorization createOAuth2(String accessToken, String refreshToken) { + return new OAuth2Authorization(accessToken, refreshToken); + } +} +``` + +--- + +### 扩展 2: 自定义 Provider + +**场景**: 实现带刷新机制的 Token Provider + +```java +@Component +public class RefreshableTokenProvider implements AuthProvider { + private String cachedToken; + private long expirationTime; + + @Autowired + private TokenService tokenService; + + @Override + public Authorization provide() { + // 检查 Token 是否过期 + if (cachedToken == null || System.currentTimeMillis() > expirationTime) { + refreshToken(); + } + return Authorization.createBearer(cachedToken); + } + + private void refreshToken() { + TokenResponse response = tokenService.getToken(); + this.cachedToken = response.getToken(); + this.expirationTime = System.currentTimeMillis() + response.getExpiresIn() * 1000; + } +} +``` + +**注意**: Provider 的 `provide()` 方法在 `StaticAuthApplier` 构造时调用一次,因此: +- 缓存和刷新逻辑需要在 Provider 内部实现 +- 或者使用参数级别认证每次动态传入 + +--- + +### 扩展 3: 请求级别的动态 Token + +**场景**: 每次 HTTP 请求时动态获取最新的 Token + +**方案**: 使用参数级别认证 + +```java +@Service +public class UserService { + @Autowired + private UserClient userClient; + + @Autowired + private SecurityContext securityContext; + + public UserProfile getProfile() { + // 每次请求时获取当前用户的 Token + String token = securityContext.getCurrentUserToken(); + return userClient.getProfile(token); + } +} +``` + +**客户端接口**: + +```java +@HttpProxy +public interface UserClient { + @GetMapping("/api/profile") + UserProfile getProfile(@RequestAuth(type = BEARER) String token); +} +``` + +--- + +### 扩展 4: 多认证策略 + +**场景**: 根据环境(开发/生产)使用不同的认证方式 + +```java +@Component +public class EnvironmentAwareAuthProvider implements AuthProvider { + @Value("${app.env}") + private String environment; + + @Value("${api.dev.token}") + private String devToken; + + @Value("${api.prod.username}") + private String prodUsername; + + @Value("${api.prod.password}") + private String prodPassword; + + @Override + public Authorization provide() { + if ("development".equals(environment)) { + return Authorization.createBearer(devToken); + } else { + return Authorization.createBasic(prodUsername, prodPassword); + } + } +} +``` + +--- + +## 总结 + +### 核心特性 + +1. **三级配置**: 接口级别、方法级别、参数级别 +2. **多种认证类型**: Bearer、Basic、API Key、Custom +3. **动态 Provider**: 支持运行时动态获取认证信息 +4. **字段更新机制**: 参数级别更新 Authorization 字段 +5. **架构一致性**: 与 FEL Tool 系统复用底层机制 + +### 设计原则 + +1. **构造函数注入**: 依赖在构造时注入,立即创建,Fail-fast +2. **不可变性**: Authorization 对象在创建后为 final,线程安全 +3. **复用机制**: 复用 AuthorizationDestinationSetter,避免重复代码 +4. **语义重载**: name 属性在不同类型下有不同含义,减少注解复杂度 +5. **静态优先**: 先应用静态认证,再应用参数级别认证 + +### 关键组件职责 + +| 组件 | 职责 | +|------|------| +| `@RequestAuth` | 声明认证信息 | +| `AnnotationParser` | 解析注解,创建 Applier | +| `StaticAuthApplier` | 应用静态认证(创建 Authorization) | +| `RequestAuthResolver` | 解析参数级别认证 | +| `AuthFieldMapper` | 确定要更新的字段 | +| `AuthorizationDestinationSetter` | 更新 Authorization 字段 | +| `Authorization` | 封装认证信息,应用到 HTTP 请求 | +| `HttpInvocationHandler` | 协调所有 Applier,构建请求 | + +--- + +## 相关文档 + +- [HTTP 客户端认证使用手册](./HTTP_CLIENT_AUTH_USAGE.md) +- [示例代码 - Example 07](../../../../../../../examples/fit-example/07-http-client-proxy) diff --git a/framework/fit/java/fit-builtin/services/fit-http-classic/definition/HTTP_CLIENT_AUTH_USAGE.md b/framework/fit/java/fit-builtin/services/fit-http-classic/definition/HTTP_CLIENT_AUTH_USAGE.md new file mode 100644 index 000000000..3db24ae1f --- /dev/null +++ b/framework/fit/java/fit-builtin/services/fit-http-classic/definition/HTTP_CLIENT_AUTH_USAGE.md @@ -0,0 +1,640 @@ +# HTTP 客户端认证使用手册 + +本文档详细介绍如何使用 `@RequestAuth` 注解为 HTTP 客户端添加认证功能。 + +## 目录 + +1. [快速开始](#快速开始) +2. [认证类型](#认证类型) +3. [使用场景](#使用场景) +4. [最佳实践](#最佳实践) +5. [常见问题](#常见问题) + +--- + +## 快速开始 + +### 基本用法 + +在 HTTP 客户端接口的类、方法或参数上使用 `@RequestAuth` 注解: + +```java +@HttpProxy +@RequestAuth(type = AuthType.BEARER, value = "my-token") +public interface MyClient { + @GetMapping("/api/users") + List getUsers(); +} +``` + +### 注解位置 + +`@RequestAuth` 可以应用在三个层级: + +1. **接口级别**:所有方法继承此认证 +2. **方法级别**:覆盖接口级别的认证 +3. **参数级别**:动态更新认证信息 + +--- + +## 认证类型 + +### 1. Bearer Token 认证 + +#### 静态配置 + +```java +@HttpProxy +public interface UserClient { + @GetMapping("/api/users") + @RequestAuth(type = AuthType.BEARER, value = "static-token-12345") + List getUsers(); +} +``` + +**生成的 HTTP 请求:** +``` +GET /api/users HTTP/1.1 +Authorization: Bearer static-token-12345 +``` + +#### 参数驱动 + +```java +@HttpProxy +public interface UserClient { + @GetMapping("/api/users") + List getUsers(@RequestAuth(type = AuthType.BEARER) String token); +} +``` + +**调用示例:** +```java +userClient.getUsers("dynamic-token-67890"); +``` + +**生成的 HTTP 请求:** +``` +GET /api/users HTTP/1.1 +Authorization: Bearer dynamic-token-67890 +``` + +--- + +### 2. Basic 认证 + +#### 静态配置 + +```java +@HttpProxy +public interface AdminClient { + @GetMapping("/admin/settings") + @RequestAuth(type = AuthType.BASIC, username = "admin", password = "secret123") + Settings getSettings(); +} +``` + +**生成的 HTTP 请求:** +``` +GET /admin/settings HTTP/1.1 +Authorization: Basic YWRtaW46c2VjcmV0MTIz +``` +(YWRtaW46c2VjcmV0MTIz 是 "admin:secret123" 的 Base64 编码) + +#### 参数级别 - 单字段更新 + +```java +@HttpProxy +public interface AdminClient { + @GetMapping("/admin/users") + @RequestAuth(type = AuthType.BASIC, username = "default-user", password = "default-pass") + List getUsers(@RequestAuth(type = AuthType.BASIC) String username); +} +``` + +**说明:** +- 方法级别提供完整的 username + password +- 参数级别覆盖 username 字段(默认行为) + +**调用示例:** +```java +adminClient.getUsers("john"); // 最终: john:default-pass +``` + +#### 参数级别 - 双字段更新 + +```java +@HttpProxy +public interface AdminClient { + @GetMapping("/admin/login") + @RequestAuth(type = AuthType.BASIC, username = "base-user", password = "base-pass") + LoginResult login( + @RequestAuth(type = AuthType.BASIC, name = "username") String user, + @RequestAuth(type = AuthType.BASIC, name = "password") String pass + ); +} +``` + +**说明:** +- 使用 `name` 属性明确指定要更新的字段 +- `name = "username"` 更新 username 字段 +- `name = "password"` 更新 password 字段 + +**调用示例:** +```java +adminClient.login("john", "secret"); // 最终: john:secret +``` + +**重要提示:** +- 方法级别的 BASIC 认证必须提供完整的 username 和 password +- 参数级别只能更新已存在的认证对象的字段 +- 如果不指定 `name` 属性,默认更新 `username` 字段 + +--- + +### 3. API Key 认证 + +#### Header 中的 API Key + +```java +@HttpProxy +public interface SearchClient { + @GetMapping("/search") + @RequestAuth(type = AuthType.API_KEY, name = "X-API-Key", value = "my-api-key-123") + SearchResult search(@RequestQuery("q") String query); +} +``` + +**生成的 HTTP 请求:** +``` +GET /search?q=hello HTTP/1.1 +X-API-Key: my-api-key-123 +``` + +#### Query 参数中的 API Key + +```java +@HttpProxy +public interface SearchClient { + @GetMapping("/search") + @RequestAuth( + type = AuthType.API_KEY, + name = "api_key", + value = "my-key-456", + location = Source.QUERY + ) + SearchResult search(@RequestQuery("q") String query); +} +``` + +**生成的 HTTP 请求:** +``` +GET /search?q=hello&api_key=my-key-456 HTTP/1.1 +``` + +#### 参数驱动的 API Key + +```java +@HttpProxy +public interface SearchClient { + @GetMapping("/search") + SearchResult search( + @RequestQuery("q") String query, + @RequestAuth(type = AuthType.API_KEY, name = "X-API-Key") String apiKey + ); +} +``` + +**调用示例:** +```java +searchClient.search("hello", "user-specific-key"); +``` + +**生成的 HTTP 请求:** +``` +GET /search?q=hello HTTP/1.1 +X-API-Key: user-specific-key +``` + +--- + +### 4. 自定义认证(Provider) + +#### 定义 Provider + +```java +@Component +public class DynamicTokenProvider implements AuthProvider { + @Override + public Authorization provide() { + // 动态获取 token(如从缓存、数据库等) + String token = loadTokenFromCache(); + return Authorization.createBearer(token); + } +} +``` + +#### 使用 Provider + +```java +@HttpProxy +public interface UserClient { + @GetMapping("/api/users") + @RequestAuth(type = AuthType.BEARER, provider = DynamicTokenProvider.class) + List getUsers(); +} +``` + +**优势:** +- Token 在每次请求时动态获取 +- 支持 Token 刷新、轮换等复杂场景 +- Provider 可以依赖注入其他 Bean + +#### 自定义签名 Provider + +```java +@Component +public class CustomSignatureProvider implements AuthProvider { + @Override + public Authorization provide() { + long timestamp = System.currentTimeMillis(); + String signature = calculateSignature(timestamp); + + return Authorization.createCustom(headers -> { + headers.put("X-Timestamp", String.valueOf(timestamp)); + headers.put("X-Signature", signature); + headers.put("X-App-Id", "my-app"); + }); + } +} +``` + +--- + +## 使用场景 + +### 场景 1:接口级别全局认证 + +**需求:** 所有 API 都使用相同的 API Key + +```java +@HttpProxy +@RequestAddress(host = "api.example.com") +@RequestAuth(type = AuthType.API_KEY, name = "X-Service-Key", value = "service-key-123") +public interface ServiceClient { + @GetMapping("/api/users") + List getUsers(); + + @GetMapping("/api/orders") + List getOrders(); + + // 所有方法自动携带 X-Service-Key: service-key-123 +} +``` + +--- + +### 场景 2:方法级别覆盖 + +**需求:** 大部分 API 用 API Key,个别 API 需要 Bearer Token + +```java +@HttpProxy +@RequestAuth(type = AuthType.API_KEY, name = "X-Service-Key", value = "service-key") +public interface ServiceClient { + @GetMapping("/api/users") + List getUsers(); // 使用接口级别的 API Key + + @GetMapping("/api/admin") + @RequestAuth(type = AuthType.BEARER, value = "admin-token") + AdminData getAdminData(); // 覆盖为 Bearer Token +} +``` + +--- + +### 场景 3:组合认证 + +**需求:** 服务级 API Key + 用户级 Bearer Token + +```java +@HttpProxy +@RequestAuth(type = AuthType.API_KEY, name = "X-Service-Key", value = "service-key") +public interface UserClient { + @GetMapping("/api/user/profile") + @RequestAuth(type = AuthType.BEARER, provider = UserTokenProvider.class) + UserProfile getProfile(); +} +``` + +**生成的 HTTP 请求:** +``` +GET /api/user/profile HTTP/1.1 +X-Service-Key: service-key +Authorization: Bearer user-token-from-provider +``` + +**说明:** +- 接口级别的认证不会被覆盖,而是叠加 +- 最终请求同时包含两种认证信息 + +--- + +### 场景 4:多租户场景 + +**需求:** 不同租户使用不同的 API Key + +```java +@HttpProxy +public interface TenantClient { + @GetMapping("/api/data") + Data getData(@RequestAuth(type = AuthType.API_KEY, name = "X-Tenant-Key") String tenantKey); +} +``` + +**调用示例:** +```java +// 租户 A +Data dataA = tenantClient.getData("tenant-a-key"); + +// 租户 B +Data dataB = tenantClient.getData("tenant-b-key"); +``` + +--- + +### 场景 5:动态用户认证 + +**需求:** 根据当前登录用户动态设置认证 + +```java +@HttpProxy +public interface UserClient { + @GetMapping("/api/profile") + UserProfile getProfile(@RequestAuth(type = AuthType.BEARER) String userToken); +} +``` + +**业务代码:** +```java +@Service +public class UserService { + @Autowired + private UserClient userClient; + + public UserProfile getCurrentUserProfile() { + String token = SecurityContext.getCurrentToken(); + return userClient.getProfile(token); + } +} +``` + +--- + +## 最佳实践 + +### 1. 敏感信息管理 + +**❌ 不推荐:硬编码** +```java +@RequestAuth(type = AuthType.BEARER, value = "hardcoded-token-123") +``` + +**✅ 推荐:使用配置** +```java +@Component +public class ConfigurableTokenProvider implements AuthProvider { + @Value("${api.token}") + private String token; + + @Override + public Authorization provide() { + return Authorization.createBearer(token); + } +} + +@RequestAuth(type = AuthType.BEARER, provider = ConfigurableTokenProvider.class) +``` + +--- + +### 2. 认证层级选择 + +| 场景 | 推荐层级 | 原因 | +|------|---------|------| +| 所有方法使用相同认证 | 接口级别 | 避免重复配置 | +| 少数方法需要特殊认证 | 方法级别覆盖 | 灵活性 | +| 每次请求认证信息不同 | 参数级别 | 动态性 | +| 认证信息需要刷新/计算 | Provider | 复用和维护性 | + +--- + +### 3. Provider 最佳实践 + +**单例 Provider(推荐)** +```java +@Component +public class TokenProvider implements AuthProvider { + // 注入依赖 + @Autowired + private TokenService tokenService; + + @Override + public Authorization provide() { + return Authorization.createBearer(tokenService.getToken()); + } +} +``` + +**优点:** +- 可以依赖注入其他 Bean +- 生命周期由 Spring 管理 +- 支持缓存和复用 + +--- + +### 4. 参数级别认证注意事项 + +**BASIC 认证必须提供基础值:** + +```java +// ✅ 正确:方法级别提供完整认证 +@RequestAuth(type = AuthType.BASIC, username = "base", password = "pass") +UserData login(@RequestAuth(type = AuthType.BASIC) String username); + +// ❌ 错误:方法级别缺少 password +@RequestAuth(type = AuthType.BASIC, username = "base") // 缺少 password +UserData login(@RequestAuth(type = AuthType.BASIC) String username); +``` + +**API_KEY 的 name 属性语义:** + +```java +// BASIC: name 指定要更新的字段 +@RequestAuth(type = BASIC, name = "username") // 更新 username 字段 + +// API_KEY: name 指定 HTTP Header/Query 名称 +@RequestAuth(type = API_KEY, name = "X-API-Key") // HTTP Header 名称 +``` + +--- + +### 5. 错误处理 + +**Provider 中的错误处理:** + +```java +@Component +public class ResilientTokenProvider implements AuthProvider { + @Override + public Authorization provide() { + try { + String token = fetchTokenFromRemote(); + return Authorization.createBearer(token); + } catch (Exception e) { + // 降级到本地缓存的 token + String cachedToken = loadFromCache(); + if (cachedToken != null) { + return Authorization.createBearer(cachedToken); + } + throw new AuthenticationException("Failed to obtain token", e); + } + } +} +``` + +--- + +## 常见问题 + +### Q1: 如何在运行时切换认证方式? + +**A:** 使用 Provider 动态决定认证类型 + +```java +@Component +public class DynamicAuthProvider implements AuthProvider { + @Value("${auth.type}") + private String authType; + + @Override + public Authorization provide() { + if ("bearer".equals(authType)) { + return Authorization.createBearer(getToken()); + } else if ("basic".equals(authType)) { + return Authorization.createBasic(getUsername(), getPassword()); + } + throw new IllegalStateException("Unknown auth type: " + authType); + } +} +``` + +--- + +### Q2: 参数级别的认证会覆盖方法级别的认证吗? + +**A:** 不会完全覆盖,而是**更新字段** + +```java +@RequestAuth(type = BASIC, username = "admin", password = "pass123") +void login(@RequestAuth(type = BASIC) String username); + +// 调用: login("john") +// 结果: username=john, password=pass123 (password 保持不变) +``` + +--- + +### Q3: 可以同时使用多个 @RequestAuth 注解吗? + +**A:** 可以,使用 `@Repeatable` 支持 + +```java +@HttpProxy +@RequestAuth(type = API_KEY, name = "X-Service-Key", value = "service-key") +@RequestAuth(type = BEARER, provider = UserTokenProvider.class) +public interface MultiAuthClient { + // 同时发送两种认证信息 +} +``` + +--- + +### Q4: Provider 的生命周期是什么? + +**A:** Provider 通过 `BeanContainer` 获取,遵循 Spring Bean 的生命周期 + +- **单例模式**(推荐):Provider 只创建一次,可以维护状态(如缓存) +- **原型模式**:每次请求创建新实例(需要配置 `@Scope("prototype")`) + +--- + +### Q5: 如何测试使用了认证的客户端? + +**方法 1:Mock Provider** + +```java +@SpringBootTest +class UserClientTest { + @MockBean + private TokenProvider tokenProvider; + + @Autowired + private UserClient userClient; + + @Test + void testGetUsers() { + when(tokenProvider.provide()) + .thenReturn(Authorization.createBearer("test-token")); + + List users = userClient.getUsers(); + assertNotNull(users); + } +} +``` + +**方法 2:使用测试配置** + +```java +@TestConfiguration +public class TestAuthConfig { + @Bean + @Primary + public TokenProvider testTokenProvider() { + return () -> Authorization.createBearer("test-token"); + } +} +``` + +--- + +### Q6: BASIC 认证的 name 属性为什么和 API_KEY 不同? + +**A:** `name` 属性语义重载,根据认证类型有不同含义: + +| 认证类型 | name 属性含义 | 示例 | +|---------|-------------|------| +| BASIC | Authorization 对象字段名 | `name="username"` 或 `name="password"` | +| API_KEY | HTTP Header/Query 名称 | `name="X-API-Key"` | +| BEARER | 无效(被忽略) | - | + +**设计原因:** +- BASIC:参数级别需要指定更新 username 还是 password +- API_KEY:需要指定 HTTP 中的 Key 名称 + +--- + +## 示例代码 + +完整的示例代码请参考: +- **Example 07**: `examples/fit-example/07-http-client-proxy` +- **测试用例**: `TestAuthClient.java` +- **Provider 示例**: `DynamicTokenProvider.java`, `CustomSignatureProvider.java` + +--- + +## 相关文档 + +- [HTTP 客户端认证原理手册](./HTTP_CLIENT_AUTH_PRINCIPLES.md) +- [Authorization 对象详解](./HTTP_CLIENT_AUTH_AUTHORIZATION.md) +- [FIT HTTP 客户端代理文档](./README.md) diff --git a/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/annotation/RequestAuth.java b/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/annotation/RequestAuth.java index b25489f17..03b85a8ba 100644 --- a/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/annotation/RequestAuth.java +++ b/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/annotation/RequestAuth.java @@ -72,14 +72,65 @@ String value() default StringUtils.EMPTY; /** - * 鉴权参数的名称。 + * 鉴权参数的名称,语义根据鉴权类型不同而不同。 + * + *

对于 API_KEY 类型:

*
    - *
  • 对于 API Key,这是 key 的名称(如 "X-API-Key")
  • - *
  • 对于 Bearer Token,这个字段不使用(默认使用 Authorization 头)
  • - *
  • 对于 Basic Auth,这个字段不使用
  • + *
  • 指定 HTTP Header/Query/Cookie 的名称(如 "X-API-Key")
  • + *
  • 此属性在静态配置和参数级别配置中都有效
  • + *
  • 示例:{@code @RequestAuth(type = API_KEY, name = "X-API-Key", value = "secret")}
  • + *
+ * + *

对于 BASIC 类型(仅参数级别):

+ *
    + *
  • 指定要更新的 Authorization 字段名("username" 或 "password")
  • + *
  • 允许在参数级别分别更新用户名和密码
  • + *
  • 如果不指定,默认更新 username 字段
  • + *
  • 示例: + *
    {@code
    +     * String login(
    +     *     @RequestAuth(type = BASIC, name = "username") String user,
    +     *     @RequestAuth(type = BASIC, name = "password") String pwd
    +     * );
    +     * }
  • *
* + *

对于 BEARER 类型:

+ *
    + *
  • 此属性不使用,会被忽略(默认使用 Authorization 头)
  • + *
+ * + *

name 属性的语义总结:

+ * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + *
name 属性在不同鉴权类型下的含义
鉴权类型应用位置name 属性的含义示例值
API_KEY静态配置/参数级别HTTP Header/Query/Cookie 的名称"X-API-Key", "api_key"
BASIC仅参数级别Authorization 对象的字段名"username", "password"
BEARER任意位置无效(被忽略)-
+ * * @return 表示鉴权参数名称的 {@link String}。 + * @see modelengine.fit.http.client.proxy.scanner.resolver.AuthFieldMapper */ String name() default StringUtils.EMPTY; diff --git a/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/client/proxy/scanner/AnnotationParser.java b/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/client/proxy/scanner/AnnotationParser.java index c7f438ce7..e6eee183a 100644 --- a/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/client/proxy/scanner/AnnotationParser.java +++ b/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/client/proxy/scanner/AnnotationParser.java @@ -7,6 +7,7 @@ package modelengine.fit.http.client.proxy.scanner; import static modelengine.fitframework.inspection.Validation.notNull; +import static modelengine.fitframework.util.ObjectUtils.cast; import modelengine.fit.http.annotation.DeleteMapping; import modelengine.fit.http.annotation.GetMapping; @@ -15,6 +16,7 @@ import modelengine.fit.http.annotation.PostMapping; import modelengine.fit.http.annotation.PutMapping; import modelengine.fit.http.annotation.RequestAddress; +import modelengine.fit.http.annotation.RequestAuth; import modelengine.fit.http.annotation.RequestBean; import modelengine.fit.http.annotation.RequestBody; import modelengine.fit.http.annotation.RequestCookie; @@ -22,20 +24,20 @@ import modelengine.fit.http.annotation.RequestHeader; import modelengine.fit.http.annotation.RequestMapping; import modelengine.fit.http.annotation.RequestQuery; -import modelengine.fit.http.annotation.RequestAuth; import modelengine.fit.http.client.proxy.PropertyValueApplier; import modelengine.fit.http.client.proxy.scanner.entity.Address; import modelengine.fit.http.client.proxy.scanner.entity.HttpInfo; import modelengine.fit.http.client.proxy.scanner.resolver.PathVariableResolver; +import modelengine.fit.http.client.proxy.scanner.resolver.RequestAuthResolver; import modelengine.fit.http.client.proxy.scanner.resolver.RequestBodyResolver; import modelengine.fit.http.client.proxy.scanner.resolver.RequestCookieResolver; import modelengine.fit.http.client.proxy.scanner.resolver.RequestFormResolver; import modelengine.fit.http.client.proxy.scanner.resolver.RequestHeaderResolver; import modelengine.fit.http.client.proxy.scanner.resolver.RequestQueryResolver; -import modelengine.fit.http.client.proxy.scanner.resolver.RequestAuthResolver; import modelengine.fit.http.client.proxy.support.applier.MultiDestinationsPropertyValueApplier; import modelengine.fit.http.client.proxy.support.applier.StaticAuthApplier; import modelengine.fit.http.client.proxy.support.setter.DestinationSetterInfo; +import modelengine.fitframework.ioc.BeanContainer; import modelengine.fitframework.util.ArrayUtils; import modelengine.fitframework.util.ReflectionUtils; import modelengine.fitframework.util.StringUtils; @@ -68,7 +70,7 @@ public class AnnotationParser { private static final Set> mappingMethodAnnotations = Stream.of(PostMapping.class, PutMapping.class, GetMapping.class, DeleteMapping.class, PatchMapping.class) .collect(Collectors.toSet()); - private static final Map, ParamResolver> annotationParsers = new HashMap<>(); + private static final Map, ParamResolver> annotationParsers = new HashMap<>(); static { annotationParsers.put(RequestQuery.class, new RequestQueryResolver()); @@ -81,14 +83,17 @@ public class AnnotationParser { } private final ValueFetcher valueFetcher; + private final BeanContainer beanContainer; /** * Constructs an AnnotationParser with the specified ValueFetcher. * - * @param valueFetcher The ValueFetcher used to fetch values for property setters. + * @param valueFetcher The {@link ValueFetcher} used to fetch values for property setters. + * @param beanContainer The {@link BeanContainer} used to retrieve beans. */ - public AnnotationParser(ValueFetcher valueFetcher) { + public AnnotationParser(ValueFetcher valueFetcher, BeanContainer beanContainer) { this.valueFetcher = notNull(valueFetcher, "The value fetcher cannot be null."); + this.beanContainer = notNull(beanContainer, "The bean container cannot be null."); } /** @@ -106,10 +111,12 @@ public Map parseInterface(Class clazz) { Arrays.stream(clazz.getMethods()).forEach(method -> { HttpInfo httpInfo = this.parseMethod(method, pathPatternPrefix); httpInfo.setAddress(address); - // 添加类级别的鉴权应用器 - List appliers = new ArrayList<>(classLevelAuthAppliers); - appliers.addAll(httpInfo.getAppliers()); - httpInfo.setAppliers(appliers); + + // 构建静态应用器列表(类级别鉴权 + 方法级别鉴权) + List staticAppliers = new ArrayList<>(classLevelAuthAppliers); + staticAppliers.addAll(this.getMethodLevelAuthAppliers(method)); + httpInfo.setStaticAppliers(staticAppliers); + httpInfoMap.put(method, httpInfo); }); } @@ -119,15 +126,12 @@ public Map parseInterface(Class clazz) { private HttpInfo parseMethod(Method method, String pathPatternPrefix) { HttpInfo httpInfo = new HttpInfo(); this.parseHttpMethod(method, httpInfo, pathPatternPrefix); - List appliers = new ArrayList<>(); - // 添加方法级别的鉴权应用器 - List methodLevelAuthAppliers = this.getMethodLevelAuthAppliers(method); - appliers.addAll(methodLevelAuthAppliers); + // 构建参数应用器列表 + List paramAppliers = new ArrayList<>(); + Arrays.stream(method.getParameters()).forEach(parameter -> paramAppliers.add(this.parseParam(parameter))); + httpInfo.setParamAppliers(paramAppliers); - // 添加参数应用器 - Arrays.stream(method.getParameters()).forEach(parameter -> appliers.add(this.parseParam(parameter))); - httpInfo.setAppliers(appliers); return httpInfo; } @@ -183,7 +187,7 @@ private Address getAddress(Class requestAddressClazz) { private PropertyValueApplier parseParam(Parameter parameter) { Annotation[] annotations = parameter.getAnnotations(); Class type = parameter.getType(); - List setterInfos = getSetterInfos(annotations, type.getDeclaredFields(), "$"); + List setterInfos = this.getSetterInfos(annotations, type.getDeclaredFields(), "$"); return new MultiDestinationsPropertyValueApplier(setterInfos, this.valueFetcher); } @@ -197,9 +201,9 @@ private List getSetterInfos(Annotation[] annotations, Fie prefix + "." + field.getName()))); return setterInfos; } else { - ParamResolver resolver = annotationParsers.get(annotation.annotationType()); + ParamResolver resolver = annotationParsers.get(annotation.annotationType()); if (resolver != null) { - setterInfos.add(resolver.resolve(annotation, prefix)); + setterInfos.add(resolver.resolve(cast(annotation), prefix)); return setterInfos; } } @@ -211,7 +215,7 @@ private List getClassLevelAuthAppliers(Class clazz) { List appliers = new ArrayList<>(); RequestAuth[] authAnnotations = clazz.getAnnotationsByType(RequestAuth.class); for (RequestAuth auth : authAnnotations) { - appliers.add(new StaticAuthApplier(auth)); + appliers.add(new StaticAuthApplier(auth, this.beanContainer)); } return appliers; } @@ -220,7 +224,7 @@ private List getMethodLevelAuthAppliers(Method method) { List appliers = new ArrayList<>(); RequestAuth[] authAnnotations = method.getAnnotationsByType(RequestAuth.class); for (RequestAuth auth : authAnnotations) { - appliers.add(new StaticAuthApplier(auth)); + appliers.add(new StaticAuthApplier(auth, this.beanContainer)); } return appliers; } diff --git a/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/client/proxy/scanner/HttpInvocationHandler.java b/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/client/proxy/scanner/HttpInvocationHandler.java index 0a4526a4f..4b04ea60f 100644 --- a/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/client/proxy/scanner/HttpInvocationHandler.java +++ b/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/client/proxy/scanner/HttpInvocationHandler.java @@ -65,10 +65,19 @@ public Object invoke(Object proxy, Method method, Object[] args) throws Throwabl if (httpInfo == null) { throw new HttpClientException("No method http info."); } - List appliers = httpInfo.getAppliers(); - if (args.length != appliers.size()) { - throw new HttpClientException("Args length not equals to appliers size."); + + // 处理 args 为 null 的情况 + Object[] actualArgs = args != null ? args : new Object[0]; + + // 获取分离的应用器列表 + List staticAppliers = httpInfo.getStaticAppliers(); + List paramAppliers = httpInfo.getParamAppliers(); + + // 检查参数数量与参数应用器数量是否匹配 + if (actualArgs.length != paramAppliers.size()) { + throw new HttpClientException("Args length not equals to param appliers size."); } + HttpClassicClient client = this.factory.create(); RequestBuilder requestBuilder = RequestBuilder.create() .client(client) @@ -78,8 +87,15 @@ public Object invoke(Object proxy, Method method, Object[] args) throws Throwabl if (address != null) { requestBuilder.protocol(address.getProtocol()).host(address.getHost()).port(address.getPort()); } - for (int i = 0; i < appliers.size(); i++) { - appliers.get(i).apply(requestBuilder, args[i]); + + // 先应用静态应用器(不需要参数) + for (PropertyValueApplier staticApplier : staticAppliers) { + staticApplier.apply(requestBuilder, null); + } + + // 再应用参数应用器(需要对应参数) + for (int i = 0; i < paramAppliers.size(); i++) { + paramAppliers.get(i).apply(requestBuilder, actualArgs[i]); } HttpClassicClientRequest request = requestBuilder.build(); try (HttpClassicClientResponse response = client.exchange(request, method.getReturnType())) { diff --git a/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/client/proxy/scanner/entity/HttpInfo.java b/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/client/proxy/scanner/entity/HttpInfo.java index dd673728e..302ed0fb4 100644 --- a/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/client/proxy/scanner/entity/HttpInfo.java +++ b/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/client/proxy/scanner/entity/HttpInfo.java @@ -22,7 +22,8 @@ public class HttpInfo { private Address address; private HttpRequestMethod method; private String pathPattern; - private List appliers = new ArrayList<>(); + private List staticAppliers = new ArrayList<>(); + private List paramAppliers = new ArrayList<>(); /** * Gets the address information for the HTTP request. @@ -79,20 +80,38 @@ public void setPathPattern(String pathPattern) { } /** - * Gets the list of property value appliers for the HTTP request. + * Gets the list of static property value appliers (not requiring parameters). * - * @return The list of property value appliers. + * @return The list of static property value appliers. */ - public List getAppliers() { - return this.appliers; + public List getStaticAppliers() { + return this.staticAppliers; } /** - * Sets the list of property value appliers for the HTTP request. + * Sets the list of static property value appliers (not requiring parameters). * - * @param appliers The list of property value appliers to set. + * @param staticAppliers The list of static property value appliers to set. */ - public void setAppliers(List appliers) { - this.appliers = appliers; + public void setStaticAppliers(List staticAppliers) { + this.staticAppliers = staticAppliers; + } + + /** + * Gets the list of parameter-based property value appliers. + * + * @return The list of parameter-based property value appliers. + */ + public List getParamAppliers() { + return this.paramAppliers; + } + + /** + * Sets the list of parameter-based property value appliers. + * + * @param paramAppliers The list of parameter-based property value appliers to set. + */ + public void setParamAppliers(List paramAppliers) { + this.paramAppliers = paramAppliers; } } \ No newline at end of file diff --git a/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/client/proxy/scanner/resolver/AuthFieldMapper.java b/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/client/proxy/scanner/resolver/AuthFieldMapper.java new file mode 100644 index 000000000..4f693d76b --- /dev/null +++ b/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/client/proxy/scanner/resolver/AuthFieldMapper.java @@ -0,0 +1,219 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) 2025 Huawei Technologies Co., Ltd. All rights reserved. + * This file is a part of the ModelEngine Project. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +package modelengine.fit.http.client.proxy.scanner.resolver; + +import modelengine.fit.http.client.proxy.auth.AuthType; +import modelengine.fit.http.client.proxy.support.authorization.ApiKeyAuthorization; +import modelengine.fit.http.client.proxy.support.authorization.BasicAuthorization; +import modelengine.fit.http.client.proxy.support.authorization.BearerAuthorization; +import modelengine.fitframework.util.StringUtils; + +/** + * 鉴权字段映射工具类。 + *

用于确定参数级别鉴权应该更新 {@link modelengine.fit.http.client.proxy.Authorization} + * 对象的哪个字段。

+ * + *

设计背景

+ *

参数级别的鉴权(如 {@code @RequestAuth(type = BEARER) String token})需要通过 + * {@code authorizationInfo(key, value)} 方法动态更新已存在的 Authorization 对象。 + * 这个 "key" 必须与 Authorization 实现类中 {@code setValue(String key, Object value)} + * 方法能识别的字段名一致。

+ * + *

字段映射关系

+ * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + *
鉴权类型字段映射表
鉴权类型Authorization 实现可更新字段字段含义字段常量
BEARERBearerAuthorization"token"Bearer Token 值BearerAuthorization.AUTH_TOKEN
BASICBasicAuthorization"username"
"password"
用户名或密码
(参数级别建议只更新一个)
BasicAuthorization.AUTH_USER_NAME
BasicAuthorization.AUTH_USER_PWD
API_KEYApiKeyAuthorization"key"
"value"
HTTP Header/Query 名称
实际的 API Key 值
ApiKeyAuthorization.AUTH_KEY
ApiKeyAuthorization.AUTH_VALUE
+ * + *

使用示例

+ *
{@code
+ * // 参数级别 Bearer Token
+ * String api(@RequestAuth(type = BEARER) String token);
+ * // → 更新 BearerAuthorization.token 字段
+ *
+ * // 参数级别 API Key(更新值)
+ * String search(@RequestAuth(type = API_KEY, name = "X-API-Key") String key);
+ * // → 更新 ApiKeyAuthorization.value 字段
+ * // → ApiKeyAuthorization.key 从注解的 name 属性获取("X-API-Key")
+ * }
+ * + *

与 Tool 系统的一致性

+ *

此类的设计与 Tool 系统中的 JSON 配置保持一致。例如:

+ *
{@code
+ * // Tool JSON 配置
+ * {
+ *   "mappings": {
+ *     "people": {
+ *       "name": {
+ *         "key": "token",           // ← 字段名
+ *         "httpSource": "AUTHORIZATION"
+ *       }
+ *     }
+ *   }
+ * }
+ *
+ * // 对应的注解使用
+ * String api(@RequestAuth(type = BEARER) String token);
+ * // → AuthFieldMapper.getParameterAuthField(BEARER) 返回 "token"
+ * // → 与 JSON 中的 "key": "token" 完全一致
+ * }
+ * + * @author 季聿阶 + * @see modelengine.fit.http.client.proxy.Authorization + * @see modelengine.fit.http.client.proxy.support.setter.AuthorizationDestinationSetter + * @see BearerAuthorization + * @see BasicAuthorization + * @see ApiKeyAuthorization + * @since 2025-10-01 + */ +public final class AuthFieldMapper { + private AuthFieldMapper() { + // 工具类,禁止实例化 + } + + /** + * 获取参数级别鉴权应该更新的 Authorization 字段名。 + * + *

此方法返回的字段名将用于 {@code requestBuilder.authorizationInfo(key, value)} 调用, + * 进而调用 {@code authorization.set(key, value)} 来更新对应字段。

+ * + *

重要说明:

+ *
    + *
  • BEARER: 返回 {@code "token"},更新 Bearer Token 值。 + *
    示例:{@code @RequestAuth(type = BEARER) String token} + *
    效果:更新 {@code BearerAuthorization.token} 字段 + *
    注意:{@code name} 属性对 BEARER 无效
  • + * + *
  • BASIC: 根据 {@code name} 属性决定更新哪个字段。 + *
    name 属性行为: + *
      + *
    • {@code name = "username"}: 更新 {@code BasicAuthorization.username} 字段
    • + *
    • {@code name = "password"}: 更新 {@code BasicAuthorization.password} 字段
    • + *
    • {@code name} 未指定或为空: 默认更新 {@code username} 字段(向后兼容)
    • + *
    + *
    单字段示例:{@code @RequestAuth(type = BASIC) String username} + *
    效果:更新 {@code BasicAuthorization.username} 字段 + *

    双字段示例(推荐): + *
    {@code
    +     * String login(
    +     *     @RequestAuth(type = BASIC, name = "username") String user,
    +     *     @RequestAuth(type = BASIC, name = "password") String pwd
    +     * );
    +     *     }
    + * 效果:同时更新 {@code username} 和 {@code password} 字段
  • + * + *
  • API_KEY: 返回 {@code "value"},更新 API Key 的值(而非名称)。 + *
    关键理解:API Key 有两个概念: + *
      + *
    • API Key 的名称:HTTP Header/Query 的 key(如 "X-API-Key"), + * 对应 {@code ApiKeyAuthorization.key} 字段,通过注解的 {@code name} 属性指定
    • + *
    • API Key 的:实际的密钥字符串, + * 对应 {@code ApiKeyAuthorization.value} 字段,通过参数传入
    • + *
    + * 示例:{@code @RequestAuth(type = API_KEY, name = "X-API-Key") String apiKeyValue} + *
    效果: + *
      + *
    • {@code ApiKeyAuthorization.key} = "X-API-Key" (从注解的 name 属性)
    • + *
    • {@code ApiKeyAuthorization.value} = apiKeyValue (从参数,本方法返回的字段)
    • + *
    • 最终 HTTP Header: {@code X-API-Key: apiKeyValue}
    • + *
    + * 注意:对于 API_KEY,{@code name} 属性必须指定 HTTP Header/Query 名称, + * 不能用于字段选择
  • + *
+ * + *

name 属性的语义重载:

+ *

{@code name} 属性在不同鉴权类型下有不同含义:

+ *
    + *
  • BASIC: 指定要更新的字段名("username" 或 "password")
  • + *
  • API_KEY: 指定 HTTP Header/Query 的名称(如 "X-API-Key")
  • + *
  • BEARER: 无效,被忽略
  • + *
+ * + * @param type 表示鉴权类型的 {@link AuthType}。 + * @param nameAttribute 注解的 {@code name} 属性值,可能为 {@code null} 或空字符串。 + * @return Authorization 对象的字段名,用于 {@code authorization.set(fieldName, value)} 调用。 + * @throws IllegalArgumentException 如果鉴权类型不支持参数级别动态更新,或 BASIC 类型的 + * {@code name} 属性值无效(不是 "username" 或 "password")。 + */ + public static String getParameterAuthField(AuthType type, String nameAttribute) { + return switch (type) { + case BEARER -> + // 参考 BearerAuthorization.AUTH_TOKEN = "token" + // setValue() 方法: if (key.equals("token")) { this.token = value; } + // name 属性对 BEARER 无效,直接忽略 + "token"; + case BASIC -> { + // 参考 BasicAuthorization.AUTH_USER_NAME = "username", AUTH_USER_PWD = "password" + // setValue() 方法: + // if (key.equals("username")) { this.username = value; } + // if (key.equals("password")) { this.password = value; } + // + // name 属性用于指定要更新的字段: + // - name = "username": 更新 username 字段 + // - name = "password": 更新 password 字段 + // - name 未指定或为空: 默认更新 username(向后兼容) + if (StringUtils.isNotBlank(nameAttribute)) { + if (StringUtils.equals("username", nameAttribute)) { + yield "username"; + } else if (StringUtils.equals("password", nameAttribute)) { + yield "password"; + } else { + throw new IllegalArgumentException( + "For BASIC auth, name attribute must be 'username' or 'password', got: " + + nameAttribute); + } + } + // 默认行为:更新 username(向后兼容) + yield "username"; + // 默认行为:更新 username(向后兼容) + } + case API_KEY -> + // 参考 ApiKeyAuthorization.AUTH_VALUE = "value" + // setValue() 方法: if (key.equals("value")) { this.value = value; } + // + // 重要:返回 "value" 而不是 "key" + // - ApiKeyAuthorization.key 字段存储的是 HTTP Header/Query 的名称(如 "X-API-Key") + // 这个值来自注解的 name 属性,在静态鉴权时设置 + // - ApiKeyAuthorization.value 字段存储的是实际的 API Key 值 + // 这个值来自参数传入,是参数级别需要动态更新的字段 + // + // 注意:对于 API_KEY,name 属性的含义与 BASIC 不同 + // - API_KEY 的 name: HTTP Header/Query 的名称(不影响此方法返回值) + // - BASIC 的 name: 要更新的字段名(影响此方法返回值) + "value"; + case CUSTOM -> throw new IllegalArgumentException( + "CUSTOM auth type must use AuthProvider, not supported for parameter-level auth"); + }; + } +} diff --git a/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/client/proxy/scanner/resolver/RequestAuthResolver.java b/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/client/proxy/scanner/resolver/RequestAuthResolver.java index 3816df7ce..27379bb09 100644 --- a/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/client/proxy/scanner/resolver/RequestAuthResolver.java +++ b/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/client/proxy/scanner/resolver/RequestAuthResolver.java @@ -8,19 +8,40 @@ import modelengine.fit.http.annotation.RequestAuth; import modelengine.fit.http.client.proxy.scanner.ParamResolver; -import modelengine.fit.http.client.proxy.support.setter.AuthDestinationSetter; +import modelengine.fit.http.client.proxy.support.setter.AuthorizationDestinationSetter; import modelengine.fit.http.client.proxy.support.setter.DestinationSetterInfo; /** * 解析 {@link RequestAuth} 注解的解析器。 *

负责将 {@link RequestAuth} 注解转换为可用于设置 HTTP 请求鉴权信息的 {@link DestinationSetterInfo} 对象。

+ *

复用底层的 {@link AuthorizationDestinationSetter} 机制,确保与 FEL Tool 系统架构一致。

+ * + *

工作原理

+ *

参数级别的鉴权通过 {@link AuthorizationDestinationSetter} 动态更新已存在的 Authorization 对象。 + * 使用 {@link AuthFieldMapper} 确定应该更新 Authorization 对象的哪个字段。

+ * + *

使用示例

+ *
{@code
+ * // Bearer Token
+ * String api(@RequestAuth(type = BEARER) String token);
+ * // → 更新 BearerAuthorization.token 字段
+ *
+ * // API Key
+ * String search(@RequestAuth(type = API_KEY, name = "X-API-Key") String apiKey);
+ * // → 更新 ApiKeyAuthorization.value 字段
+ * // → ApiKeyAuthorization.key 从注解的 name 属性获取
+ * }
* * @author 季聿阶 * @since 2025-09-30 + * @see AuthFieldMapper */ public class RequestAuthResolver implements ParamResolver { @Override public DestinationSetterInfo resolve(RequestAuth annotation, String jsonPath) { - return new DestinationSetterInfo(new AuthDestinationSetter(annotation), jsonPath); + // 使用 AuthFieldMapper 获取应该更新的 Authorization 字段名 + // 传入 name 属性以支持 BASIC 类型的字段选择(username 或 password) + String authField = AuthFieldMapper.getParameterAuthField(annotation.type(), annotation.name()); + return new DestinationSetterInfo(new AuthorizationDestinationSetter(authField), jsonPath); } } \ No newline at end of file diff --git a/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/client/proxy/support/applier/StaticAuthApplier.java b/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/client/proxy/support/applier/StaticAuthApplier.java index b2622d469..df5680374 100644 --- a/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/client/proxy/support/applier/StaticAuthApplier.java +++ b/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/client/proxy/support/applier/StaticAuthApplier.java @@ -6,33 +6,88 @@ package modelengine.fit.http.client.proxy.support.applier; +import static modelengine.fitframework.inspection.Validation.notNull; + import modelengine.fit.http.annotation.RequestAuth; +import modelengine.fit.http.client.proxy.Authorization; import modelengine.fit.http.client.proxy.PropertyValueApplier; import modelengine.fit.http.client.proxy.RequestBuilder; -import modelengine.fit.http.client.proxy.support.setter.AuthDestinationSetter; +import modelengine.fit.http.client.proxy.auth.AuthProvider; +import modelengine.fit.http.client.proxy.auth.AuthType; +import modelengine.fitframework.ioc.BeanContainer; +import modelengine.fitframework.util.StringUtils; /** * 静态鉴权信息应用器。 *

用于处理类级别和方法级别的 @RequestAuth 注解,将静态鉴权信息应用到 HTTP 请求中。

+ *

复用底层的 {@link Authorization} 机制,确保架构一致性。

* * @author 季聿阶 * @since 2025-09-30 */ public class StaticAuthApplier implements PropertyValueApplier { - private final AuthDestinationSetter authSetter; + private final Authorization authorization; /** - * 使用指定的鉴权注解初始化 {@link StaticAuthApplier} 的新实例。 + * 使用指定的鉴权注解和 BeanContainer 初始化 {@link StaticAuthApplier} 的新实例。 * * @param authAnnotation 表示鉴权注解的 {@link RequestAuth}。 + * @param beanContainer 表示 Bean 容器,用于获取 AuthProvider。 */ - public StaticAuthApplier(RequestAuth authAnnotation) { - this.authSetter = new AuthDestinationSetter(authAnnotation); + public StaticAuthApplier(RequestAuth authAnnotation, BeanContainer beanContainer) { + notNull(beanContainer, "The bean container cannot be null."); + this.authorization = this.createAuthorizationFromAnnotation(authAnnotation, beanContainer); } @Override public void apply(RequestBuilder requestBuilder, Object value) { - // 静态鉴权不需要参数值,传入 null 即可 - authSetter.set(requestBuilder, null); + // 静态鉴权不需要参数值,直接将 Authorization 对象设置到 RequestBuilder + requestBuilder.authorization(this.authorization); } + + private Authorization createAuthorizationFromAnnotation(RequestAuth annotation, BeanContainer beanContainer) { + // 如果指定了 Provider,需要 BeanContainer + if (annotation.provider() != AuthProvider.class) { + AuthProvider provider = beanContainer.beans().get(annotation.provider()); + if (provider == null) { + throw new IllegalStateException( + "AuthProvider not found in BeanContainer: " + annotation.provider().getName()); + } + return provider.provide(); + } + + // 基于注解类型创建 Authorization + AuthType type = annotation.type(); + switch (type) { + case BEARER: + String token = annotation.value(); + if (StringUtils.isEmpty(token)) { + throw new IllegalArgumentException("Bearer token cannot be empty for static auth"); + } + return Authorization.createBearer(token); + + case BASIC: + String username = annotation.username(); + String password = annotation.password(); + if (StringUtils.isEmpty(username) || StringUtils.isEmpty(password)) { + throw new IllegalArgumentException("Username and password cannot be empty for Basic auth"); + } + return Authorization.createBasic(username, password); + + case API_KEY: + String keyName = annotation.name(); + String keyValue = annotation.value(); + if (StringUtils.isEmpty(keyName) || StringUtils.isEmpty(keyValue)) { + throw new IllegalArgumentException("API Key name and value cannot be empty for static auth"); + } + return Authorization.createApiKey(keyName, keyValue, annotation.location()); + + case CUSTOM: + throw new IllegalArgumentException("CUSTOM auth type requires a provider"); + + default: + throw new IllegalArgumentException("Unsupported auth type: " + type); + } + } + } \ No newline at end of file diff --git a/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/client/proxy/support/setter/AuthDestinationSetter.java b/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/client/proxy/support/setter/AuthDestinationSetter.java deleted file mode 100644 index fc6e4eafe..000000000 --- a/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/client/proxy/support/setter/AuthDestinationSetter.java +++ /dev/null @@ -1,135 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) 2025 Huawei Technologies Co., Ltd. All rights reserved. - * This file is a part of the ModelEngine Project. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -package modelengine.fit.http.client.proxy.support.setter; - -import modelengine.fit.http.annotation.RequestAuth; -import modelengine.fit.http.client.proxy.Authorization; -import modelengine.fit.http.client.proxy.DestinationSetter; -import modelengine.fit.http.client.proxy.RequestBuilder; -import modelengine.fit.http.client.proxy.auth.AuthProvider; -import modelengine.fit.http.client.proxy.auth.AuthType; -import modelengine.fit.http.server.handler.Source; -import modelengine.fitframework.ioc.BeanContainer; -import modelengine.fitframework.util.StringUtils; - -/** - * 表示向 HTTP 请求设置鉴权信息的 {@link DestinationSetter}。 - *

支持多种鉴权类型和动态 Provider。

- * - * @author 季聿阶 - * @since 2025-09-30 - */ -public class AuthDestinationSetter implements DestinationSetter { - private final RequestAuth authAnnotation; - private final BeanContainer beanContainer; - - /** - * 使用指定的鉴权注解初始化 {@link AuthDestinationSetter} 的新实例。 - * - * @param authAnnotation 表示鉴权注解的 {@link RequestAuth}。 - */ - public AuthDestinationSetter(RequestAuth authAnnotation) { - this(authAnnotation, null); - } - - /** - * 使用指定的鉴权注解和 Bean 容器初始化 {@link AuthDestinationSetter} 的新实例。 - * - * @param authAnnotation 表示鉴权注解的 {@link RequestAuth}。 - * @param beanContainer 表示 Bean 容器的 {@link BeanContainer}。 - */ - public AuthDestinationSetter(RequestAuth authAnnotation, BeanContainer beanContainer) { - this.authAnnotation = authAnnotation; - this.beanContainer = beanContainer; - } - - @Override - public void set(RequestBuilder requestBuilder, Object value) { - Authorization authorization = createAuthorization(value); - if (authorization != null) { - authorization.assemble(requestBuilder); - } - } - - private Authorization createAuthorization(Object value) { - // 如果指定了 Provider,优先使用 Provider - if (authAnnotation.provider() != AuthProvider.class) { - if (beanContainer != null) { - AuthProvider provider = beanContainer.beans().get(authAnnotation.provider()); - if (provider != null) { - return provider.provide(); - } else { - throw new IllegalStateException("AuthProvider " + authAnnotation.provider().getName() + " not found in container"); - } - } else { - // TODO: MVP 版本暂时不支持 Provider,后续版本再实现 - throw new UnsupportedOperationException("AuthProvider support is not implemented in this version"); - } - } - - // 基于注解类型创建 Authorization - AuthType type = authAnnotation.type(); - switch (type) { - case BEARER: - String token = getBearerToken(value); - if (StringUtils.isNotEmpty(token)) { - return Authorization.createBearer(token); - } - break; - case BASIC: - String username = getBasicUsername(); - String password = getBasicPassword(); - if (StringUtils.isNotEmpty(username) && StringUtils.isNotEmpty(password)) { - return Authorization.createBasic(username, password); - } - break; - case API_KEY: - String keyName = getApiKeyName(); - String keyValue = getApiKeyValue(value); - Source location = authAnnotation.location(); - if (StringUtils.isNotEmpty(keyName) && StringUtils.isNotEmpty(keyValue)) { - return Authorization.createApiKey(keyName, keyValue, location); - } - break; - case CUSTOM: - // CUSTOM 类型必须使用 Provider - throw new IllegalArgumentException("CUSTOM auth type requires a provider"); - } - - return null; - } - - private String getBearerToken(Object value) { - // 如果是参数驱动,使用参数值 - if (value instanceof String) { - return (String) value; - } - // 否则使用注解中的静态值 - return StringUtils.isNotEmpty(authAnnotation.value()) ? authAnnotation.value() : null; - } - - private String getBasicUsername() { - return StringUtils.isNotEmpty(authAnnotation.username()) ? authAnnotation.username() : null; - } - - private String getBasicPassword() { - return StringUtils.isNotEmpty(authAnnotation.password()) ? authAnnotation.password() : null; - } - - private String getApiKeyName() { - return StringUtils.isNotEmpty(authAnnotation.name()) ? authAnnotation.name() : null; - } - - private String getApiKeyValue(Object value) { - // 如果是参数驱动,使用参数值 - if (value instanceof String) { - return (String) value; - } - // 否则使用注解中的静态值 - return StringUtils.isNotEmpty(authAnnotation.value()) ? authAnnotation.value() : null; - } -} \ No newline at end of file diff --git a/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/test/java/modelengine/fit/http/client/proxy/scanner/HttpInvocationHandlerTest.java b/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/test/java/modelengine/fit/http/client/proxy/scanner/HttpInvocationHandlerTest.java new file mode 100644 index 000000000..1439b4a6c --- /dev/null +++ b/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/test/java/modelengine/fit/http/client/proxy/scanner/HttpInvocationHandlerTest.java @@ -0,0 +1,264 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) 2025 Huawei Technologies Co., Ltd. All rights reserved. + * This file is a part of the ModelEngine Project. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +package modelengine.fit.http.client.proxy.scanner; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.fail; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import modelengine.fit.http.client.HttpClassicClient; +import modelengine.fit.http.client.HttpClassicClientFactory; +import modelengine.fit.http.client.HttpClassicClientResponse; +import modelengine.fit.http.client.HttpClientException; +import modelengine.fit.http.client.proxy.PropertyValueApplier; +import modelengine.fit.http.client.proxy.RequestBuilder; +import modelengine.fit.http.client.proxy.scanner.entity.Address; +import modelengine.fit.http.client.proxy.scanner.entity.HttpInfo; +import modelengine.fit.http.entity.TextEntity; +import modelengine.fit.http.protocol.HttpRequestMethod; +import modelengine.fitframework.ioc.BeanContainer; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * 表示 {@link HttpInvocationHandler} 的单元测试。 + * + * @author 季聿阶 + * @since 2025-09-30 + */ +class HttpInvocationHandlerTest { + @Mock + private HttpClassicClientFactory mockFactory; + + @Mock + private BeanContainer mockContainer; + + @Mock + private HttpClassicClient mockClient; + + @Mock + private HttpClassicClientResponse mockResponse; + + @Mock + private TextEntity mockTextEntity; + + @Mock + private PropertyValueApplier mockStaticApplier; + + @Mock + private PropertyValueApplier mockParamApplier; + + private HttpInvocationHandler handler; + private Map httpInfoMap; + private Method noParamMethod; + private Method oneParamMethod; + private Method twoParamMethod; + private AutoCloseable mockCloseable; + + @BeforeEach + void setUp() throws NoSuchMethodException { + // 初始化标有 @Mock 注解的字段,返回 AutoCloseable 用于资源清理 + this.mockCloseable = MockitoAnnotations.openMocks(this); + + when(this.mockFactory.create()).thenReturn(this.mockClient); + when(this.mockClient.exchange(any(), any())).thenReturn(this.mockResponse); + when(this.mockResponse.statusCode()).thenReturn(200); + when(this.mockResponse.textEntity()).thenReturn(Optional.of(this.mockTextEntity)); + when(this.mockTextEntity.content()).thenReturn("test response"); + + this.httpInfoMap = new HashMap<>(); + this.handler = new HttpInvocationHandler(this.httpInfoMap, this.mockContainer, this.mockFactory); + + // 创建测试方法 + this.noParamMethod = TestInterface.class.getMethod("noParamMethod"); + this.oneParamMethod = TestInterface.class.getMethod("oneParamMethod", String.class); + this.twoParamMethod = TestInterface.class.getMethod("twoParamMethod", String.class, String.class); + } + + @AfterEach + void tearDown() throws Exception { + if (this.mockCloseable != null) { + this.mockCloseable.close(); + } + } + + /** + * 测试核心Bug修复:args为null时不再导致NullPointerException + */ + @Test + void testInvokeWithNoParametersAndNullArgs() throws Throwable { + HttpInfo httpInfo = createHttpInfo(); + httpInfo.setStaticAppliers(List.of(this.mockStaticApplier)); + httpInfo.setParamAppliers(new ArrayList<>()); + this.httpInfoMap.put(this.noParamMethod, httpInfo); + + // 这个测试验证核心Bug修复:args为null不再导致NullPointerException + try { + Object result = this.handler.invoke(null, this.noParamMethod, null); + assertEquals("test response", result); + } catch (NullPointerException e) { + if (e.getMessage().contains("Cannot read the array length because \"args\" is null")) { + fail("核心Bug未修复:args为null时仍然导致NullPointerException"); + } + // 其他NPE可能是Mock问题,不是我们测试的核心 + throw e; + } + + // 验证静态应用器被调用 + verify(this.mockStaticApplier).apply(any(RequestBuilder.class), eq(null)); + verify(this.mockParamApplier, never()).apply(any(), any()); + } + + /** + * 测试参数数量不匹配异常 + */ + @Test + void testInvokeWithParameterCountMismatch() { + HttpInfo httpInfo = createHttpInfo(); + httpInfo.setStaticAppliers(List.of(this.mockStaticApplier)); + httpInfo.setParamAppliers(List.of(this.mockParamApplier)); // 期望1个参数 + this.httpInfoMap.put(this.twoParamMethod, httpInfo); + + // 传入2个参数,但期望1个参数应用器 + Object[] args = {"param1", "param2"}; + + HttpClientException exception = + assertThrows(HttpClientException.class, () -> this.handler.invoke(null, this.twoParamMethod, args)); + + assertEquals("Args length not equals to param appliers size.", exception.getMessage()); + } + + /** + * 测试没有HTTP信息的异常处理 + */ + @Test + void testInvokeWithNoHttpInfo() { + // 不设置HttpInfo + HttpClientException exception = + assertThrows(HttpClientException.class, () -> this.handler.invoke(null, this.noParamMethod, null)); + + assertEquals("No method http info.", exception.getMessage()); + } + + /** + * 测试单参数方法的正常调用 + */ + @Test + void testInvokeWithOneParameter() throws Throwable { + HttpInfo httpInfo = createHttpInfo(); + httpInfo.setStaticAppliers(List.of(this.mockStaticApplier)); + httpInfo.setParamAppliers(List.of(this.mockParamApplier)); + this.httpInfoMap.put(this.oneParamMethod, httpInfo); + + String param = "test-param"; + Object result = this.handler.invoke(null, this.oneParamMethod, new Object[] {param}); + + assertEquals("test response", result); + + // 验证静态和参数应用器都被调用 + verify(this.mockStaticApplier).apply(any(RequestBuilder.class), eq(null)); + verify(this.mockParamApplier).apply(any(RequestBuilder.class), eq(param)); + } + + /** + * 测试多参数方法的正常调用 + */ + @Test + void testInvokeWithMultipleParameters() throws Throwable { + HttpInfo httpInfo = createHttpInfo(); + PropertyValueApplier mockParamApplier2 = mock(PropertyValueApplier.class); + + httpInfo.setStaticAppliers(List.of(this.mockStaticApplier)); + httpInfo.setParamAppliers(List.of(this.mockParamApplier, mockParamApplier2)); + this.httpInfoMap.put(this.twoParamMethod, httpInfo); + + String param1 = "test-param1"; + String param2 = "test-param2"; + Object result = this.handler.invoke(null, this.twoParamMethod, new Object[] {param1, param2}); + + assertEquals("test response", result); + + // 验证所有应用器被正确调用 + verify(this.mockStaticApplier).apply(any(RequestBuilder.class), eq(null)); + verify(this.mockParamApplier).apply(any(RequestBuilder.class), eq(param1)); + verify(mockParamApplier2).apply(any(RequestBuilder.class), eq(param2)); + } + + /** + * 测试只有静态应用器的方法调用 + */ + @Test + void testInvokeWithStaticAppliersOnly() throws Throwable { + HttpInfo httpInfo = createHttpInfo(); + PropertyValueApplier mockStaticApplier2 = mock(PropertyValueApplier.class); + + httpInfo.setStaticAppliers(List.of(this.mockStaticApplier, mockStaticApplier2)); + httpInfo.setParamAppliers(new ArrayList<>()); + this.httpInfoMap.put(this.noParamMethod, httpInfo); + + Object result = this.handler.invoke(null, this.noParamMethod, null); + + assertEquals("test response", result); + + // 验证所有静态应用器被调用 + verify(this.mockStaticApplier).apply(any(RequestBuilder.class), eq(null)); + verify(mockStaticApplier2).apply(any(RequestBuilder.class), eq(null)); + verify(this.mockParamApplier, never()).apply(any(), any()); + } + + /** + * 测试空参数数组的处理 + */ + @Test + void testInvokeWithEmptyArgs() throws Throwable { + HttpInfo httpInfo = createHttpInfo(); + httpInfo.setStaticAppliers(List.of(this.mockStaticApplier)); + httpInfo.setParamAppliers(new ArrayList<>()); + this.httpInfoMap.put(this.noParamMethod, httpInfo); + + Object result = this.handler.invoke(null, this.noParamMethod, new Object[0]); + + assertEquals("test response", result); + verify(this.mockStaticApplier).apply(any(RequestBuilder.class), eq(null)); + } + + private HttpInfo createHttpInfo() { + HttpInfo httpInfo = new HttpInfo(); + httpInfo.setMethod(HttpRequestMethod.GET); + httpInfo.setPathPattern("/test"); + httpInfo.setAddress(new Address()); + httpInfo.setStaticAppliers(new ArrayList<>()); + httpInfo.setParamAppliers(new ArrayList<>()); + return httpInfo; + } + + // 测试接口 + interface TestInterface { + String noParamMethod(); + + String oneParamMethod(String param); + + String twoParamMethod(String param1, String param2); + } +} \ No newline at end of file diff --git a/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/test/java/modelengine/fit/http/client/proxy/scanner/resolver/AuthFieldMapperTest.java b/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/test/java/modelengine/fit/http/client/proxy/scanner/resolver/AuthFieldMapperTest.java new file mode 100644 index 000000000..50f8f8538 --- /dev/null +++ b/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/test/java/modelengine/fit/http/client/proxy/scanner/resolver/AuthFieldMapperTest.java @@ -0,0 +1,122 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) 2025 Huawei Technologies Co., Ltd. All rights reserved. + * This file is a part of the ModelEngine Project. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +package modelengine.fit.http.client.proxy.scanner.resolver; + +import modelengine.fit.http.client.proxy.auth.AuthType; +import modelengine.fit.http.client.proxy.support.authorization.ApiKeyAuthorization; +import modelengine.fit.http.client.proxy.support.authorization.BasicAuthorization; +import modelengine.fit.http.client.proxy.support.authorization.BearerAuthorization; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * AuthFieldMapper 的单元测试。 + *

验证参数级别鉴权的字段映射逻辑是否正确。

+ * + * @author 季聿阶 + * @since 2025-10-01 + */ +@DisplayName("AuthFieldMapper 测试") +class AuthFieldMapperTest { + + @Test + @DisplayName("Bearer 鉴权应该映射到 token 字段(name 属性被忽略)") + void testBearerAuthField() { + // 不指定 name 属性 + String field = AuthFieldMapper.getParameterAuthField(AuthType.BEARER, null); + assertEquals("token", field); + + // 即使指定 name 属性也会被忽略 + field = AuthFieldMapper.getParameterAuthField(AuthType.BEARER, "ignored"); + assertEquals("token", field); + } + + @Test + @DisplayName("BASIC 鉴权默认应该映射到 username 字段(向后兼容)") + void testBasicAuthFieldDefault() { + // 不指定 name 属性,默认返回 username + String field = AuthFieldMapper.getParameterAuthField(AuthType.BASIC, null); + assertEquals("username", field); + + // 空字符串也应该返回 username + field = AuthFieldMapper.getParameterAuthField(AuthType.BASIC, ""); + assertEquals("username", field); + } + + @Test + @DisplayName("BASIC 鉴权可以通过 name='username' 明确指定 username 字段") + void testBasicAuthFieldExplicitUsername() { + String field = AuthFieldMapper.getParameterAuthField(AuthType.BASIC, "username"); + assertEquals("username", field); + } + + @Test + @DisplayName("BASIC 鉴权可以通过 name='password' 指定 password 字段") + void testBasicAuthFieldPassword() { + String field = AuthFieldMapper.getParameterAuthField(AuthType.BASIC, "password"); + assertEquals("password", field); + } + + @Test + @DisplayName("BASIC 鉴权的 name 属性必须是 'username' 或 'password'") + void testBasicAuthFieldInvalidName() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> AuthFieldMapper.getParameterAuthField(AuthType.BASIC, "invalid") + ); + assertTrue(exception.getMessage().contains("must be 'username' or 'password'")); + } + + @Test + @DisplayName("API Key 鉴权应该映射到 value 字段(而非 key 字段)") + void testApiKeyAuthField() { + // name 属性用于指定 HTTP Header 名称,不影响字段映射 + String field = AuthFieldMapper.getParameterAuthField(AuthType.API_KEY, "X-API-Key"); + + // 验证返回的字段名正确 + // 重要:应该是 "value" 而不是 "key" + assertEquals("value", field); + + // 确保不是错误地返回了 "key" + assertNotEquals("key", field); + } + + @Test + @DisplayName("CUSTOM 鉴权类型应该抛出异常") + void testCustomAuthTypeThrowsException() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> AuthFieldMapper.getParameterAuthField(AuthType.CUSTOM, null) + ); + + assertTrue(exception.getMessage().contains("CUSTOM")); + assertTrue(exception.getMessage().contains("AuthProvider")); + } + + @Test + @DisplayName("验证字段名与 Authorization 实现的 setValue 方法兼容") + void testFieldCompatibilityWithAuthorizationImplementations() { + // Bearer: 确保字段名能被 BearerAuthorization.setValue() 识别 + String bearerField = AuthFieldMapper.getParameterAuthField(AuthType.BEARER, null); + assertEquals("token", bearerField); + + // Basic: 确保字段名能被 BasicAuthorization.setValue() 识别 + String basicFieldUsername = AuthFieldMapper.getParameterAuthField(AuthType.BASIC, "username"); + assertEquals("username", basicFieldUsername); + + String basicFieldPassword = AuthFieldMapper.getParameterAuthField(AuthType.BASIC, "password"); + assertEquals("password", basicFieldPassword); + + // API Key: 确保字段名能被 ApiKeyAuthorization.setValue() 识别 + String apiKeyField = AuthFieldMapper.getParameterAuthField(AuthType.API_KEY, null); + assertTrue("key".equals(apiKeyField) || "value".equals(apiKeyField)); + // 并且应该是 "value",因为参数级别更新的是 API Key 的值 + assertEquals("value", apiKeyField); + } +} diff --git a/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/test/java/modelengine/fit/http/client/proxy/scanner/resolver/RequestAuthResolverTest.java b/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/test/java/modelengine/fit/http/client/proxy/scanner/resolver/RequestAuthResolverTest.java index 9e06f1e30..f9a96a09d 100644 --- a/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/test/java/modelengine/fit/http/client/proxy/scanner/resolver/RequestAuthResolverTest.java +++ b/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/test/java/modelengine/fit/http/client/proxy/scanner/resolver/RequestAuthResolverTest.java @@ -8,7 +8,7 @@ import modelengine.fit.http.annotation.RequestAuth; import modelengine.fit.http.client.proxy.auth.AuthType; -import modelengine.fit.http.client.proxy.support.setter.AuthDestinationSetter; +import modelengine.fit.http.client.proxy.support.setter.AuthorizationDestinationSetter; import modelengine.fit.http.client.proxy.support.setter.DestinationSetterInfo; import modelengine.fit.http.server.handler.Source; import org.junit.jupiter.api.BeforeEach; @@ -29,7 +29,7 @@ class RequestAuthResolverTest { @BeforeEach void setUp() { - resolver = new RequestAuthResolver(); + this.resolver = new RequestAuthResolver(); } @Test @@ -39,12 +39,16 @@ void testResolveBearerAuth() { String jsonPath = "$.token"; // 解析注解 - DestinationSetterInfo setterInfo = resolver.resolve(authAnnotation, jsonPath); + DestinationSetterInfo setterInfo = this.resolver.resolve(authAnnotation, jsonPath); - // 验证结果 + // 验证结果 - 现在使用 AuthorizationDestinationSetter assertNotNull(setterInfo); - assertInstanceOf(AuthDestinationSetter.class, setterInfo.destinationSetter()); + assertInstanceOf(AuthorizationDestinationSetter.class, setterInfo.destinationSetter()); assertEquals(jsonPath, setterInfo.sourcePath()); + + // 验证 Setter 类型(字段名的正确性由 AuthFieldMapperTest 验证) + AuthorizationDestinationSetter setter = (AuthorizationDestinationSetter) setterInfo.destinationSetter(); + assertNotNull(setter); } @Test @@ -55,12 +59,17 @@ void testResolveApiKeyAuth() { String jsonPath = "$.apiKey"; // 解析注解 - DestinationSetterInfo setterInfo = resolver.resolve(authAnnotation, jsonPath); + DestinationSetterInfo setterInfo = this.resolver.resolve(authAnnotation, jsonPath); - // 验证结果 + // 验证结果 - 现在使用 AuthorizationDestinationSetter assertNotNull(setterInfo); - assertInstanceOf(AuthDestinationSetter.class, setterInfo.destinationSetter()); + assertInstanceOf(AuthorizationDestinationSetter.class, setterInfo.destinationSetter()); assertEquals(jsonPath, setterInfo.sourcePath()); + + // 验证 Setter 类型(字段名的正确性由 AuthFieldMapperTest 验证) + // 注意:此测试发现之前的 Bug - API Key 应该映射到 "value" 字段,而不是 annotation.name() + AuthorizationDestinationSetter setter = (AuthorizationDestinationSetter) setterInfo.destinationSetter(); + assertNotNull(setter); } @Test @@ -71,12 +80,16 @@ void testResolveBasicAuth() { String jsonPath = "$"; // 解析注解 - DestinationSetterInfo setterInfo = resolver.resolve(authAnnotation, jsonPath); + DestinationSetterInfo setterInfo = this.resolver.resolve(authAnnotation, jsonPath); - // 验证结果 + // 验证结果 - 现在使用 AuthorizationDestinationSetter assertNotNull(setterInfo); - assertInstanceOf(AuthDestinationSetter.class, setterInfo.destinationSetter()); + assertInstanceOf(AuthorizationDestinationSetter.class, setterInfo.destinationSetter()); assertEquals(jsonPath, setterInfo.sourcePath()); + + // 验证 Setter 类型(字段名的正确性由 AuthFieldMapperTest 验证) + AuthorizationDestinationSetter setter = (AuthorizationDestinationSetter) setterInfo.destinationSetter(); + assertNotNull(setter); } // 辅助方法:创建RequestAuth注解的模拟对象 diff --git a/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/test/java/modelengine/fit/http/client/proxy/support/setter/AuthDestinationSetterTest.java b/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/test/java/modelengine/fit/http/client/proxy/support/setter/AuthDestinationSetterTest.java deleted file mode 100644 index a21dda24b..000000000 --- a/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/test/java/modelengine/fit/http/client/proxy/support/setter/AuthDestinationSetterTest.java +++ /dev/null @@ -1,153 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) 2025 Huawei Technologies Co., Ltd. All rights reserved. - * This file is a part of the ModelEngine Project. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -package modelengine.fit.http.client.proxy.support.setter; - -import modelengine.fit.http.annotation.RequestAuth; -import modelengine.fit.http.client.proxy.RequestBuilder; -import modelengine.fit.http.client.proxy.auth.AuthType; -import modelengine.fit.http.server.handler.Source; -import org.junit.jupiter.api.Test; -import org.mockito.Mockito; - -import java.lang.annotation.Annotation; - -import static org.mockito.Mockito.*; - -/** - * AuthDestinationSetter 的单元测试。 - * - * @author 季聿阶 - * @since 2025-09-30 - */ -class AuthDestinationSetterTest { - @Test - void testSetBearerTokenStatic() { - // 创建 Bearer Token 注解 - RequestAuth authAnnotation = createRequestAuth(AuthType.BEARER, "test-bearer-token", "", - Source.HEADER, "", "", null); - - AuthDestinationSetter setter = new AuthDestinationSetter(authAnnotation); - RequestBuilder mockBuilder = Mockito.mock(RequestBuilder.class); - - // 执行设置(静态 token,value 应该为 null) - setter.set(mockBuilder, null); - - // 验证是否调用了正确的 header 方法 - verify(mockBuilder).header("Authorization", "Bearer test-bearer-token"); - } - - @Test - void testSetBearerTokenDynamic() { - // 创建 Bearer Token 注解(没有静态值) - RequestAuth authAnnotation = createRequestAuth(AuthType.BEARER, "", "", - Source.HEADER, "", "", null); - - AuthDestinationSetter setter = new AuthDestinationSetter(authAnnotation); - RequestBuilder mockBuilder = Mockito.mock(RequestBuilder.class); - - // 执行设置(动态 token) - setter.set(mockBuilder, "dynamic-bearer-token"); - - // 验证是否调用了正确的 header 方法 - verify(mockBuilder).header("Authorization", "Bearer dynamic-bearer-token"); - } - - @Test - void testSetBasicAuth() { - // 创建 Basic Auth 注解 - RequestAuth authAnnotation = createRequestAuth(AuthType.BASIC, "", "", - Source.HEADER, "admin", "secret", null); - - AuthDestinationSetter setter = new AuthDestinationSetter(authAnnotation); - RequestBuilder mockBuilder = Mockito.mock(RequestBuilder.class); - - // 执行设置 - setter.set(mockBuilder, null); - - // 验证是否调用了正确的 header 方法(Basic Auth 的 base64 编码) - verify(mockBuilder).header(eq("Authorization"), argThat(value -> - value.toString().startsWith("Basic "))); - } - - @Test - void testSetApiKeyHeader() { - // 创建 API Key Header 注解 - RequestAuth authAnnotation = createRequestAuth(AuthType.API_KEY, "test-api-key", "X-API-Key", - Source.HEADER, "", "", null); - - AuthDestinationSetter setter = new AuthDestinationSetter(authAnnotation); - RequestBuilder mockBuilder = Mockito.mock(RequestBuilder.class); - - // 执行设置 - setter.set(mockBuilder, null); - - // 验证是否调用了正确的 header 方法 - verify(mockBuilder).header("X-API-Key", "test-api-key"); - } - - @Test - void testSetApiKeyQuery() { - // 创建 API Key Query 注解 - RequestAuth authAnnotation = createRequestAuth(AuthType.API_KEY, "test-api-key", "api_key", - Source.QUERY, "", "", null); - - AuthDestinationSetter setter = new AuthDestinationSetter(authAnnotation); - RequestBuilder mockBuilder = Mockito.mock(RequestBuilder.class); - - // 执行设置 - setter.set(mockBuilder, null); - - // 验证是否调用了正确的 query 方法 - verify(mockBuilder).query("api_key", "test-api-key"); - } - - // 辅助方法:创建 RequestAuth 注解的模拟对象 - private RequestAuth createRequestAuth(AuthType type, String value, String name, Source location, - String username, String password, Class provider) { - return new RequestAuth() { - @Override - public AuthType type() { - return type; - } - - @Override - public String value() { - return value; - } - - @Override - public String name() { - return name; - } - - @Override - public Source location() { - return location; - } - - @Override - public String username() { - return username; - } - - @Override - public String password() { - return password; - } - - @Override - public Class provider() { - return provider != null ? provider : modelengine.fit.http.client.proxy.auth.AuthProvider.class; - } - - @Override - public Class annotationType() { - return RequestAuth.class; - } - }; - } -} \ No newline at end of file