xo-umbrella2/xo-websock/utest/Webserver.test.cpp

465 lines
21 KiB
C++

/** @file Webserver.test.cpp
*
* @author Roland Conybeare, Sep 2026
*
* The Webserver's endpoint registration API, on a server that is made but
* never started: no socket, no service thread. What needs a running server
* -- ending live subscriptions on unregister -- is covered at the router
* level (WsSessionRouter.test.cpp, [removal]).
*
* Expectations are OBSERVED, never predicted.
**/
#include "WebsockUtestAppcx.hpp"
#include "xo/websock/Webserver.hpp"
#include "xo/websock/DynamicEndpoint.hpp"
#include <xo/printjson/PrintJsonSingleton.hpp>
#include <catch2/catch.hpp>
#include <xo/reflect/Reflect.hpp>
#include <xo/reflect/StructReflector.hpp>
#include <xo/reflectutil/type_name.hpp>
#include <json/json.h>
#include <functional>
#include <memory>
#include <sstream>
#include <stdexcept>
#include <vector>
#include <string>
namespace xo {
using xo::web::Webserver;
using xo::web::WebserverConfig;
using xo::web::HttpEndpointDescr;
using xo::web::StreamEndpointDescr;
using xo::web::WebsocketSink;
using xo::web::HttpRequest;
using xo::web::HttpResponse;
using xo::web::Runstate;
using xo::json::PrintJsonSingleton;
using xo::fn::CallbackId;
using xo::reflect::Reflect;
using xo::reflect::StructReflector;
namespace ut {
namespace {
/** what the introspect example sends: a reflected struct holding
* a Webserver*. PrintJson follows the pointer to the Webserver
* printer (xo/websock/websock_json.hpp)
**/
struct HoldsServer {
static void reflect_self() {
StructReflector<HoldsServer> sr;
/* owning: in these tests the holder is where the
* server prints. A raw pointer is borrowed by default,
* printing only a ref (.xo-backlog/xo-printjson/issues/08)
*/
if (sr.is_incomplete())
REFLECT_MEMBER(sr, server).owning();
}
Webserver * server_ = nullptr;
};
/** the value of member @p name of a reflected struct's json:
* its "_members_" entry (.xo-backlog/xo-printjson/issues/07)
**/
Json::Value const & member_value(Json::Value const & obj, std::string const & name) {
for (Json::Value const & m : obj["_members_"])
if (m["_name_"].asString() == name)
return m["_value_"];
static Json::Value const none;
return none;
}
Json::Value parse_json(std::string const & text) {
Json::Value root;
JSONCPP_STRING err;
std::unique_ptr<Json::CharReader> rd(Json::CharReaderBuilder().newCharReader());
bool ok = rd->parse(text.data(), text.data() + text.size(), &root, &err);
INFO("text: " << text << " err: " << err);
REQUIRE(ok);
return root;
}
rp<Webserver> make_idle_server() {
/* port never bound: start_webserver() is not called */
return Webserver::make(WebsockUtestAppcx::appcx().cx<S_websock_tag>(), WebserverConfig());
}
HttpEndpointDescr http_descr(std::string pattern) {
return HttpEndpointDescr(std::move(pattern),
[](HttpRequest const &) { return HttpResponse::json("{}"); });
}
StreamEndpointDescr stream_descr(std::string pattern) {
return StreamEndpointDescr(std::move(pattern),
[](rp<WebsocketSink> const &) { return CallbackId(1); },
[](CallbackId) {});
}
}
TEST_CASE("webserver-unregister-stream-endpoint", "[websock][Webserver]")
{
rp<Webserver> websrv = make_idle_server();
REQUIRE(websrv->state() == Runstate::stopped);
websrv->register_stream_endpoint(stream_descr("/fw/${id}"));
/* a duplicate stem is rejected until unregistered */
REQUIRE_THROWS_AS(websrv->register_stream_endpoint(stream_descr("/fw/${x}")),
std::runtime_error);
/* exact pattern only */
REQUIRE(!websrv->unregister_stream_endpoint("/fw/${x}"));
REQUIRE(websrv->unregister_stream_endpoint("/fw/${id}"));
REQUIRE(!websrv->unregister_stream_endpoint("/fw/${id}"));
/* the stem is free again */
REQUIRE_NOTHROW(websrv->register_stream_endpoint(stream_descr("/fw/${x}")));
}
TEST_CASE("webserver-unregister-http-endpoint", "[websock][Webserver]")
{
rp<Webserver> websrv = make_idle_server();
websrv->register_http_endpoint(http_descr("/status"));
websrv->register_stream_endpoint(stream_descr("/status"));
/* http and stream are separate: removing one leaves the other */
REQUIRE(websrv->unregister_http_endpoint("/status"));
REQUIRE(!websrv->unregister_http_endpoint("/status"));
REQUIRE(websrv->unregister_stream_endpoint("/status"));
}
TEST_CASE("webserver-visits-its-endpoints", "[websock][Webserver]")
{
rp<Webserver> websrv = make_idle_server();
std::vector<std::string> v;
auto visit = [&] {
v.clear();
websrv->visit_endpoints([&v](xo::web::DynamicEndpoint const & ep) {
v.push_back(ep.uri_pattern());
});
};
visit();
REQUIRE(v.empty());
websrv->register_stream_endpoint(stream_descr("/fw/${id}"));
websrv->register_http_endpoint(http_descr("/status"));
visit();
REQUIRE(v == std::vector<std::string>{"/status", "/fw/${id}"});
REQUIRE(websrv->unregister_stream_endpoint("/fw/${id}"));
visit();
REQUIRE(v == std::vector<std::string>{"/status"});
}
TEST_CASE("websock-types-are-reflected", "[websock][reflect]")
{
/* the context describes xo-websock's types to xo-reflect
* (websock_reflect_types), including ones private to a .cpp
*/
rp<Webserver> websrv = make_idle_server();
for (char const * name : {"xo::web::Webserver",
"xo::web::WebserverImpl",
"xo::web::WebserverConfig",
"xo::web::WebsocketSessionRecd",
"xo::web::WsSessionSender<xo::web::WebserverImpl>",
"xo::web::WsSessionTable<xo::web::WebsocketSessionRecd>",
"xo::web::WebsocketSink",
"xo::web::WebsocketSinkImpl",
"xo::web::WsSessionRouter",
"xo::web::WsSessionRouter::Subscription",
"xo::web::DynamicEndpoint",
"xo::web::UrlRouter"})
{
INFO(name);
auto td = xo::reflect::TypeDescrBase::lookup_by_name(name);
REQUIRE(td);
REQUIRE(td->is_struct());
REQUIRE(td->complete_flag());
}
/* SelfTaggingDisplayable: from a Webserver*, the actual type */
Webserver * w = websrv.get();
auto most = Reflect::require<Webserver>()->most_derived_self_tp(w);
REQUIRE(most.td()->canonical_name() == "xo::web::WebserverImpl");
REQUIRE(most.address() == static_cast<void *>(w));
}
namespace {
/* a stream receiver; SelfTagging, not reflected in full */
class NamedReceiver : public xo::web::StreamReceiver {
public:
xo::reflect::TaggedRcptr self_tp() override { return Reflect::make_rctp(this); }
void receive(rp<WebsocketSink> const &, Json::Value const &) override {}
};
}
TEST_CASE("webserver-json-names-each-receiver", "[websock][Webserver][json]")
{
/* an endpoint's receiver printed in full: named by its most-
* derived type (StreamReceiver is SelfTagging), with the id the
* receiver_ member's ref uses
*/
HoldsServer::reflect_self();
rp<Webserver> websrv = make_idle_server();
rp<NamedReceiver> recv(new NamedReceiver());
websrv->register_http_endpoint(http_descr("/status"));
websrv->register_stream_endpoint(
StreamEndpointDescr("/fw/${id}",
[](rp<WebsocketSink> const &) { return CallbackId(1); },
[](CallbackId) {},
recv));
HoldsServer holder;
holder.server_ = websrv.get();
std::stringstream ss;
PrintJsonSingleton::instance()->print(holder, &ss);
Json::Value root = parse_json(ss.str());
Json::Value const & eps = member_value(root, "server")["endpoints"];
REQUIRE(eps.size() == 2);
/* http /status: none */
REQUIRE(eps[0]["receiver"].isNull());
/* stream /fw: the receiver, by its own type */
Json::Value const & r = eps[1]["receiver"];
REQUIRE(r.isObject());
REQUIRE(r["_canonical_type_"].asString() == std::string(xo::reflect::type_name<NamedReceiver>()));
REQUIRE(r["_short_type_"].asString() == "NamedReceiver");
REQUIRE(r["_name_"].asString() == "NamedReceiver");
/* the endpoint's hold and this test's */
REQUIRE(r["refcount"].asUInt() == 2);
/* the receiver_ member refers to it */
REQUIRE(member_value(eps[1], "receiver_")["_ref_"].asInt() == r["_id_"].asInt());
}
TEST_CASE("webserver-prints-as-json", "[websock][Webserver][json]")
{
HoldsServer::reflect_self();
rp<Webserver> websrv = make_idle_server();
websrv->register_http_endpoint(http_descr("/status"));
websrv->register_stream_endpoint(stream_descr("/fw/${id}"));
HoldsServer holder;
holder.server_ = websrv.get();
std::stringstream ss;
PrintJsonSingleton::instance()->print(holder, &ss);
Json::Value root = parse_json(ss.str());
Json::Value const & srv = member_value(root, "server");
INFO("json: " << ss.str());
REQUIRE(srv["_name_"].asString() == "Webserver");
/* the actual type, via self_tp() -- not the interface */
REQUIRE(srv["_canonical_type_"].asString() == "xo::web::WebserverImpl");
REQUIRE(srv["_short_type_"].asString() == "WebserverImpl");
REQUIRE(srv["_id_"].isInt());
REQUIRE(srv["refcount"].asUInt() >= 1);
REQUIRE(srv["listen_port"].asInt() == 0);
REQUIRE(srv["state"].asString() == "stopped");
/* chosen C++ members (.xo-backlog/xo-websock/issues/13): each
* with its declared type, and a value or why not
*/
{
Json::Value const & mem = srv["_members_"];
std::vector<std::string> names;
for (Json::Value const & m : mem) {
names.push_back(m["_name_"].asString());
REQUIRE(m["_canonical_type_"].isString());
REQUIRE(m["_short_type_"].isString());
REQUIRE((m.isMember("_value_") != m.isMember("_error_")));
}
/* reflected members, unguarded first; state_ last, read
* under the server's mutex_ (xo-printjson#09, xo-websock#15)
*/
REQUIRE(names == std::vector<std::string>{"ws_config_", "pjson_", "url_router_",
"session_table_", "listen_port_", "state_"});
/* an entry by name: robust to member order */
auto entry_of = [](Json::Value const & m, std::string const & name) -> Json::Value const & {
for (Json::Value const & x : m)
if (x["_name_"].asString() == name)
return x;
static Json::Value const none;
return none;
};
/* each declared type's xo-reflect metatype */
std::vector<std::string> metatypes;
for (Json::Value const & m : mem)
metatypes.push_back(m["_metatype_"].asString());
REQUIRE(metatypes == std::vector<std::string>{"struct", "pointer", "struct",
"struct", "atomic", "atomic"});
/* state_: a reflected enum (xo-reflect#06) -- printed by name */
REQUIRE(entry_of(mem, "listen_port_")["_canonical_type_"].asString() == "std::atomic<int>");
REQUIRE(entry_of(mem, "listen_port_")["_short_type_"].asString() == "atomic<int>");
REQUIRE(entry_of(mem, "listen_port_")["_value_"].asInt() == 0);
REQUIRE(entry_of(mem, "state_")["_canonical_type_"].asString() == "xo::web::Runstate");
REQUIRE(entry_of(mem, "state_")["_value_"].asString() == "stopped");
/* the config: a value with its own members, so the page can
* open it -- a default WebserverConfig
*/
{
Json::Value const & cfg = entry_of(mem, "ws_config_")["_value_"];
REQUIRE(cfg["_short_type_"].asString() == "WebserverConfig");
REQUIRE(!cfg.isMember("id")); /* a value: printed here only */
std::vector<std::string> cnames;
for (Json::Value const & m : cfg["_members_"])
cnames.push_back(m["_name_"].asString());
REQUIRE(cnames == std::vector<std::string>{"port_", "tls_flag_", "host_check_flag_",
"use_retry_flag_", "mount_origin_"});
REQUIRE(cfg["_members_"][0]["_value_"].asInt() == 0);
REQUIRE(cfg["_members_"][1]["_value_"].asBool() == false);
REQUIRE(cfg["_members_"][4]["_value_"].asString() == "./mount-origin");
}
REQUIRE(entry_of(mem, "url_router_")["_canonical_type_"].asString() == "xo::web::UrlRouter");
REQUIRE(entry_of(mem, "url_router_")["_value_"]["_canonical_type_"].asString() == "xo::web::UrlRouter");
REQUIRE(entry_of(mem, "url_router_")["_value_"]["_short_type_"].asString() == "UrlRouter");
/* the url router: an id, and its maps -- stem -> a ref to
* the very endpoint printed in the server's list
*/
Json::Value const & ur = entry_of(mem, "url_router_")["_value_"];
REQUIRE(ur["_id_"].isInt());
REQUIRE(ur["_members_"][0]["_name_"].asString() == "http_map_");
REQUIRE(ur["_members_"][1]["_name_"].asString() == "stream_map_");
std::size_t n_refs = 0;
for (Json::Value const & ep : srv["endpoints"]) {
Json::Value const & map
= ur["_members_"][ep["kind"].asString() == "http" ? 0 : 1]["_value_"];
INFO("stem " << ep["stem"].asString());
REQUIRE(map[ep["stem"].asString()]["_ref_"].asInt() == ep["_id_"].asInt());
++n_refs;
}
REQUIRE(n_refs == ur["_members_"][0]["_value_"].size()
+ ur["_members_"][1]["_value_"].size());
/* the session table: an id; no session yet -- ids from 1 */
Json::Value const & st = entry_of(mem, "session_table_")["_value_"];
REQUIRE(st["_id_"].isInt());
REQUIRE(st["_members_"][0]["_name_"].asString() == "next_id_");
REQUIRE(st["_members_"][0]["_value_"].asUInt64() == 1);
REQUIRE(st["_members_"][1]["_name_"].asString() == "session_map_");
REQUIRE(st["_members_"][1]["_value_"].isObject());
REQUIRE(st["_members_"][1]["_value_"].empty());
}
/* every member a printer opts in to is printable: anywhere in
* the output, no "_error_" (JsonMembers' "type not reflected")
*/
{
std::vector<std::string> errors;
std::function<void (Json::Value const &)> walk
= [&walk, &errors](Json::Value const & x) {
if (x.isObject()) {
if (x.isMember("_error_"))
errors.push_back(x["_name_"].asString() + ": "
+ x["_error_"].asString());
for (auto const & k : x.getMemberNames())
walk(x[k]);
} else if (x.isArray()) {
for (Json::Value const & y : x)
walk(y);
}
};
walk(root);
INFO("errors: " << errors.size() << (errors.empty() ? "" : " first: " + errors[0]));
REQUIRE(errors.empty());
}
Json::Value const & eps = srv["endpoints"];
REQUIRE(eps.size() == 2);
REQUIRE(eps[0]["_name_"].asString() == "DynamicEndpoint");
REQUIRE(eps[0]["_canonical_type_"].asString() == "xo::web::DynamicEndpoint");
REQUIRE(eps[0]["kind"].asString() == "http");
REQUIRE(eps[0]["pattern"].asString() == "/status");
REQUIRE(eps[0]["has_receive"].asBool() == false);
/* each endpoint's chosen C++ members (.xo-backlog/xo-websock/issues/13) */
{
Json::Value const & m = eps[0]["_members_"]; /* http /status */
std::vector<std::string> names;
for (Json::Value const & x : m)
names.push_back(x["_name_"].asString());
/* reflected members first (kind_, uri_pattern_, var_v_, receiver_),
* then the summaries and the ref (xo-printjson#06,
* xo-reflect#06)
*/
REQUIRE(names == std::vector<std::string>{"kind_", "uri_pattern_", "var_v_",
"receiver_", "uri_regex_", "http_handler_",
"subscribe_fn_", "unsubscribe_fn_"});
/* by name: robust to member order */
auto value_of = [](Json::Value const & mem, std::string const & name) {
for (Json::Value const & x : mem)
if (x["_name_"].asString() == name)
return x["_value_"];
return Json::Value();
};
REQUIRE(value_of(m, "kind_").asString() == "http");
REQUIRE(value_of(m, "uri_pattern_").asString() == "/status");
REQUIRE(value_of(m, "uri_regex_").asString() == "0 captures");
REQUIRE(value_of(m, "var_v_").isArray());
REQUIRE(value_of(m, "var_v_").empty());
REQUIRE(value_of(m, "http_handler_").asString() == "set");
REQUIRE(value_of(m, "subscribe_fn_").asString() == "empty");
REQUIRE(value_of(m, "receiver_").isNull());
Json::Value const & s = eps[1]["_members_"]; /* the stream endpoint */
REQUIRE(value_of(s, "kind_").asString() == "stream");
REQUIRE(value_of(s, "uri_pattern_").asString() == "/fw/${id}");
REQUIRE(value_of(s, "uri_regex_").asString() == "1 captures");
REQUIRE(value_of(s, "var_v_").size() == 1);
REQUIRE(value_of(s, "var_v_")[0].asString() == "id");
REQUIRE(value_of(s, "http_handler_").asString() == "empty");
REQUIRE(value_of(s, "subscribe_fn_").asString() == "set");
REQUIRE(value_of(s, "unsubscribe_fn_").asString() == "set");
}
REQUIRE(eps[1]["kind"].asString() == "stream");
REQUIRE(eps[1]["stem"].asString() == "/fw/");
REQUIRE(eps[1]["pattern"].asString() == "/fw/${id}");
/* identity and refcount: distinct objects, each held only by the
* router's map (no subscriptions on an idle server)
*/
REQUIRE(eps[0]["_id_"].isInt());
REQUIRE(eps[0]["_id_"].asInt() != eps[1]["_id_"].asInt());
REQUIRE(eps[0]["refcount"].asUInt() == 1);
REQUIRE(eps[1]["refcount"].asUInt() == 1);
REQUIRE(srv["sessions"].isArray());
REQUIRE(srv["sessions"].empty());
}
} /*namespace ut*/
} /*namespace xo*/
/* end Webserver.test.cpp */