Skip to content

Commit 1f8b92d

Browse files
committed
feat(sdk/java): 呈现 hints + 入站校验 + manifest 上传接线(补齐三项缺口)
- FieldHints:setFieldHint/setFieldWidget(不可变风格,复用 invoker.Json 解析),7 例单测 - 入站校验:ClientConfig.validateInputPayloads(默认关)+ validateInboundPayload(复用 invoker.JsonSchemaValidator), invoke 失败回错误 payload、startTask 抛错;6 例单测 - manifest 上传:SdkWireMessages 新增 RegisterCapabilitiesRequest/ProviderMeta wire 消息(编码+解码), 注册成功后独立短连接 control_addr 上传(best-effort),2 例单测 - 矩阵:三行全部翻绿;Java 全量 gradle test 通过
1 parent 9f04c33 commit 1f8b92d

7 files changed

Lines changed: 528 additions & 5 deletions

File tree

sdks/java/src/main/java/io/github/cuihairu/croupier/sdk/ClientConfig.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ public class ClientConfig {
4848

4949
// ========== File Transfer Configuration ==========
5050
private boolean enableFileTransfer = false; // Enable file transfer functionality (default: false)
51+
private boolean validateInputPayloads = false; // F:provider 侧入站校验(按函数声明 input schema),默认关闭
5152
private int maxFileSize = 10485760; // Max file size in bytes (default: 10MB)
5253

5354
// ========== Logging Configuration ==========
@@ -123,6 +124,9 @@ public ClientConfig(String gameId, String serviceId) {
123124
public boolean isEnableFileTransfer() { return enableFileTransfer; }
124125
public void setEnableFileTransfer(boolean enableFileTransfer) { this.enableFileTransfer = enableFileTransfer; }
125126

127+
public boolean isValidateInputPayloads() { return validateInputPayloads; }
128+
public void setValidateInputPayloads(boolean validateInputPayloads) { this.validateInputPayloads = validateInputPayloads; }
129+
126130
public int getMaxFileSize() { return maxFileSize; }
127131
public void setMaxFileSize(int maxFileSize) { this.maxFileSize = maxFileSize; }
128132

sdks/java/src/main/java/io/github/cuihairu/croupier/sdk/CroupierClientImpl.java

Lines changed: 80 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,9 @@ public CompletableFuture<Void> connect() {
145145
startHeartbeatLoop();
146146

147147
logger.info("Successfully connected");
148+
149+
// F:控制面 manifest 上传(best-effort,不阻断连接结果)
150+
maybeRegisterCapabilities();
148151
} catch (Exception e) {
149152
connected.set(false);
150153
sessionId = "";
@@ -300,6 +303,40 @@ private void closeTransport() {
300303
}
301304
}
302305

306+
/**
307+
* F:向控制面(control_addr)上传能力清单。
308+
* 独立短连接 + best-effort:任何失败仅告警,不影响已完成的注册连接。
309+
*/
310+
private void maybeRegisterCapabilities() {
311+
String controlAddr = config.getControlAddr();
312+
if (controlAddr == null || controlAddr.isBlank()) {
313+
return;
314+
}
315+
TransportClient controlTransport = null;
316+
try {
317+
controlTransport = transportFactory.apply(controlAddr, config.getTimeoutSeconds() * 1000);
318+
controlTransport.connect();
319+
SdkWireMessages.RegisterCapabilitiesRequest request =
320+
new SdkWireMessages.RegisterCapabilitiesRequest(
321+
new SdkWireMessages.ProviderMeta(
322+
config.getServiceId(),
323+
config.getServiceVersion(),
324+
config.getProviderLang(),
325+
config.getProviderSdk()),
326+
getManifestGzipped());
327+
controlTransport.request(
328+
Protocol.MSG_REGISTER_CAPABILITIES_REQ,
329+
SdkWireMessages.encodeRegisterCapabilitiesRequest(request));
330+
logger.info("Capabilities registered to control plane: {}", controlAddr);
331+
} catch (Exception e) {
332+
logger.warn("Failed to register capabilities: {}", e.getMessage());
333+
} finally {
334+
if (controlTransport != null) {
335+
controlTransport.close();
336+
}
337+
}
338+
}
339+
303340
private SdkWireMessages.ProviderConnectResponse providerConnect(TransportClient nextTransport) throws InvokerException {
304341
return SdkWireMessages.decodeProviderConnectResponse(
305342
nextTransport.request(
@@ -589,16 +626,45 @@ private byte[] handleInvokeRequest(byte[] body) throws Exception {
589626

590627
private byte[] invokeInbound(byte[] body) throws Exception {
591628
SdkWireMessages.InvokeRequest request = SdkWireMessages.decodeInvokeRequest(body);
592-
String result = invoke(
593-
request.functionId,
594-
new String(request.payload, StandardCharsets.UTF_8),
595-
request.metadata
596-
);
629+
String payload = new String(request.payload, StandardCharsets.UTF_8);
630+
631+
// Provider 侧入站校验(可选):按函数声明的 input schema 校验 payload,
632+
// 失败回错误响应,handler 不被调用(服务端仍是权威校验方)。
633+
if (config.isValidateInputPayloads()) {
634+
String validationError = validateInboundPayload(request.functionId, payload);
635+
if (validationError != null) {
636+
return SdkWireMessages.encodeInvokeResponse(
637+
new SdkWireMessages.InvokeResponse(
638+
("{\"error\":" + io.github.cuihairu.croupier.sdk.invoker.Json.stringify(validationError) + "}")
639+
.getBytes(StandardCharsets.UTF_8)));
640+
}
641+
}
642+
643+
String result = invoke(request.functionId, payload, request.metadata);
597644
return SdkWireMessages.encodeInvokeResponse(
598645
new SdkWireMessages.InvokeResponse(result.getBytes(StandardCharsets.UTF_8))
599646
);
600647
}
601648

649+
/**
650+
* Provider 侧入站校验(F:与 Go/Python/JS/C# 语义对齐):按函数声明的
651+
* input schema 校验 payload。开关关闭/未注册/schema 缺失时跳过(服务端
652+
* 仍是权威校验方);失败返回错误消息,通过则返回 null。
653+
*/
654+
private String validateInboundPayload(String functionId, String payload) {
655+
if (!config.isValidateInputPayloads()) {
656+
return null;
657+
}
658+
FunctionDescriptor descriptor = descriptors.get(functionId);
659+
if (descriptor == null || descriptor.getInputSchema() == null
660+
|| descriptor.getInputSchema().isBlank()) {
661+
return null;
662+
}
663+
List<String> errors = io.github.cuihairu.croupier.sdk.invoker.JsonSchemaValidator.validate(
664+
payload, descriptor.getInputSchema());
665+
return errors.isEmpty() ? null : "payload validation failed: " + String.join("; ", errors);
666+
}
667+
602668
private byte[] handleStartTaskRequest(byte[] body) throws Exception {
603669
SdkWireMessages.InvokeRequest request = SdkWireMessages.decodeInvokeRequest(body);
604670
String functionId = request.functionId;
@@ -607,6 +673,15 @@ private byte[] handleStartTaskRequest(byte[] body) throws Exception {
607673
throw new CroupierException("Function not found: " + functionId);
608674
}
609675

676+
// F:入站校验(同 invoke)——失败抛错,任务不启动
677+
if (config.isValidateInputPayloads()) {
678+
String validationError = validateInboundPayload(
679+
functionId, new String(request.payload, StandardCharsets.UTF_8));
680+
if (validationError != null) {
681+
throw new CroupierException(validationError);
682+
}
683+
}
684+
610685
String payload = new String(request.payload, StandardCharsets.UTF_8);
611686
String taskId = functionId + "-" + UUID.randomUUID().toString().substring(0, 12);
612687
LocalTaskState taskState = new LocalTaskState(taskId);
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package io.github.cuihairu.croupier.sdk;
2+
3+
import io.github.cuihairu.croupier.sdk.invoker.Json;
4+
5+
/**
6+
* F:x-ui 呈现 hints 便捷层(契约见 docs/architecture/presentation-hints.md)。
7+
*
8+
* 向函数描述符的 input schema 合并 x-* 呈现意图,供 Dashboard 生成更友好的表单。
9+
*/
10+
public final class FieldHints {
11+
12+
private FieldHints() {
13+
}
14+
15+
/**
16+
* 向 input_schema 的 properties[field] 合并单个 x-* hint。
17+
*
18+
* @return 合并后的新描述符(不可变风格,原描述符不变)
19+
* @throws IllegalArgumentException field 为空或 hint 不是 x-/x_ 扩展键
20+
*/
21+
public static FunctionDescriptor setFieldHint(FunctionDescriptor descriptor, String field,
22+
String hint, Object value) {
23+
if (descriptor == null) {
24+
throw new IllegalArgumentException("descriptor is required");
25+
}
26+
if (field == null || field.trim().isEmpty()) {
27+
throw new IllegalArgumentException("field key is required for setFieldHint");
28+
}
29+
String normalized = normalizeHintKey(hint);
30+
if (normalized == null) {
31+
throw new IllegalArgumentException(
32+
"hint \"" + hint + "\" must be an x- extension key (e.g. x-widget)");
33+
}
34+
35+
java.util.Map<String, Object> schema = parseSchema(descriptor.getInputSchema());
36+
schema.putIfAbsent("type", "object");
37+
@SuppressWarnings("unchecked")
38+
java.util.Map<String, Object> properties = schema.containsKey("properties")
39+
&& schema.get("properties") instanceof java.util.Map<?, ?> existingProperties
40+
? new java.util.HashMap<>((java.util.Map<String, Object>) existingProperties)
41+
: new java.util.HashMap<>();
42+
schema.put("properties", properties);
43+
@SuppressWarnings("unchecked")
44+
java.util.Map<String, Object> property = properties.containsKey(field)
45+
&& properties.get(field) instanceof java.util.Map<?, ?> existing
46+
? new java.util.HashMap<>((java.util.Map<String, Object>) existing)
47+
: new java.util.HashMap<>();
48+
property.put(normalized, value);
49+
properties.put(field, property);
50+
schema.put("properties", properties);
51+
52+
FunctionDescriptor updated = new FunctionDescriptor(descriptor);
53+
updated.setInputSchema(Json.stringify(schema));
54+
return updated;
55+
}
56+
57+
/** 等价于 {@code setFieldHint(descriptor, field, "x-widget", widget)}。 */
58+
public static FunctionDescriptor setFieldWidget(FunctionDescriptor descriptor, String field,
59+
String widget) {
60+
if (widget == null || widget.trim().isEmpty()) {
61+
throw new IllegalArgumentException("widget is required for setFieldWidget");
62+
}
63+
return setFieldHint(descriptor, field, "x-widget", widget);
64+
}
65+
66+
private static String normalizeHintKey(String hint) {
67+
if (hint == null) {
68+
return null;
69+
}
70+
String trimmed = hint.trim();
71+
if (trimmed.length() < 3) {
72+
return null;
73+
}
74+
char first = Character.toLowerCase(trimmed.charAt(0));
75+
char second = trimmed.charAt(1);
76+
if (first != 'x' || (second != '-' && second != '_')) {
77+
return null;
78+
}
79+
return "x-" + trimmed.substring(2);
80+
}
81+
82+
@SuppressWarnings("unchecked")
83+
private static java.util.Map<String, Object> parseSchema(String raw) {
84+
if (raw == null || raw.trim().isEmpty()) {
85+
return new java.util.HashMap<>();
86+
}
87+
Object parsed = Json.parse(raw);
88+
if (parsed instanceof java.util.Map<?, ?> map) {
89+
return new java.util.HashMap<>((java.util.Map<String, Object>) map);
90+
}
91+
throw new IllegalArgumentException("input schema must be a JSON object");
92+
}
93+
}

sdks/java/src/main/java/io/github/cuihairu/croupier/sdk/wire/SdkWireMessages.java

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -475,6 +475,91 @@ private interface Encoder {
475475
}
476476

477477
/** ProviderDrainRequest:Agent 请求 Provider 优雅下线。 */
478+
// ===== F:控制面能力清单上传 =====
479+
480+
public static final class ProviderMeta {
481+
public final String id;
482+
public final String version;
483+
public final String lang;
484+
public final String sdk;
485+
486+
public ProviderMeta(String id, String version, String lang, String sdk) {
487+
this.id = id == null ? "" : id;
488+
this.version = version == null ? "" : version;
489+
this.lang = lang == null ? "" : lang;
490+
this.sdk = sdk == null ? "" : sdk;
491+
}
492+
}
493+
494+
public static final class RegisterCapabilitiesRequest {
495+
public final ProviderMeta provider;
496+
public final byte[] manifestJsonGz;
497+
498+
public RegisterCapabilitiesRequest(ProviderMeta provider, byte[] manifestJsonGz) {
499+
this.provider = provider;
500+
this.manifestJsonGz = manifestJsonGz == null ? new byte[0] : manifestJsonGz;
501+
}
502+
}
503+
504+
public static byte[] encodeRegisterCapabilitiesRequest(RegisterCapabilitiesRequest message) {
505+
return encode(out -> {
506+
writeMessage(out, 1, encodeProviderMeta(message.provider));
507+
writeBytes(out, 2, message.manifestJsonGz);
508+
});
509+
}
510+
511+
public static RegisterCapabilitiesRequest decodeRegisterCapabilitiesRequest(byte[] data) {
512+
ProviderMeta provider = new ProviderMeta("", "", "", "");
513+
byte[] manifestJsonGz = new byte[0];
514+
CodedInputStream input = newInput(data);
515+
try {
516+
while (!input.isAtEnd()) {
517+
int tag = input.readTag();
518+
if (tag == 0) {
519+
break;
520+
}
521+
switch (WireFormat.getTagFieldNumber(tag)) {
522+
case 1 -> {
523+
byte[] nested = input.readByteArray();
524+
CodedInputStream nestedInput = newInput(nested);
525+
String id = "";
526+
String version = "";
527+
String lang = "";
528+
String sdk = "";
529+
while (!nestedInput.isAtEnd()) {
530+
int nestedTag = nestedInput.readTag();
531+
if (nestedTag == 0) {
532+
break;
533+
}
534+
switch (WireFormat.getTagFieldNumber(nestedTag)) {
535+
case 1 -> id = nestedInput.readString();
536+
case 2 -> version = nestedInput.readString();
537+
case 3 -> lang = nestedInput.readString();
538+
case 4 -> sdk = nestedInput.readString();
539+
default -> nestedInput.skipField(nestedTag);
540+
}
541+
}
542+
provider = new ProviderMeta(id, version, lang, sdk);
543+
}
544+
case 2 -> manifestJsonGz = input.readByteArray();
545+
default -> input.skipField(tag);
546+
}
547+
}
548+
} catch (java.io.IOException e) {
549+
throw new IllegalArgumentException("failed to decode RegisterCapabilitiesRequest", e);
550+
}
551+
return new RegisterCapabilitiesRequest(provider, manifestJsonGz);
552+
}
553+
554+
private static byte[] encodeProviderMeta(ProviderMeta meta) {
555+
return encode(out -> {
556+
writeString(out, 1, meta.id);
557+
writeString(out, 2, meta.version);
558+
writeString(out, 3, meta.lang);
559+
writeString(out, 4, meta.sdk);
560+
});
561+
}
562+
478563
public static final class ProviderDrainRequest {
479564
public final String sessionId;
480565
public final String reason;
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package io.github.cuihairu.croupier.sdk;
2+
3+
import org.junit.jupiter.api.Test;
4+
5+
import static org.junit.jupiter.api.Assertions.assertEquals;
6+
import static org.junit.jupiter.api.Assertions.assertFalse;
7+
import static org.junit.jupiter.api.Assertions.assertNotNull;
8+
import static org.junit.jupiter.api.Assertions.assertNull;
9+
import static org.junit.jupiter.api.Assertions.assertThrows;
10+
import static org.junit.jupiter.api.Assertions.assertTrue;
11+
12+
/**
13+
* F:x-ui 呈现 hints 便捷层测试。
14+
*/
15+
public class FieldHintsTest {
16+
17+
@Test
18+
public void emptySchemaCreatesObjectSkeleton() {
19+
FunctionDescriptor descriptor = FieldHints.setFieldWidget(
20+
new FunctionDescriptor("player.ban", "1.0.0"), "id", "Select");
21+
assertNotNull(descriptor.getInputSchema());
22+
assertTrue(descriptor.getInputSchema().contains("\"type\":\"object\""));
23+
assertTrue(descriptor.getInputSchema().contains("\"x-widget\":\"Select\""));
24+
}
25+
26+
@Test
27+
public void preservesExistingAttributesAndOverrides() {
28+
FunctionDescriptor descriptor = new FunctionDescriptor("player.ban", "1.0.0");
29+
descriptor.setInputSchema(
30+
"{\"type\":\"object\",\"properties\":{\"id\":{\"type\":\"string\",\"title\":\"玩家 ID\",\"x-widget\":\"Input\"}}}");
31+
FunctionDescriptor updated = FieldHints.setFieldWidget(descriptor, "id", "TreeSelect");
32+
// 不可变风格:原描述符不变
33+
assertTrue(descriptor.getInputSchema().contains("\"x-widget\":\"Input\""));
34+
assertTrue(updated.getInputSchema().contains("\"x-widget\":\"TreeSelect\""));
35+
assertTrue(updated.getInputSchema().contains("玩家 ID"));
36+
}
37+
38+
@Test
39+
public void optionsSourceObject() {
40+
FunctionDescriptor descriptor = FieldHints.setFieldHint(
41+
new FunctionDescriptor("player.ban", "1.0.0"), "id", "x-options-source",
42+
java.util.Map.of("functionId", "player.list",
43+
"labelPath", "/items/*/name",
44+
"valuePath", "/items/*/id"));
45+
assertTrue(descriptor.getInputSchema().contains("player.list"));
46+
assertTrue(descriptor.getInputSchema().contains("/items/*/name"));
47+
}
48+
49+
@Test
50+
public void xUnderscoreNormalizedToXDash() {
51+
FunctionDescriptor descriptor = FieldHints.setFieldHint(
52+
new FunctionDescriptor("f", "1.0.0"), "a", "x_widget", "Input");
53+
assertTrue(descriptor.getInputSchema().contains("x-widget"));
54+
assertFalse(descriptor.getInputSchema().contains("x_widget"));
55+
}
56+
57+
@Test
58+
public void invalidHintRejected() {
59+
assertThrows(IllegalArgumentException.class,
60+
() -> FieldHints.setFieldHint(new FunctionDescriptor("f", "1.0.0"), "a", "widget", "Input"));
61+
}
62+
63+
@Test
64+
public void emptyFieldRejected() {
65+
assertThrows(IllegalArgumentException.class,
66+
() -> FieldHints.setFieldHint(new FunctionDescriptor("f", "1.0.0"), " ", "x-widget", "Input"));
67+
}
68+
69+
@Test
70+
public void emptyWidgetRejected() {
71+
assertThrows(IllegalArgumentException.class,
72+
() -> FieldHints.setFieldWidget(new FunctionDescriptor("f", "1.0.0"), "a", " "));
73+
}
74+
}

0 commit comments

Comments
 (0)