Skip to content

Commit 9f04c33

Browse files
committed
feat(sdk/cpp): 呈现 hints 便捷层 + 控制面 manifest 上传
- field_hints.h:SetFieldHint/SetFieldWidget(x-/x_ 归一、空 schema 建骨架、重复覆盖、非法拒绝),9 例单测 - manifest 上传:Connect 成功后独立短连接 control_addr → RegisterCapabilitiesRequest(zlib gzip envelope,5s 超时,best-effort) - vcpkg.json + CMakeLists 新增 zlib 依赖;checked-in generated agent/v1/register.pb.cc 编入 SDK - 入站校验(前序提交)合计:C++ 测试 571 → 584 已知边界:manifest 上传端到端帧回路测试未覆盖(编译与逻辑对齐 Go 已验证版本),Java 同构接线后续补
1 parent 77b22e2 commit 9f04c33

5 files changed

Lines changed: 260 additions & 0 deletions

File tree

sdks/cpp/CMakeLists.txt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,9 @@ find_package(CURL REQUIRED)
116116
# TCP transport is enabled by default (gRPC removed)
117117
set(tcp_ENABLED ON)
118118

119+
# zlib for control-plane manifest gzip
120+
find_package(ZLIB REQUIRED)
121+
119122
# Protobuf for message serialization
120123
find_package(Protobuf CONFIG)
121124
if(NOT Protobuf_FOUND)
@@ -138,6 +141,8 @@ if(tcp_ENABLED AND (NOT DEFINED GENERATED_PROTO_SOURCES OR NOT GENERATED_PROTO_S
138141
set(_sdk_generated_invocation_h "${_sdk_generated_proto_dir}/croupier/sdk/v1/invocation.pb.h")
139142
set(_sdk_generated_provider_cc "${_sdk_generated_proto_dir}/croupier/sdk/v1/provider.pb.cc")
140143
set(_sdk_generated_provider_h "${_sdk_generated_proto_dir}/croupier/sdk/v1/provider.pb.h")
144+
set(_sdk_generated_register_cc "${_sdk_generated_proto_dir}/croupier/agent/v1/register.pb.cc")
145+
set(_sdk_generated_register_h "${_sdk_generated_proto_dir}/croupier/agent/v1/register.pb.h")
141146
if(EXISTS "${_sdk_generated_invocation_cc}" AND EXISTS "${_sdk_generated_invocation_h}" AND
142147
EXISTS "${_sdk_generated_provider_cc}" AND EXISTS "${_sdk_generated_provider_h}")
143148
set(PROTO_GENERATED_DIR "${_sdk_generated_proto_dir}")
@@ -147,6 +152,11 @@ if(tcp_ENABLED AND (NOT DEFINED GENERATED_PROTO_SOURCES OR NOT GENERATED_PROTO_S
147152
set(GENERATED_PROTO_HEADERS
148153
"${_sdk_generated_invocation_h}"
149154
"${_sdk_generated_provider_h}")
155+
if(EXISTS "${_sdk_generated_register_cc}" AND EXISTS "${_sdk_generated_register_h}")
156+
# agent/v1 register(RegisterCapabilitiesRequest 等,F:manifest 上传)
157+
list(APPEND GENERATED_PROTO_SOURCES "${_sdk_generated_register_cc}")
158+
list(APPEND GENERATED_PROTO_HEADERS "${_sdk_generated_register_h}")
159+
endif()
150160
message(STATUS "Using checked-in generated invocation/provider protobuf sources")
151161
endif()
152162
endif()
@@ -403,6 +413,9 @@ if(BUILD_SHARED_LIBS)
403413
protobuf::libprotobuf
404414
)
405415

416+
# Link zlib for control-plane manifest gzip
417+
target_link_libraries(croupier-sdk-shared PUBLIC ZLIB::ZLIB)
418+
406419
if(JSON_LIBRARY)
407420
target_link_libraries(croupier-sdk-shared
408421
PUBLIC
@@ -665,6 +678,7 @@ if(BUILD_TESTS)
665678
if(TARGET GTest::gtest_main OR TARGET gtest_main)
666679
set(CROUPIER_TEST_SOURCES
667680
tests/test_utils.cpp
681+
tests/test_field_hints.cpp
668682
tests/test_json_utils.cpp
669683
tests/test_file_utils.cpp
670684
tests/test_main_thread_dispatcher.cpp
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
// Croupier SDK field hints helper (x-ui-* presentation contract).
2+
//
3+
// 契约:docs/architecture/presentation-hints.md
4+
// 用法:
5+
// FunctionDescriptor desc = ...;
6+
// croupier::sdk::SetFieldWidget(desc, "id", "Select");
7+
// croupier::sdk::SetFieldHint(desc, "id", "x-options-source", {{"functionId","player.list"}});
8+
//
9+
// 依赖 nlohmann/json(vcpkg PUBLIC 依赖,SDK 使用方已可用)。
10+
11+
#pragma once
12+
13+
#include "croupier/sdk/croupier_client.h"
14+
15+
#include <nlohmann/json.hpp>
16+
17+
#include <cctype>
18+
#include <string>
19+
20+
namespace croupier::sdk {
21+
22+
// 向 input_schema 的 properties[field] 合并单个 x-* hint。
23+
// schema 为空时创建 object 骨架;重复设置覆盖;
24+
// hint 非法(非 x-/x_ 前缀)或 field 为空返回 false 且不修改 descriptor。
25+
inline bool SetFieldHint(FunctionDescriptor& descriptor, const std::string& field,
26+
const std::string& hint, const nlohmann::json& value) {
27+
if (field.empty() || hint.size() < 3) {
28+
return false;
29+
}
30+
const char c0 = static_cast<char>(std::tolower(static_cast<unsigned char>(hint[0])));
31+
if (c0 != 'x' || (hint[1] != '-' && hint[1] != '_')) {
32+
return false;
33+
}
34+
const std::string normalized = "x-" + hint.substr(2);
35+
if (normalized.size() < 3) {
36+
return false;
37+
}
38+
39+
nlohmann::json schema = nlohmann::json::object();
40+
if (!descriptor.input_schema.empty()) {
41+
try {
42+
schema = nlohmann::json::parse(descriptor.input_schema);
43+
} catch (...) {
44+
return false;
45+
}
46+
if (!schema.is_object()) {
47+
return false;
48+
}
49+
}
50+
if (!schema.contains("type")) {
51+
schema["type"] = "object";
52+
}
53+
if (!schema.contains("properties") || !schema["properties"].is_object()) {
54+
schema["properties"] = nlohmann::json::object();
55+
}
56+
nlohmann::json& properties = schema["properties"];
57+
if (!properties.contains(field) || !properties[field].is_object()) {
58+
properties[field] = nlohmann::json::object();
59+
}
60+
properties[field][normalized] = value;
61+
descriptor.input_schema = schema.dump();
62+
return true;
63+
}
64+
65+
// 等价于 SetFieldHint(descriptor, field, "x-widget", widget)。
66+
inline bool SetFieldWidget(FunctionDescriptor& descriptor, const std::string& field,
67+
const std::string& widget) {
68+
if (widget.empty()) {
69+
return false;
70+
}
71+
return SetFieldHint(descriptor, field, "x-widget", nlohmann::json(widget));
72+
}
73+
74+
} // namespace croupier::sdk

sdks/cpp/src/croupier_client.cpp

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@
2929
#include <unordered_map>
3030

3131
#include <nlohmann/json.hpp>
32+
#include <zlib.h>
33+
34+
#include "croupier/agent/v1/register.pb.h"
3235

3336
// Logging macros with configuration support
3437
// These check the global logger configuration before outputting
@@ -558,6 +561,10 @@ class CroupierClient::Impl {
558561
last_error_.clear();
559562
startHeartbeatLoop();
560563
SDK_LOG_INFO("Connected to agent at " + NormalizeTCPAddress(config_.agent_addr));
564+
565+
// 控制面 manifest 上传(best-effort,不阻断注册结果)
566+
maybeRegisterCapabilities();
567+
561568
return true;
562569
} catch (const std::exception& e) {
563570
last_error_ = e.what();
@@ -626,6 +633,96 @@ class CroupierClient::Impl {
626633
}
627634
}
628635

636+
// 构建控制面能力清单(provider 元数据 + 函数摘要),与 JS/C#/Go 同构。
637+
std::string buildManifestJson() const {
638+
std::string json = "{\"provider\":{";
639+
json += "\"id\":\"" + EscapeJsonString(
640+
config_.service_id.empty() ? std::string("cpp-service") : config_.service_id) + "\",";
641+
json += "\"version\":\"" + EscapeJsonString(
642+
config_.service_version.empty() ? std::string("1.0.0") : config_.service_version) + "\",";
643+
json += "\"lang\":\"" + EscapeJsonString(
644+
config_.provider_lang.empty() ? std::string("cpp") : config_.provider_lang) + "\",";
645+
json += "\"sdk\":\"" + EscapeJsonString(
646+
config_.provider_sdk.empty() ? std::string("croupier-cpp-sdk") : config_.provider_sdk) + "\"}";
647+
json += ",\"functions\":[";
648+
bool first = true;
649+
for (const auto& [function_id, desc] : descriptors_) {
650+
if (function_id.empty()) continue;
651+
if (!first) json += ",";
652+
first = false;
653+
json += "{\"id\":\"" + EscapeJsonString(function_id) + "\"";
654+
json += ",\"version\":\"" + EscapeJsonString(desc.version.empty() ? std::string("1.0.0") : desc.version) + "\"";
655+
if (!desc.resource.empty()) json += ",\"resource\":\"" + EscapeJsonString(desc.resource) + "\"";
656+
if (!desc.operation.empty()) json += ",\"operation\":\"" + EscapeJsonString(desc.operation) + "\"";
657+
if (!desc.risk.empty()) json += ",\"risk\":\"" + EscapeJsonString(desc.risk) + "\"";
658+
if (!desc.permission.empty()) json += ",\"permission\":\"" + EscapeJsonString(desc.permission) + "\"";
659+
if (!desc.description.empty()) json += ",\"description\":\"" + EscapeJsonString(desc.description) + "\"";
660+
if (!desc.input_schema.empty()) json += ",\"inputSchema\":" + desc.input_schema;
661+
if (!desc.output_schema.empty()) json += ",\"outputSchema\":" + desc.output_schema;
662+
json += "}";
663+
}
664+
json += "]}";
665+
return json;
666+
}
667+
668+
// gzip 压缩(RFC 1952 envelope):zlib deflate + windowBits 15+16。
669+
static std::vector<uint8_t> GzipCompress(const std::string& data) {
670+
std::vector<uint8_t> out;
671+
z_stream stream{};
672+
if (deflateInit2(&stream, Z_DEFAULT_COMPRESSION, Z_DEFLATED, 15 + 16, 8, Z_DEFAULT_STRATEGY) != Z_OK) {
673+
throw std::runtime_error("gzip init failed");
674+
}
675+
stream.next_in = reinterpret_cast<Bytef*>(const_cast<char*>(data.data()));
676+
stream.avail_in = static_cast<uInt>(data.size());
677+
char buffer[4096];
678+
int status = Z_OK;
679+
do {
680+
stream.next_out = reinterpret_cast<Bytef*>(buffer);
681+
stream.avail_out = sizeof(buffer);
682+
status = deflate(&stream, Z_FINISH);
683+
if (status != Z_OK && status != Z_STREAM_END && status != Z_BUF_ERROR) {
684+
deflateEnd(&stream);
685+
throw std::runtime_error("gzip deflate failed");
686+
}
687+
out.insert(out.end(), buffer, buffer + (sizeof(buffer) - stream.avail_out));
688+
} while (status != Z_STREAM_END);
689+
deflateEnd(&stream);
690+
return out;
691+
}
692+
693+
// 向控制面(config_.control_addr)上传能力清单。
694+
// 独立短连接 + 超时;任何失败仅告警,不影响已完成的函数注册。
695+
void maybeRegisterCapabilities() {
696+
if (config_.control_addr.empty()) {
697+
return;
698+
}
699+
try {
700+
const auto address = ParseTCPAddress(config_.control_addr);
701+
TCPTransport control(address.host, address.port, 5000);
702+
control.Connect();
703+
704+
::croupier::agent::v1::ProviderMeta meta;
705+
meta.set_id(config_.service_id.empty() ? std::string("cpp-service") : config_.service_id);
706+
meta.set_version(config_.service_version.empty() ? std::string("1.0.0") : config_.service_version);
707+
meta.set_lang(config_.provider_lang.empty() ? std::string("cpp") : config_.provider_lang);
708+
meta.set_sdk(config_.provider_sdk.empty() ? std::string("croupier-cpp-sdk") : config_.provider_sdk);
709+
::croupier::agent::v1::RegisterCapabilitiesRequest request;
710+
*request.mutable_provider() = meta;
711+
const std::string manifest = buildManifestJson();
712+
const auto manifest_gz = GzipCompress(manifest);
713+
request.set_manifest_json_gz(manifest_gz.data(), manifest_gz.size());
714+
715+
const auto body = SerializeMessage(request);
716+
const auto [resp_msg, resp_data] = control.Call(protocol::MSG_REGISTER_CAPABILITIES_REQ, body);
717+
(void)resp_msg;
718+
(void)resp_data;
719+
control.Close();
720+
SDK_LOG_INFO("Capabilities registered to control plane: " + config_.control_addr);
721+
} catch (const std::exception& e) {
722+
SDK_LOG_WARN(std::string("Failed to register capabilities: ") + e.what());
723+
}
724+
}
725+
629726
std::string registerWithAgent(TCPTransport& transport) {
630727
::croupier::sdk::v1::ProviderConnectRequest request;
631728
request.set_service_id(config_.service_id);
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
// F:SetFieldHint/SetFieldWidget(x-ui 呈现契约便捷层)单测。
2+
3+
#include "croupier/sdk/field_hints.h"
4+
5+
#include <gtest/gtest.h>
6+
7+
namespace croupier::sdk::test {
8+
9+
TEST(FieldHintsTest, EmptySchemaCreatesObjectSkeleton) {
10+
FunctionDescriptor desc;
11+
ASSERT_TRUE(SetFieldWidget(desc, "id", "Select"));
12+
auto schema = nlohmann::json::parse(desc.input_schema);
13+
EXPECT_EQ(schema["type"], "object");
14+
EXPECT_EQ(schema["properties"]["id"]["x-widget"], "Select");
15+
}
16+
17+
TEST(FieldHintsTest, PreservesExistingAttributesAndOverrides) {
18+
FunctionDescriptor desc;
19+
desc.input_schema = R"({"type":"object","properties":{"id":{"type":"string","title":"玩家 ID","x-widget":"Input"}}})";
20+
ASSERT_TRUE(SetFieldWidget(desc, "id", "TreeSelect"));
21+
auto prop = nlohmann::json::parse(desc.input_schema)["properties"]["id"];
22+
EXPECT_EQ(prop["title"], "玩家 ID");
23+
EXPECT_EQ(prop["x-widget"], "TreeSelect");
24+
}
25+
26+
TEST(FieldHintsTest, OptionsSourceObject) {
27+
FunctionDescriptor desc;
28+
ASSERT_TRUE(SetFieldHint(desc, "id", "x-options-source",
29+
nlohmann::json{{"functionId", "player.list"},
30+
{"labelPath", "/items/*/name"},
31+
{"valuePath", "/items/*/id"}}));
32+
auto prop = nlohmann::json::parse(desc.input_schema)["properties"]["id"];
33+
EXPECT_EQ(prop["x-options-source"]["functionId"], "player.list");
34+
EXPECT_EQ(prop["x-options-source"]["labelPath"], "/items/*/name");
35+
}
36+
37+
TEST(FieldHintsTest, XUnderscoreNormalizedToXDash) {
38+
FunctionDescriptor desc;
39+
ASSERT_TRUE(SetFieldHint(desc, "a", "x_widget", "Input"));
40+
auto prop = nlohmann::json::parse(desc.input_schema)["properties"]["a"];
41+
EXPECT_TRUE(prop.contains("x-widget"));
42+
EXPECT_FALSE(prop.contains("x_widget"));
43+
}
44+
45+
TEST(FieldHintsTest, InvalidHintRejected) {
46+
FunctionDescriptor desc;
47+
EXPECT_FALSE(SetFieldHint(desc, "a", "widget", "Input"));
48+
EXPECT_TRUE(desc.input_schema.empty());
49+
}
50+
51+
TEST(FieldHintsTest, EmptyFieldRejected) {
52+
FunctionDescriptor desc;
53+
EXPECT_FALSE(SetFieldHint(desc, "", "x-widget", "Input"));
54+
}
55+
56+
TEST(FieldHintsTest, EmptyWidgetRejected) {
57+
FunctionDescriptor desc;
58+
EXPECT_FALSE(SetFieldWidget(desc, "a", ""));
59+
}
60+
61+
TEST(FieldHintsTest, InvalidExistingSchemaRejected) {
62+
FunctionDescriptor desc;
63+
desc.input_schema = "not-json";
64+
EXPECT_FALSE(SetFieldHint(desc, "a", "x-widget", "Input"));
65+
}
66+
67+
TEST(FieldHintsTest, ShortHintRejected) {
68+
FunctionDescriptor desc;
69+
EXPECT_FALSE(SetFieldHint(desc, "a", "x-", "Input"));
70+
}
71+
72+
} // namespace croupier::sdk::test

sdks/cpp/vcpkg.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@
1414
},
1515
{
1616
"name": "curl"
17+
},
18+
{
19+
"name": "zlib"
1720
}
1821
],
1922
"overrides": [

0 commit comments

Comments
 (0)