From f6cb2f702b6491f18aee607f0027e897b6d142a8 Mon Sep 17 00:00:00 2001 From: Stephen Berry Date: Thu, 12 Mar 2026 20:20:25 -0500 Subject: [PATCH 1/2] OpenRPC Support --- include/glaze/json/schema.hpp | 2 +- include/glaze/rpc/openrpc.hpp | 51 +++ include/glaze/rpc/registry.hpp | 133 +++++++- tests/networking_tests/CMakeLists.txt | 1 + .../openrpc_test/CMakeLists.txt | 7 + .../openrpc_test/openrpc_test.cpp | 302 ++++++++++++++++++ 6 files changed, 491 insertions(+), 5 deletions(-) create mode 100644 include/glaze/rpc/openrpc.hpp create mode 100644 tests/networking_tests/openrpc_test/CMakeLists.txt create mode 100644 tests/networking_tests/openrpc_test/openrpc_test.cpp diff --git a/include/glaze/json/schema.hpp b/include/glaze/json/schema.hpp index 73d969d226..57d70d4d46 100644 --- a/include/glaze/json/schema.hpp +++ b/include/glaze/json/schema.hpp @@ -514,7 +514,7 @@ namespace glz } }; - template + template struct to_json_schema { template diff --git a/include/glaze/rpc/openrpc.hpp b/include/glaze/rpc/openrpc.hpp new file mode 100644 index 0000000000..2a9f7bd8a8 --- /dev/null +++ b/include/glaze/rpc/openrpc.hpp @@ -0,0 +1,51 @@ +// Glaze Library +// For the license information refer to glaze.hpp + +#pragma once + +#include +#include + +#include "glaze/core/common.hpp" + +namespace glz +{ + struct openrpc_info + { + std::string title = "API"; + std::string version = "1.0.0"; + std::optional description{}; + }; + + struct openrpc_content_descriptor + { + std::string name{}; + std::optional description{}; + raw_json schema{"{}"}; // Pre-serialized JSON Schema + std::optional required{}; + }; + + struct openrpc_method + { + std::string name{}; + std::optional description{}; + std::vector params{}; + std::optional result{}; + }; + + struct open_rpc + { + std::string openrpc = "1.3.2"; + openrpc_info info{}; + std::vector methods{}; + }; + + // Metadata captured at registration time for generating OpenRPC spec + struct method_metadata + { + std::string name{}; + std::string params_schema{}; // JSON Schema for input type (empty if no params) + std::string result_schema{}; // JSON Schema for output type (empty if void) + bool params_required{false}; // true for functions that require params + }; +} diff --git a/include/glaze/rpc/registry.hpp b/include/glaze/rpc/registry.hpp index c84f23c617..1851d7b234 100644 --- a/include/glaze/rpc/registry.hpp +++ b/include/glaze/rpc/registry.hpp @@ -4,6 +4,8 @@ #pragma once #include "glaze/glaze.hpp" +#include "glaze/json/schema.hpp" +#include "glaze/rpc/openrpc.hpp" #include "glaze/rpc/repe/buffer.hpp" #include "glaze/rpc/repe/repe.hpp" @@ -104,9 +106,28 @@ namespace glz typename protocol_storage::type endpoints{}; - void clear() { endpoints.clear(); } + openrpc_info open_rpc_info{}; + + void clear() + { + endpoints.clear(); + methods_metadata.clear(); + } private: + std::vector methods_metadata{}; + + // Helper to generate JSON schema string for a type + template + static std::string generate_schema() + { + auto result = write_json_schema>(); + if (result) { + return std::move(*result); + } + return "{}"; + } + // Helper to register all members of a type without registering the root endpoint template requires(glaze_object_t || reflectable) @@ -152,6 +173,12 @@ namespace glz if constexpr (std::is_invocable_v) { using Result = std::decay_t>; impl::template register_function_endpoint(full_key, func, *this); + if constexpr (std::same_as) { + methods_metadata.push_back({std::string(full_key), {}, {}, false}); + } + else { + methods_metadata.push_back({std::string(full_key), {}, generate_schema(), false}); + } } else if constexpr (is_invocable_concrete>) { using Tuple = invocable_args_t>; @@ -161,6 +188,15 @@ namespace glz using Params = glz::tuple_element_t<0, Tuple>; impl::template register_param_function_endpoint(full_key, func, *this); + using Result = std::invoke_result_t, Params>; + if constexpr (std::same_as, void>) { + methods_metadata.push_back( + {std::string(full_key), generate_schema(), {}, true}); + } + else { + methods_metadata.push_back( + {std::string(full_key), generate_schema(), generate_schema>(), true}); + } } else if constexpr (is_function_ptr_invocable>) { // Handle function pointers with arguments (e.g., void(*)(int)) @@ -171,15 +207,26 @@ namespace glz using Params = glz::tuple_element_t<0, Tuple>; impl::template register_param_function_endpoint(full_key, func, *this); + using Result = std::invoke_result_t, Params>; + if constexpr (std::same_as, void>) { + methods_metadata.push_back( + {std::string(full_key), generate_schema(), {}, true}); + } + else { + methods_metadata.push_back( + {std::string(full_key), generate_schema(), generate_schema>(), true}); + } } else if constexpr (std::is_pointer_v> && (glaze_object_t>> || reflectable>>)) { // Handle pointer members explicitly for RPC traversal if (func) { // Only traverse if pointer is valid - on>, full_key>(*func); - impl::template register_object_endpoint>>( - full_key, *func, *this); + using ObjType = std::remove_pointer_t>; + on(*func); + impl::template register_object_endpoint(full_key, *func, *this); + auto schema = generate_schema(); + methods_metadata.push_back({std::string(full_key), schema, schema, false}); } // else: skip registration for null pointers - no endpoints created } @@ -187,10 +234,18 @@ namespace glz on, full_key>(func); impl::template register_object_endpoint>(full_key, func, *this); + { + auto schema = generate_schema>(); + methods_metadata.push_back({std::string(full_key), schema, schema, false}); + } } else if constexpr (not std::is_lvalue_reference_v) { // For glz::custom, glz::manage, etc. impl::template register_value_endpoint>(full_key, func, *this); + { + auto schema = generate_schema>(); + methods_metadata.push_back({std::string(full_key), schema, schema, false}); + } } else { static_assert(std::is_lvalue_reference_v); @@ -203,11 +258,14 @@ namespace glz if constexpr (std::is_void_v) { if constexpr (n_args == 0) { impl::template register_member_function_endpoint(full_key, value, func, *this); + methods_metadata.push_back({std::string(full_key), {}, {}, false}); } else if constexpr (n_args == 1) { using Input = std::decay_t>; impl::template register_member_function_with_params_endpoint(full_key, value, func, *this); + methods_metadata.push_back( + {std::string(full_key), generate_schema(), {}, true}); } else { static_assert(false_v, "function cannot have more than one input"); @@ -217,11 +275,15 @@ namespace glz // Member function pointers if constexpr (n_args == 0) { impl::template register_member_function_endpoint(full_key, value, func, *this); + methods_metadata.push_back( + {std::string(full_key), {}, generate_schema(), false}); } else if constexpr (n_args == 1) { using Input = std::decay_t>; impl::template register_member_function_with_params_endpoint(full_key, value, func, *this); + methods_metadata.push_back( + {std::string(full_key), generate_schema(), generate_schema(), true}); } else { static_assert(false_v, "function cannot have more than one input"); @@ -232,6 +294,10 @@ namespace glz // this is a variable and not a function, so we build RPC read/write calls // We can't remove const here, because const fields need to be able to be written impl::template register_variable_endpoint>(full_key, func, *this); + { + auto schema = generate_schema>(); + methods_metadata.push_back({std::string(full_key), schema, schema, false}); + } } } }); @@ -247,6 +313,8 @@ namespace glz if constexpr (parent == root && (glaze_object_t || reflectable)) { impl::register_endpoint(root, value, *this); + auto schema = generate_schema(); + methods_metadata.push_back({std::string(root), schema, schema, false}); } register_members(value); @@ -270,6 +338,63 @@ namespace glz }); } + /// Generate the OpenRPC specification document from registered endpoints + open_rpc open_rpc_spec() const + { + open_rpc doc; + doc.info = open_rpc_info; + doc.methods.reserve(methods_metadata.size()); + + for (const auto& meta : methods_metadata) { + openrpc_method method; + method.name = meta.name; + + if (!meta.params_schema.empty()) { + openrpc_content_descriptor param; + param.name = "params"; + param.schema = raw_json{meta.params_schema}; + if (meta.params_required) { + param.required = true; + } + method.params.push_back(std::move(param)); + } + + if (!meta.result_schema.empty()) { + openrpc_content_descriptor result; + result.name = "result"; + result.schema = raw_json{meta.result_schema}; + method.result = std::move(result); + } + + doc.methods.push_back(std::move(method)); + } + + return doc; + } + + /// Register a /open_rpc endpoint that returns the OpenRPC specification + void register_open_rpc() + { + if constexpr (Proto == REPE) { + endpoints["/open_rpc"] = [this](repe::state_view& state) { + if (state.notify()) { + return; + } + auto spec = open_rpc_spec(); + repe::write_response(spec, state); + }; + } + else if constexpr (Proto == JSONRPC) { + endpoints["/open_rpc"] = [this](jsonrpc::state&& state) { + if (state.notify()) { + return; + } + auto spec = open_rpc_spec(); + jsonrpc::write_response(spec, state); + }; + } + } + /// Message-based call for REPE protocol (deprecated) /// @deprecated Use the zero-copy span-based overload instead: /// `call(std::span, std::string&)` diff --git a/tests/networking_tests/CMakeLists.txt b/tests/networking_tests/CMakeLists.txt index 8ffabfe461..df1c8e36e7 100644 --- a/tests/networking_tests/CMakeLists.txt +++ b/tests/networking_tests/CMakeLists.txt @@ -21,6 +21,7 @@ if(glaze_BUILD_SSL_TESTS) endif() add_subdirectory(jsonrpc_registry_test) add_subdirectory(openapi_test) +add_subdirectory(openrpc_test) add_subdirectory(repe_buffer_test) add_subdirectory(repe_plugin_test) add_subdirectory(repe_test) diff --git a/tests/networking_tests/openrpc_test/CMakeLists.txt b/tests/networking_tests/openrpc_test/CMakeLists.txt new file mode 100644 index 0000000000..ee66202ebd --- /dev/null +++ b/tests/networking_tests/openrpc_test/CMakeLists.txt @@ -0,0 +1,7 @@ +project(openrpc_test) + +add_executable(${PROJECT_NAME} ${PROJECT_NAME}.cpp) + +target_link_libraries(${PROJECT_NAME} PRIVATE glz_test_exceptions) + +add_test(NAME ${PROJECT_NAME} COMMAND ${PROJECT_NAME}) diff --git a/tests/networking_tests/openrpc_test/openrpc_test.cpp b/tests/networking_tests/openrpc_test/openrpc_test.cpp new file mode 100644 index 0000000000..2fe652ddca --- /dev/null +++ b/tests/networking_tests/openrpc_test/openrpc_test.cpp @@ -0,0 +1,302 @@ +// Glaze Library +// For the license information refer to glaze.hpp + +#include +#include + +#include "glaze/glaze.hpp" +#include "glaze/rpc/registry.hpp" +#include "glaze/rpc/repe/buffer.hpp" +#include "ut/ut.hpp" + +using namespace ut; + +namespace repe = glz::repe; + +// ============================================================ +// Test helpers for span-based API +// ============================================================ +namespace test_helpers +{ + namespace detail + { + inline std::string& request_buffer() + { + thread_local std::string buf; + return buf; + } + + inline std::string& response_buffer() + { + thread_local std::string buf; + return buf; + } + + inline repe::message& request_message() + { + thread_local repe::message msg; + msg.query.clear(); + msg.body.clear(); + return msg; + } + } + + template + inline repe::message call(Registry& registry, repe::message& request) + { + auto& request_buffer = detail::request_buffer(); + auto& response_buffer = detail::response_buffer(); + + repe::to_buffer(request, request_buffer); + registry.call(std::span{request_buffer}, response_buffer); + + repe::message response{}; + repe::from_buffer(response_buffer, response); + return response; + } + + template + inline repe::message call_json(Registry& registry, const repe::user_header& hdr) + { + auto& request = detail::request_message(); + repe::request_json(hdr, request); + return call(registry, request); + } + + template + inline repe::message call_json(Registry& registry, const repe::user_header& hdr, Value&& value) + { + auto& request = detail::request_message(); + repe::request_json(hdr, request, std::forward(value)); + return call(registry, request); + } +} + +// ============================================================ +// Test types +// ============================================================ + +struct my_api_t +{ + int count{}; + std::string name{"default"}; + std::function get_count = [this] { return count; }; + std::function set_count = [this](int v) { count = v; }; + std::function&)> max_value = [](std::vector& vec) { + return (std::ranges::max)(vec); + }; +}; + +struct nested_t +{ + my_api_t api{}; + std::string label{}; +}; + +// ============================================================ +// Tests +// ============================================================ + +suite open_rpc_spec_generation = [] { + "open_rpc_spec_basic"_test = [] { + glz::registry server{}; + server.open_rpc_info.title = "My API"; + server.open_rpc_info.version = "2.0.0"; + server.open_rpc_info.description = "Test API"; + + my_api_t api{}; + server.on(api); + + auto spec = server.open_rpc_spec(); + + expect(spec.openrpc == "1.3.2"); + expect(spec.info.title == "My API"); + expect(spec.info.version == "2.0.0"); + expect(spec.info.description == "Test API"); + expect(!spec.methods.empty()); + + // Check that we have methods for: root, /count, /name, /get_count, /set_count, /max_value + std::set method_names; + for (const auto& m : spec.methods) { + method_names.insert(m.name); + } + + expect(method_names.contains("")); // root endpoint + expect(method_names.contains("/count")); + expect(method_names.contains("/name")); + expect(method_names.contains("/get_count")); + expect(method_names.contains("/set_count")); + expect(method_names.contains("/max_value")); + }; + + "open_rpc_spec_function_metadata"_test = [] { + glz::registry server{}; + + my_api_t api{}; + server.on(api); + + auto spec = server.open_rpc_spec(); + + // Find /get_count - no-param function returning int + auto it = std::find_if(spec.methods.begin(), spec.methods.end(), + [](const auto& m) { return m.name == "/get_count"; }); + expect(it != spec.methods.end()); + expect(it->params.empty()); // no params + expect(it->result.has_value()); // has result + expect(it->result->name == "result"); + expect(it->result->schema.str.find("integer") != std::string::npos); // int schema contains "integer" + + // Find /set_count - function with int param, void return + it = std::find_if(spec.methods.begin(), spec.methods.end(), + [](const auto& m) { return m.name == "/set_count"; }); + expect(it != spec.methods.end()); + expect(it->params.size() == 1u); + expect(it->params[0].name == "params"); + expect(it->params[0].required.has_value()); + expect(it->params[0].required.value() == true); + expect(!it->result.has_value()); // void return + + // Find /max_value - function with vector param, double return + it = std::find_if(spec.methods.begin(), spec.methods.end(), + [](const auto& m) { return m.name == "/max_value"; }); + expect(it != spec.methods.end()); + expect(it->params.size() == 1u); + expect(it->params[0].required.has_value()); + expect(it->params[0].required.value() == true); + expect(it->result.has_value()); + expect(it->result->schema.str.find("number") != std::string::npos); // double schema contains "number" + }; + + "open_rpc_spec_variable_metadata"_test = [] { + glz::registry server{}; + + my_api_t api{}; + server.on(api); + + auto spec = server.open_rpc_spec(); + + // Find /count - int variable (read/write) + auto it = std::find_if(spec.methods.begin(), spec.methods.end(), + [](const auto& m) { return m.name == "/count"; }); + expect(it != spec.methods.end()); + // Variables have params (for write) and result (for read) + expect(it->params.size() == 1u); + expect(!it->params[0].required.has_value()); // params not required (read doesn't need them) + expect(it->result.has_value()); + expect(it->result->schema.str.find("integer") != std::string::npos); + + // Find /name - string variable + it = std::find_if(spec.methods.begin(), spec.methods.end(), + [](const auto& m) { return m.name == "/name"; }); + expect(it != spec.methods.end()); + expect(it->params.size() == 1u); + expect(it->result.has_value()); + expect(it->result->schema.str.find("string") != std::string::npos); + }; + + "open_rpc_spec_serialization"_test = [] { + glz::registry server{}; + server.open_rpc_info.title = "Test API"; + server.open_rpc_info.version = "1.0.0"; + + my_api_t api{}; + server.on(api); + + auto spec = server.open_rpc_spec(); + + // Serialize to JSON + auto json = glz::write_json(spec); + expect(json.has_value()); + + // Verify it's valid JSON containing expected fields + auto& str = *json; + expect(str.find("\"openrpc\":\"1.3.2\"") != std::string::npos); + expect(str.find("\"title\":\"Test API\"") != std::string::npos); + expect(str.find("\"methods\"") != std::string::npos); + expect(str.find("/get_count") != std::string::npos); + expect(str.find("/max_value") != std::string::npos); + }; + + "open_rpc_endpoint_repe"_test = [] { + using namespace test_helpers; + + glz::registry server{}; + server.open_rpc_info.title = "REPE API"; + + my_api_t api{}; + server.on(api); + server.register_open_rpc(); + + // Call the /open_rpc endpoint + auto response = call_json(server, repe::user_header{.query = "/open_rpc"}); + expect(response.header.ec == glz::error_code::none); + + // Parse the response body as OpenRPC + auto spec = glz::read_json(response.body); + expect(spec.has_value()) << "Failed to parse OpenRPC response: " << response.body; + + if (spec) { + expect(spec->openrpc == "1.3.2"); + expect(spec->info.title == "REPE API"); + expect(!spec->methods.empty()); + } + }; + + "open_rpc_endpoint_jsonrpc"_test = [] { + glz::registry server{}; + server.open_rpc_info.title = "JSONRPC API"; + + my_api_t api{}; + server.on(api); + server.register_open_rpc(); + + // Call via JSON-RPC + auto response = + server.call(R"({"jsonrpc":"2.0","method":"/open_rpc","id":1})"); + + expect(!response.empty()); + expect(response.find("\"result\"") != std::string::npos); + expect(response.find("1.3.2") != std::string::npos); + expect(response.find("JSONRPC API") != std::string::npos); + }; + + "open_rpc_nested_objects"_test = [] { + glz::registry server{}; + + nested_t nested{}; + server.on(nested); + + auto spec = server.open_rpc_spec(); + + std::set method_names; + for (const auto& m : spec.methods) { + method_names.insert(m.name); + } + + // Root and nested object endpoints should be present + expect(method_names.contains("")); + expect(method_names.contains("/api")); + // Leaf endpoints within the nested object + expect(method_names.contains("/api/count")); + expect(method_names.contains("/api/get_count")); + // Top-level leaf + expect(method_names.contains("/label")); + }; + + "open_rpc_clear"_test = [] { + glz::registry server{}; + + my_api_t api{}; + server.on(api); + + auto spec1 = server.open_rpc_spec(); + expect(!spec1.methods.empty()); + + server.clear(); + + auto spec2 = server.open_rpc_spec(); + expect(spec2.methods.empty()); + }; +}; + +int main() {} From c82b351dad72117b9ceedc97eff973214c9b22ac Mon Sep 17 00:00:00 2001 From: Stephen Berry Date: Thu, 12 Mar 2026 20:43:14 -0500 Subject: [PATCH 2/2] openrpc documentation --- .gitignore | 1 + docs/rpc/openrpc.md | 145 ++++++++++++++++++++++++++++++++++++++++++++ mkdocs.yml | 1 + 3 files changed, 147 insertions(+) create mode 100644 docs/rpc/openrpc.md diff --git a/.gitignore b/.gitignore index 81386002f0..3b32b9adff 100644 --- a/.gitignore +++ b/.gitignore @@ -62,3 +62,4 @@ pr_description.md /size_tests /tmp_benchmark /exterior +pr.md diff --git a/docs/rpc/openrpc.md b/docs/rpc/openrpc.md new file mode 100644 index 0000000000..d479de42b9 --- /dev/null +++ b/docs/rpc/openrpc.md @@ -0,0 +1,145 @@ +# OpenRPC + +Glaze can generate [OpenRPC 1.3.2](https://spec.open-rpc.org/) specification documents from a `glz::registry`, providing machine-readable API discovery for RPC services. + +- [OpenRPC specification](https://spec.open-rpc.org/) + +The OpenRPC spec is generated from the same type information that Glaze uses for serialization, so the spec always matches the actual API. + +## Generating a Spec + +Set API metadata on the registry's `open_rpc_info` member, then call `open_rpc_spec()` to get the document: + +```cpp +#include "glaze/rpc/registry.hpp" + +struct my_api_t +{ + int count{}; + std::string name{"default"}; + std::function get_count = [this] { return count; }; + std::function set_count = [this](int v) { count = v; }; + std::function&)> max_value = [](std::vector& vec) { + return (std::ranges::max)(vec); + }; +}; + +glz::registry server{}; +server.open_rpc_info.title = "My API"; +server.open_rpc_info.version = "1.0.0"; +server.open_rpc_info.description = "Example service"; + +my_api_t api{}; +server.on(api); + +auto spec = server.open_rpc_spec(); // returns glz::open_rpc +auto json = glz::write(spec); +``` + +This produces: + +```json +{ + "openrpc": "1.3.2", + "info": { + "title": "My API", + "version": "1.0.0", + "description": "Example service" + }, + "methods": [ + { + "name": "/count", + "params": [{ "name": "params", "schema": { "type": ["integer"], ... } }], + "result": { "name": "result", "schema": { "type": ["integer"], ... } } + }, + { + "name": "/get_count", + "params": [], + "result": { "name": "result", "schema": { "type": ["integer"], ... } } + }, + { + "name": "/set_count", + "params": [{ "name": "params", "schema": { "type": ["integer"], ... }, "required": true }] + }, + ... + ] +} +``` + +## Serving the Spec via an Endpoint + +Call `register_open_rpc()` to add a `/open_rpc` endpoint that serves the spec directly: + +```cpp +server.register_open_rpc(); +``` + +This works with both REPE and JSON-RPC protocols: + +```cpp +// REPE +glz::registry<> repe_server{}; +repe_server.on(api); +repe_server.register_open_rpc(); +// Query /open_rpc via REPE to get the spec + +// JSON-RPC +glz::registry jsonrpc_server{}; +jsonrpc_server.on(api); +jsonrpc_server.register_open_rpc(); +auto response = jsonrpc_server.call(R"({"jsonrpc":"2.0","method":"/open_rpc","id":1})"); +// response contains the OpenRPC spec in the "result" field +``` + +## Method Mapping + +Every registered endpoint appears as a method in the spec. JSON Schema descriptions are generated for parameter and result types using Glaze's `write_json_schema` infrastructure. + +| Endpoint Type | Params | Result | Params Required | +|---------------|--------|--------|-----------------| +| Variable (`int`, `std::string`, ...) | Type schema | Type schema | No | +| No-param function | Empty | Return type schema | — | +| Function with params | Param type schema | Return type schema | Yes | +| Void function with params | Param type schema | None | Yes | +| Object (nested struct) | Object schema | Object schema | No | +| Root endpoint | Object schema | Object schema | No | + +For variables and objects, params are not marked as required because reading (no body) and writing (with body) use the same endpoint path. + +## Data Structures + +The OpenRPC types are defined in `glaze/rpc/openrpc.hpp`: + +```cpp +struct openrpc_info { + std::string title = "API"; + std::string version = "1.0.0"; + std::optional description{}; +}; + +struct openrpc_content_descriptor { + std::string name{}; + std::optional description{}; + raw_json schema{"{}"}; // Pre-serialized JSON Schema + std::optional required{}; +}; + +struct openrpc_method { + std::string name{}; + std::optional description{}; + std::vector params{}; + std::optional result{}; +}; + +struct open_rpc { + std::string openrpc = "1.3.2"; + openrpc_info info{}; + std::vector methods{}; +}; +``` + +## See Also + +- [REPE RPC](repe-rpc.md) - REPE protocol and registry +- [JSON-RPC 2.0 Registry](jsonrpc-registry.md) - JSON-RPC protocol registry +- [JSON Schema](../json-schema.md) - Schema generation used for OpenRPC method descriptions diff --git a/mkdocs.yml b/mkdocs.yml index 7d082792d2..a0bab8e4ed 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -132,6 +132,7 @@ nav: - JSON-RPC 2.0 Registry: rpc/jsonrpc-registry.md - JSON-RPC 2.0 Client/Server: rpc/json-rpc.md - REPE to JSON-RPC: rpc/repe-jsonrpc-conversion.md + - OpenRPC: rpc/openrpc.md - Interface Utilities: - JSON Include: json-include.md - CLI Menu: cli-menu.md