From 672dd42d1402d14caff44ea7df269258afa4e865 Mon Sep 17 00:00:00 2001 From: will Date: Tue, 10 Mar 2026 10:53:41 +0000 Subject: [PATCH] rpc: add getopenrpcinfo command Expose the generated OpenRPC document through RPC so clients can inspect the interface supported by the running node. The command can optionally include hidden RPC commands and arguments for complete code generation and regtest coverage. --- src/rpc/client.cpp | 1 + src/rpc/server.cpp | 415 +++++++++++++++++++++++++++++++++++++++++ src/rpc/server.h | 2 + src/test/fuzz/rpc.cpp | 1 + src/test/rpc_tests.cpp | 33 ++++ 5 files changed, 452 insertions(+) diff --git a/src/rpc/client.cpp b/src/rpc/client.cpp index a28543fb3bb..803c96e8a99 100644 --- a/src/rpc/client.cpp +++ b/src/rpc/client.cpp @@ -335,6 +335,7 @@ static const CRPCConvertParam vRPCConvertParams[] = { "logging", 0, "include" }, { "logging", 1, "exclude" }, { "disconnectnode", 1, "nodeid" }, + { "getopenrpcinfo", 0, "show_hidden" }, { "gethdkeys", 0, "active_only" }, { "gethdkeys", 0, "options" }, { "gethdkeys", 0, "private" }, diff --git a/src/rpc/server.cpp b/src/rpc/server.cpp index b551c467ddb..ead9733d0dc 100644 --- a/src/rpc/server.cpp +++ b/src/rpc/server.cpp @@ -26,8 +26,11 @@ #include #include #include +#include #include +#include #include +#include using util::SplitString; @@ -235,8 +238,338 @@ static RPCMethod getrpcinfo() }; } +namespace { +UniValue OpenRPCArgSchema(const RPCArg& arg, bool include_hidden); +UniValue OpenRPCResultSchema(const RPCResult& result); + +UniValue MakeObject(std::initializer_list> entries) +{ + UniValue obj{UniValue::VOBJ}; + for (const auto& [key, value] : entries) { + obj.pushKV(key, value); + } + return obj; +} + +void PushUniqueSchema(UniValue& schemas, std::unordered_set& seen, UniValue schema) +{ + const std::string serialized{schema.write()}; + if (seen.insert(serialized).second) schemas.push_back(std::move(schema)); +} + +// NOLINTNEXTLINE(misc-no-recursion) +UniValue DedupArrayItemsSchema(std::span inner, bool include_hidden) +{ + if (inner.empty()) return UniValue{UniValue::VOBJ}; + if (inner.size() == 1) return OpenRPCArgSchema(inner.front(), include_hidden); + + UniValue one_of{UniValue::VARR}; + std::unordered_set seen; + for (const auto& item : inner) { + PushUniqueSchema(one_of, seen, OpenRPCArgSchema(item, include_hidden)); + } + + if (one_of.size() == 1) return one_of[0]; + + UniValue items{UniValue::VOBJ}; + items.pushKV("oneOf", std::move(one_of)); + return items; +} + +// NOLINTNEXTLINE(misc-no-recursion) +UniValue DedupArrayItemsSchema(std::span inner) +{ + if (inner.empty()) return UniValue{UniValue::VOBJ}; + if (inner.size() == 1) return OpenRPCResultSchema(inner.front()); + + UniValue one_of{UniValue::VARR}; + std::unordered_set seen; + for (const auto& item : inner) { + PushUniqueSchema(one_of, seen, OpenRPCResultSchema(item)); + } + + if (one_of.size() == 1) return one_of[0]; + + UniValue items{UniValue::VOBJ}; + items.pushKV("oneOf", std::move(one_of)); + return items; +} + +void ApplyTypeStrOverride(UniValue& schema, const RPCArg& arg) +{ + if (arg.m_opts.type_str.size() != 2) return; + const std::string& type_label{arg.m_opts.type_str[1]}; + if (type_label.empty()) return; + + static const std::unordered_set number_or_string{ + "integer / string", + "string or numeric", + }; + if (number_or_string.contains(type_label)) { + UniValue one_of{UniValue::VARR}; + one_of.push_back(MakeObject({{"type", "number"}})); + one_of.push_back(MakeObject({{"type", "string"}})); + schema = UniValue{UniValue::VOBJ}; + schema.pushKV("oneOf", std::move(one_of)); + } else { + schema.pushKV("x-bitcoin-type-override", type_label); + } +} + +void ApplyArgFallback(UniValue& schema, const RPCArg& arg) +{ + if (const auto* def = std::get_if(&arg.m_fallback)) { + schema.pushKV("default", *def); + } else if (const auto* hint = std::get_if(&arg.m_fallback)) { + schema.pushKV("x-bitcoin-default-hint", *hint); + } +} + +// NOLINTNEXTLINE(misc-no-recursion) +UniValue OpenRPCArgSchema(const RPCArg& arg, bool include_hidden) +{ + UniValue schema{UniValue::VOBJ}; + if (arg.m_opts.skip_type_check) { + ApplyTypeStrOverride(schema, arg); + if (schema.empty() && arg.m_type == RPCArg::Type::ARR) { + UniValue one_of{UniValue::VARR}; + one_of.push_back(MakeObject({{"type", "array"}})); + one_of.push_back(MakeObject({{"type", "object"}})); + schema.pushKV("oneOf", std::move(one_of)); + } + ApplyArgFallback(schema, arg); + return schema; + } + + switch (arg.m_type) { + case RPCArg::Type::STR: + schema = MakeObject({{"type", "string"}}); + break; + case RPCArg::Type::STR_HEX: + schema = MakeObject({{"type", "string"}, {"pattern", "^[0-9a-fA-F]+$"}}); + break; + case RPCArg::Type::NUM: + schema = MakeObject({{"type", "number"}}); + break; + case RPCArg::Type::BOOL: + schema = MakeObject({{"type", "boolean"}}); + break; + case RPCArg::Type::AMOUNT: { + UniValue one_of{UniValue::VARR}; + one_of.push_back(MakeObject({{"type", "number"}})); + one_of.push_back(MakeObject({{"type", "string"}})); + schema.pushKV("oneOf", std::move(one_of)); + break; + } + case RPCArg::Type::RANGE: { + UniValue items{UniValue::VARR}; + items.push_back(MakeObject({{"type", "number"}})); + items.push_back(MakeObject({{"type", "number"}})); + UniValue range_schema{UniValue::VOBJ}; + range_schema.pushKV("type", "array"); + range_schema.pushKV("items", std::move(items)); + range_schema.pushKV("additionalItems", false); + range_schema.pushKV("minItems", 2); + range_schema.pushKV("maxItems", 2); + UniValue one_of{UniValue::VARR}; + one_of.push_back(MakeObject({{"type", "number"}})); + one_of.push_back(std::move(range_schema)); + schema.pushKV("oneOf", std::move(one_of)); + break; + } + case RPCArg::Type::ARR: { + UniValue items{DedupArrayItemsSchema(arg.m_inner, include_hidden)}; + schema.pushKV("type", "array"); + schema.pushKV("items", std::move(items)); + break; + } + case RPCArg::Type::OBJ: + case RPCArg::Type::OBJ_NAMED_PARAMS: { + UniValue properties{UniValue::VOBJ}; + UniValue required{UniValue::VARR}; + for (const auto& inner : arg.m_inner) { + if (!include_hidden && inner.m_opts.hidden) continue; + UniValue prop{OpenRPCArgSchema(inner, include_hidden)}; + if (!inner.m_description.empty()) prop.pushKV("description", inner.m_description); + if (inner.m_opts.placeholder) prop.pushKV("x-bitcoin-placeholder", true); + if (inner.m_opts.also_positional) prop.pushKV("x-bitcoin-also-positional", true); + properties.pushKV(inner.GetFirstName(), std::move(prop)); + if (!inner.IsOptional()) required.push_back(inner.GetFirstName()); + } + schema.pushKV("type", "object"); + schema.pushKV("properties", std::move(properties)); + schema.pushKV("additionalProperties", false); + if (!required.empty()) schema.pushKV("required", std::move(required)); + break; + } + case RPCArg::Type::OBJ_USER_KEYS: { + schema.pushKV("type", "object"); + if (!arg.m_inner.empty()) { + schema.pushKV("additionalProperties", OpenRPCArgSchema(arg.m_inner[0], include_hidden)); + } else { + schema.pushKV("additionalProperties", true); + } + break; + } + } // no default case, so the compiler can warn about missing cases + ApplyTypeStrOverride(schema, arg); + ApplyArgFallback(schema, arg); + return schema; +} + +// NOLINTNEXTLINE(misc-no-recursion) +UniValue OpenRPCResultSchema(const RPCResult& result) +{ + if (result.m_opts.skip_type_check) { + RPCResultOptions opts{result.m_opts}; + opts.skip_type_check = false; + if (result.m_type == RPCResult::Type::OBJ) { + UniValue obj_schema{OpenRPCResultSchema(RPCResult{result, std::move(opts)})}; + if (result.m_key_name.empty()) return obj_schema; + + UniValue one_of{UniValue::VARR}; + one_of.push_back(std::move(obj_schema)); + one_of.push_back(MakeObject({{"const", false}})); + UniValue schema{UniValue::VOBJ}; + schema.pushKV("oneOf", std::move(one_of)); + return schema; + } + if (result.m_type == RPCResult::Type::ARR) return OpenRPCResultSchema(RPCResult{result, std::move(opts)}); + return UniValue{UniValue::VOBJ}; + } + + switch (result.m_type) { + case RPCResult::Type::STR: + return MakeObject({{"type", "string"}}); + case RPCResult::Type::STR_AMOUNT: + return MakeObject({{"type", "string"}, {"x-bitcoin-unit", "amount"}}); + case RPCResult::Type::STR_HEX: + return MakeObject({{"type", "string"}, {"pattern", "^[0-9a-fA-F]+$"}}); + case RPCResult::Type::NUM: + return MakeObject({{"type", "number"}}); + case RPCResult::Type::NUM_TIME: { + UniValue schema{UniValue::VOBJ}; + schema.pushKV("type", "number"); + schema.pushKV("x-bitcoin-unit", "unix-time"); + return schema; + } + case RPCResult::Type::BOOL: + return MakeObject({{"type", "boolean"}}); + case RPCResult::Type::NONE: + return MakeObject({{"type", "null"}}); + case RPCResult::Type::ARR: { + UniValue items{DedupArrayItemsSchema(result.m_inner)}; + UniValue schema{UniValue::VOBJ}; + schema.pushKV("type", "array"); + schema.pushKV("items", std::move(items)); + return schema; + } + case RPCResult::Type::ARR_FIXED: { + UniValue items{UniValue::VARR}; + for (const auto& inner : result.m_inner) { + items.push_back(OpenRPCResultSchema(inner)); + } + UniValue schema{UniValue::VOBJ}; + schema.pushKV("type", "array"); + schema.pushKV("items", std::move(items)); + schema.pushKV("additionalItems", false); + schema.pushKV("minItems", uint64_t(result.m_inner.size())); + schema.pushKV("maxItems", uint64_t(result.m_inner.size())); + return schema; + } + case RPCResult::Type::OBJ: { + UniValue properties{UniValue::VOBJ}; + UniValue required{UniValue::VARR}; + for (const auto& inner : result.m_inner) { + if (inner.m_key_name.empty()) continue; + UniValue prop{OpenRPCResultSchema(inner)}; + if (!inner.m_description.empty()) prop.pushKV("description", inner.m_description); + properties.pushKV(inner.m_key_name, std::move(prop)); + if (!inner.m_optional) required.push_back(inner.m_key_name); + } + UniValue schema{UniValue::VOBJ}; + schema.pushKV("type", "object"); + schema.pushKV("properties", std::move(properties)); + schema.pushKV("additionalProperties", false); + if (!required.empty()) schema.pushKV("required", std::move(required)); + return schema; + } + case RPCResult::Type::OBJ_DYN: { + UniValue schema{UniValue::VOBJ}; + schema.pushKV("type", "object"); + if (!result.m_inner.empty()) { + schema.pushKV("additionalProperties", OpenRPCResultSchema(result.m_inner[0])); + } else { + schema.pushKV("additionalProperties", UniValue{UniValue::VOBJ}); + } + return schema; + } + case RPCResult::Type::ANY: + return UniValue{UniValue::VOBJ}; + } // no default case, so the compiler can warn about missing cases + NONFATAL_UNREACHABLE(); +} +} // namespace + +static RPCMethod getopenrpcinfo() +{ + return RPCMethod{ + "getopenrpcinfo", + "Returns an OpenRPC document for currently available RPC commands.\n", + { + {"show_hidden", RPCArg::Type::BOOL, RPCArg::Default{false}, "Also include hidden RPC commands and arguments."}, + }, + RPCResult{ + RPCResult::Type::OBJ, "", "", + { + {RPCResult::Type::STR, "openrpc", "OpenRPC specification version."}, + {RPCResult::Type::OBJ, "info", "Metadata about this JSON-RPC interface.", + { + {RPCResult::Type::STR, "title", "API title."}, + {RPCResult::Type::STR, "version", "Bitcoin Core version string."}, + {RPCResult::Type::STR, "description", "API description."}, + }}, + {RPCResult::Type::ARR, "methods", "Documented RPC methods.", + {{RPCResult::Type::OBJ, "", "An RPC method description object.", + { + {RPCResult::Type::STR, "name", "Method name."}, + {RPCResult::Type::STR, "description", "Method description."}, + {RPCResult::Type::ARR, "params", "Method parameters.", + {{RPCResult::Type::OBJ, "", "A parameter.", + { + {RPCResult::Type::STR, "name", "Parameter name."}, + {RPCResult::Type::BOOL, "required", "Whether the parameter is required."}, + {RPCResult::Type::ANY, "schema", "JSON Schema for the parameter."}, + {RPCResult::Type::STR, "description", /*optional=*/true, "Parameter description."}, + {RPCResult::Type::ARR, "x-bitcoin-aliases", /*optional=*/true, "Alternative parameter names.", + {{RPCResult::Type::STR, "", "An alias."}}}, + {RPCResult::Type::BOOL, "x-bitcoin-placeholder", /*optional=*/true, "Whether the parameter is retained only for compatibility."}, + {RPCResult::Type::BOOL, "x-bitcoin-also-positional", /*optional=*/true, "Whether the parameter can also be passed positionally."}, + }}}}, + {RPCResult::Type::OBJ, "result", "Method result.", + { + {RPCResult::Type::STR, "name", "Result name."}, + {RPCResult::Type::ANY, "schema", "JSON Schema for the result."}, + }}, + {RPCResult::Type::STR, "x-bitcoin-category", "RPC category."}, + }}}}, + }, + {.skip_type_check = true}}, + RPCExamples{ + HelpExampleCli("getopenrpcinfo", "") + + HelpExampleRpc("getopenrpcinfo", "") + }, + [](const RPCMethod& self, const JSONRPCRequest& request) -> UniValue +{ + const bool include_hidden{!request.params[0].isNull() && request.params[0].get_bool()}; + return tableRPC.buildOpenRPCDoc(include_hidden); +}, + }; +} + static const CRPCCommand vRPCCommands[]{ /* Overall control/query calls */ + {"control", &getopenrpcinfo}, {"control", &getrpcinfo}, {"control", &help}, {"control", &stop}, @@ -527,6 +860,88 @@ std::vector CRPCTable::listCommands() const return commandList; } +UniValue CRPCTable::buildOpenRPCDoc(bool include_hidden) const +{ + std::vector method_names; + for (const auto& [name, cmds] : mapCommands) { + if (cmds.empty()) continue; + const CRPCCommand* cmd{cmds.front()}; + if ((!include_hidden && cmd->category == "hidden") || !cmd->metadata_fn) continue; + method_names.push_back(name); + } + std::sort(method_names.begin(), method_names.end()); + + UniValue methods{UniValue::VARR}; + for (const auto& method_name : method_names) { + const CRPCCommand* cmd{mapCommands.at(method_name).front()}; + RPCMethod helpman{cmd->metadata_fn()}; + + UniValue params{UniValue::VARR}; + for (const auto& arg : helpman.GetArgs()) { + if (!include_hidden && arg.m_opts.hidden) continue; + UniValue param{UniValue::VOBJ}; + param.pushKV("name", arg.GetFirstName()); + param.pushKV("required", !arg.IsOptional()); + param.pushKV("schema", OpenRPCArgSchema(arg, include_hidden)); + + std::vector names{SplitString(arg.m_names, '|')}; + if (names.size() > 1) { + UniValue aliases{UniValue::VARR}; + for (size_t i{1}; i < names.size(); ++i) aliases.push_back(names[i]); + param.pushKV("x-bitcoin-aliases", std::move(aliases)); + } + if (arg.m_opts.placeholder) param.pushKV("x-bitcoin-placeholder", true); + if (arg.m_opts.also_positional) param.pushKV("x-bitcoin-also-positional", true); + if (!arg.m_description.empty()) param.pushKV("description", arg.m_description); + params.push_back(std::move(param)); + } + + UniValue result_schema{UniValue::VOBJ}; + const auto& results{helpman.GetResults().m_results}; + if (results.size() == 1 && results[0].m_type != RPCResult::Type::ANY) { + result_schema = OpenRPCResultSchema(results[0]); + } else if (results.size() > 1) { + UniValue one_of{UniValue::VARR}; + for (const auto& r : results) { + if (r.m_type == RPCResult::Type::ANY) continue; + UniValue schema{OpenRPCResultSchema(r)}; + if (!r.m_cond.empty()) schema.pushKV("description", r.m_cond); + one_of.push_back(std::move(schema)); + } + if (one_of.size() == 1) { + result_schema = one_of[0]; + } else if (one_of.size() > 1) { + result_schema.pushKV("oneOf", std::move(one_of)); + } + } + + UniValue method{UniValue::VOBJ}; + method.pushKV("name", method_name); + method.pushKV("description", util::TrimString(helpman.GetDescription())); + method.pushKV("params", std::move(params)); + UniValue result{UniValue::VOBJ}; + result.pushKV("name", "result"); + result.pushKV("schema", std::move(result_schema)); + method.pushKV("result", std::move(result)); + method.pushKV("x-bitcoin-category", cmd->category); + methods.push_back(std::move(method)); + } + + std::string version{"v" CLIENT_VERSION_STRING}; + if (!CLIENT_VERSION_IS_RELEASE) version += "-dev"; + + UniValue info{UniValue::VOBJ}; + info.pushKV("title", "Bitcoin Core JSON-RPC"); + info.pushKV("version", version); + info.pushKV("description", "Autogenerated from Bitcoin Core RPC metadata."); + + UniValue doc{UniValue::VOBJ}; + doc.pushKV("openrpc", "1.4.1"); + doc.pushKV("info", std::move(info)); + doc.pushKV("methods", std::move(methods)); + return doc; +} + UniValue CRPCTable::dumpArgMap(const JSONRPCRequest& args_request) const { JSONRPCRequest request = args_request; diff --git a/src/rpc/server.h b/src/rpc/server.h index 147c8cf6168..208dd1be4c8 100644 --- a/src/rpc/server.h +++ b/src/rpc/server.h @@ -107,6 +107,8 @@ public: * @returns List of registered commands. */ std::vector listCommands() const; + /** Return a complete OpenRPC 1.4.1 document for registered commands. */ + UniValue buildOpenRPCDoc(bool include_hidden = false) const; /** * Return all named arguments that need to be converted by the client from string to another JSON type diff --git a/src/test/fuzz/rpc.cpp b/src/test/fuzz/rpc.cpp index e3d3e540dcb..74fbb1c5a50 100644 --- a/src/test/fuzz/rpc.cpp +++ b/src/test/fuzz/rpc.cpp @@ -147,6 +147,7 @@ const std::vector RPC_COMMANDS_SAFE_FOR_FUZZING{ "getnetworkhashps", "getnetworkinfo", "getnodeaddresses", + "getopenrpcinfo", "getorphantxs", "getpeerinfo", "getprioritisedtransactions", diff --git a/src/test/rpc_tests.cpp b/src/test/rpc_tests.cpp index 390cd50c3c3..d574e1e2d3a 100644 --- a/src/test/rpc_tests.cpp +++ b/src/test/rpc_tests.cpp @@ -136,6 +136,39 @@ BOOST_AUTO_TEST_CASE(rpc_namedonlyparams) HasJSON(R"({"code":-8,"message":"Parameter options specified twice both as positional and named argument"})")); } +BOOST_AUTO_TEST_CASE(rpc_remove_command_cleans_up_empty_entry) +{ + CRPCTable table; + RpcMethodFnType method{ + []() -> RPCMethod { + return RPCMethod{ + "method", + "Test RPC method.\n", + {}, + RPCResult{RPCResult::Type::STR, "", ""}, + RPCExamples{""}, + [](const RPCMethod&, const JSONRPCRequest&) -> UniValue { return "ok"; }, + }; + } + }; + CRPCCommand command{"test", method}; + + table.appendCommand(command.name, &command); + BOOST_CHECK(table.removeCommand(command.name, &command)); + + bool found{false}; + for (const auto& name : table.listCommands()) { + if (name == command.name) { + found = true; + break; + } + } + BOOST_CHECK(!found); + + UniValue doc{table.buildOpenRPCDoc()}; + BOOST_CHECK(doc.isObject()); +} + BOOST_AUTO_TEST_CASE(rpc_rawparams) { // Test raw transaction API argument handling