Add the streamer-tools API client, with a real HTTP backend per platform

Implements the two read-key-scoped calls in
apps/server/src/obs/plugin.routes.ts: GET /api/obs/:slug/slots and
POST /api/obs/:slug/token.

Three pieces, all in core/ with no OBS dependency:

- stplugin::json -- a small, strict JSON reader. Hand-rolled rather than
  vendoring nlohmann because the only JSON this plugin ever sees is two
  fixed-shape responses from its own server, and the parser has to build on
  three platforms with no package-manager step in CI. It never throws,
  bounds its recursion (kMaxDepth=32) so a hostile response cannot overflow
  the stack inside OBS, rejects trailing garbage, and returns the caller's
  fallback for wrong-typed access instead of aborting.

- stplugin::HttpClient -- a two-method injectable interface, with libcurl
  behind it on Linux/macOS and WinHTTP on Windows. WinHTTP rather than curl
  on Windows because it ships with the OS and does TLS through SChannel: the
  self-hosted winvm-builder runner has no package manager, and per the
  scaffold README does not even have cmake preinstalled. Both backends cap
  the response body at 4 MiB, keep TLS verification on (the read key is a
  credential), and honour a whole-request timeout.

- stplugin::ApiClient -- maps the responses onto an ApiStatus enum that
  distinguishes NotFound (404), Unavailable (503), NetworkError,
  MalformedResponse and InvalidConfig. It deliberately does not claim to
  know whether a 404 was a wrong key or an unknown slug, because the server
  deliberately does not say. Server URLs are normalised the way an operator
  actually pastes them, defaulting to https so the read key is never sent in
  the clear by accident, and redactedUrl() exists so a URL can be logged
  without the key.

Tests (279 checks across two new suites) run at two levels: a fake
HttpClient covering every response and error branch, and a real loopback
HTTP server on 127.0.0.1 driving the actual platform backend -- so libcurl
on Linux/macOS and WinHTTP on Windows are each exercised in CI rather than
assumed. The loopback cases deliberately include the ones that must not hang
OBS: a truncated JSON body, a connection accepted and closed without a
reply, non-HTTP garbage, a dead port, and a stalled server that has to be
cut off by the client's own timeout.

Verified locally on Ubuntu 24.04:
  ctest --test-dir build --output-on-failure -> 4/4 passed
  test_json: 158 checks passed
  test_api_client: 121 checks passed

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RL8abRmgFXkVASHkkqiJbE
This commit is contained in:
2026-09-06 21:28:55 -07:00
co-authored by Claude Sonnet 5
parent e595173049
commit bf33966a4e
13 changed files with 2234 additions and 12 deletions
+24 -1
View File
@@ -4,10 +4,23 @@
# 2026-09-06-obs-camera-plugin-design.md in the streamer-tools repo) --
# this must build and test headlessly on every platform.
add_library(stplugin_core STATIC
set(STPLUGIN_CORE_SOURCES
src/core.cpp
src/json.cpp
src/http_common.cpp
src/api_client.cpp
)
# HTTP backend, one per platform. See core/include/stplugin/http.h for why
# this is split rather than using libcurl everywhere.
if(WIN32)
list(APPEND STPLUGIN_CORE_SOURCES src/http_winhttp.cpp)
else()
list(APPEND STPLUGIN_CORE_SOURCES src/http_curl.cpp)
endif()
add_library(stplugin_core STATIC ${STPLUGIN_CORE_SOURCES})
target_include_directories(stplugin_core
PUBLIC
${CMAKE_CURRENT_SOURCE_DIR}/include
@@ -18,6 +31,16 @@ target_link_libraries(stplugin_core
LiveKit::livekit
)
if(WIN32)
target_link_libraries(stplugin_core PRIVATE winhttp)
else()
find_package(CURL REQUIRED)
target_link_libraries(stplugin_core PRIVATE CURL::libcurl)
endif()
find_package(Threads REQUIRED)
target_link_libraries(stplugin_core PUBLIC Threads::Threads)
set_target_properties(stplugin_core PROPERTIES
POSITION_INDEPENDENT_CODE ON
)
+118
View File
@@ -0,0 +1,118 @@
/*
streamer-tools OBS Camera Plugin - streamer-tools API client
Copyright (C) 2026 CyberCoveLLC <jknapp85@gmail.com>
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License along
with this program. If not, see <https://www.gnu.org/licenses/>
*/
#pragma once
// Client for the two read-key-scoped endpoints in
// apps/server/src/obs/plugin.routes.ts (streamer-tools repo):
//
// GET /api/obs/:slug/slots?key=<readKey>
// 200 { slots: [ { identity, displayName, live } ] }
// 404 { error: 'not found' } wrong key OR unknown room
// 503 { error: 'livekit not configured' }
//
// POST /api/obs/:slug/token?key=<readKey>
// 200 { lkToken, wsUrl, identity }
// 404 / 503 as above
//
// The server deliberately answers a wrong key and an unknown slug identically
// (404), so this client must not claim to know which it was.
#include <memory>
#include <string>
#include <vector>
#include "stplugin/core.h"
#include "stplugin/http.h"
namespace stplugin {
enum class ApiStatus {
Ok,
/// server URL / slug / key were not all filled in
InvalidConfig,
/// request never completed (DNS, TLS, timeout, ...)
NetworkError,
/// HTTP 404: unknown room slug or wrong read key -- indistinguishable
NotFound,
/// HTTP 503: the server has no LiveKit credentials configured
Unavailable,
/// any other non-2xx status
HttpError,
/// 2xx but the body was not the JSON shape this client expects
MalformedResponse,
};
/// A short, operator-facing description. Never includes the read key.
const char *describeApiStatus(ApiStatus status);
struct SlotInfo {
/// LiveKit participant identity -- this is what the session wrapper
/// subscribes to, and what gets persisted in the OBS source settings.
std::string identity;
/// Human label for the dropdown; the server falls back to identity.
std::string display_name;
/// Currently publishing camera video.
bool live = false;
};
struct SlotsResult {
ApiStatus status = ApiStatus::InvalidConfig;
/// Detail for logs/UI. Never contains the read key.
std::string message;
std::vector<SlotInfo> slots;
bool ok() const { return status == ApiStatus::Ok; }
};
struct TokenResult {
ApiStatus status = ApiStatus::InvalidConfig;
std::string message;
/// LiveKit JWT for a hidden, subscribe-only participant.
std::string lk_token;
/// LiveKit websocket URL to connect to.
std::string ws_url;
/// The obs:<slug>:<nonce> identity the server minted for us.
std::string identity;
bool ok() const { return status == ApiStatus::Ok; }
};
class ApiClient {
public:
/// Takes ownership of the HTTP client, so tests can inject a fake.
explicit ApiClient(std::shared_ptr<HttpClient> http);
SlotsResult fetchSlots(const ConnectionConfig &config) const;
TokenResult requestToken(const ConnectionConfig &config) const;
/// Accepts what an operator would actually paste: a bare hostname, a URL
/// with a trailing slash, extra whitespace. Returns an empty string if
/// nothing usable is left. Defaults to https:// when no scheme is given,
/// because the read key must never be sent in the clear by accident.
static std::string normalizeServerUrl(const std::string &raw);
/// Exposed for tests and for logging: the exact URL a call will hit,
/// with the read key replaced by "***".
static std::string redactedUrl(const std::string &url);
private:
std::shared_ptr<HttpClient> http_;
};
} // namespace stplugin
+78
View File
@@ -0,0 +1,78 @@
/*
streamer-tools OBS Camera Plugin - HTTP client interface
Copyright (C) 2026 CyberCoveLLC <jknapp85@gmail.com>
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License along
with this program. If not, see <https://www.gnu.org/licenses/>
*/
#pragma once
// A two-method HTTP interface, injectable so ApiClient can be unit-tested
// without a network (mirroring the streamer-tools repo's injectable-deps
// convention, per the design doc's Testing section).
//
// Backends, chosen per platform so no third-party HTTP dependency has to be
// built on any of the three CI runners:
// - Linux/macOS: libcurl. Already present on both (client-sdk-cpp's own
// liblivekit links libcurl on Linux, and macOS ships libcurl in the SDK).
// - Windows: WinHTTP, which ships with the OS and handles TLS through
// SChannel -- avoiding an OpenSSL or curl build on the Windows runner.
#include <map>
#include <string>
namespace stplugin {
struct HttpResponse {
/// HTTP status code, or 0 when the request never completed (DNS failure,
/// TLS failure, timeout, ...). Callers must check `network_error` first.
long status = 0;
/// Response body. May be empty, may be arbitrary bytes: never assume it
/// parses as JSON.
std::string body;
/// Empty on success. Non-empty means the request did not complete and
/// `status`/`body` are meaningless.
std::string network_error;
bool ok() const { return network_error.empty(); }
};
struct HttpRequest {
std::string method = "GET";
std::string url;
std::string body;
std::string content_type;
/// Whole-request timeout. Kept short: this runs on OBS's UI thread when
/// the properties dropdown is refreshed, and on the source's own worker
/// thread when a token is minted.
int timeout_ms = 10000;
};
class HttpClient {
public:
virtual ~HttpClient() = default;
virtual HttpResponse send(const HttpRequest &request) = 0;
};
/// Percent-encode a string for use in a URL query value.
std::string urlEncode(const std::string &value);
/// Construct the platform's real HTTP client. Returns nullptr if no backend
/// was compiled in.
HttpClient *createPlatformHttpClient();
} // namespace stplugin
+106
View File
@@ -0,0 +1,106 @@
/*
streamer-tools OBS Camera Plugin - minimal JSON reader
Copyright (C) 2026 CyberCoveLLC <jknapp85@gmail.com>
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License along
with this program. If not, see <https://www.gnu.org/licenses/>
*/
#pragma once
// A deliberately small, strict, allocation-bounded JSON reader.
//
// Why hand-rolled rather than vendoring nlohmann/json: the only JSON this
// plugin ever parses is two small, fixed-shape responses from its own
// server (apps/server/src/obs/plugin.routes.ts in the streamer-tools repo),
// and the parser has to build unmodified on three platforms with no package
// manager step in CI. The scope is small enough to test exhaustively --
// including the malformed inputs a compromised or misconfigured endpoint
// could return, which is the case that must not crash or hang OBS.
//
// Properties this parser guarantees, all covered by core/tests/test_json.cpp:
// - never throws; every failure is reported as Value::invalid()
// - bounded recursion (kMaxDepth) so nesting cannot blow the stack
// - trailing garbage after the top-level value is an error
// - accessors on a wrong-typed value return the caller's default rather
// than aborting, so callers can be written without type interrogation
#include <cstdint>
#include <map>
#include <string>
#include <vector>
namespace stplugin {
namespace json {
/// Maximum nesting depth accepted by parse(). Any deeper input is rejected
/// as invalid rather than recursed into.
constexpr int kMaxDepth = 32;
class Value {
public:
enum class Type { Invalid, Null, Bool, Number, String, Array, Object };
Value() = default;
static Value invalid() { return Value(); }
static Value makeNull();
static Value makeBool(bool v);
static Value makeNumber(double v);
static Value makeString(std::string v);
static Value makeArray(std::vector<Value> v);
static Value makeObject(std::map<std::string, Value> v);
Type type() const { return type_; }
bool valid() const { return type_ != Type::Invalid; }
bool isNull() const { return type_ == Type::Null; }
bool isBool() const { return type_ == Type::Bool; }
bool isNumber() const { return type_ == Type::Number; }
bool isString() const { return type_ == Type::String; }
bool isArray() const { return type_ == Type::Array; }
bool isObject() const { return type_ == Type::Object; }
/// Object member lookup. Returns invalid() for a missing key or when this
/// value is not an object.
const Value &operator[](const std::string &key) const;
/// Array element access. Returns invalid() when out of range or when this
/// value is not an array.
const Value &at(std::size_t index) const;
std::size_t size() const;
/// Typed accessors. Each returns `fallback` when this value is missing or
/// of the wrong type, so callers never have to check first.
std::string asString(const std::string &fallback = std::string()) const;
bool asBool(bool fallback = false) const;
double asNumber(double fallback = 0.0) const;
const std::vector<Value> &elements() const { return array_; }
private:
Type type_ = Type::Invalid;
bool bool_ = false;
double number_ = 0.0;
std::string string_;
std::vector<Value> array_;
std::map<std::string, Value> object_;
};
/// Parse a complete JSON document. Returns Value::invalid() on any syntax
/// error, on trailing non-whitespace content, or on excessive nesting.
/// Never throws.
Value parse(const std::string &text);
} // namespace json
} // namespace stplugin
+211
View File
@@ -0,0 +1,211 @@
/*
streamer-tools OBS Camera Plugin - streamer-tools API client
Copyright (C) 2026 CyberCoveLLC <jknapp85@gmail.com>
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License along
with this program. If not, see <https://www.gnu.org/licenses/>
*/
#include "stplugin/api_client.h"
#include "stplugin/json.h"
#include <utility>
namespace stplugin {
namespace {
std::string trim(const std::string &s)
{
std::size_t begin = 0;
std::size_t end = s.size();
auto is_space = [](char c) { return c == ' ' || c == '\t' || c == '\r' || c == '\n'; };
while (begin < end && is_space(s[begin]))
++begin;
while (end > begin && is_space(s[end - 1]))
--end;
return s.substr(begin, end - begin);
}
/// Map a completed HTTP response onto the shared status codes. Returns
/// ApiStatus::Ok when the caller should go on to parse the body.
ApiStatus classify(const HttpResponse &response, std::string &message)
{
if (!response.ok()) {
message = response.network_error;
return ApiStatus::NetworkError;
}
if (response.status >= 200 && response.status < 300)
return ApiStatus::Ok;
if (response.status == 404) {
message = "unknown room slug, or the read key is wrong or has been rotated";
return ApiStatus::NotFound;
}
if (response.status == 503) {
message = "the streamer-tools server has no LiveKit credentials configured";
return ApiStatus::Unavailable;
}
message = "HTTP " + std::to_string(response.status);
return ApiStatus::HttpError;
}
std::string buildUrl(const ConnectionConfig &config, const char *suffix)
{
return ApiClient::normalizeServerUrl(config.server_url) + "/api/obs/" +
urlEncode(trim(config.room_slug)) + suffix + "?key=" + urlEncode(trim(config.read_key));
}
} // namespace
const char *describeApiStatus(ApiStatus status)
{
switch (status) {
case ApiStatus::Ok: return "ok";
case ApiStatus::InvalidConfig: return "server URL, room slug and read key are all required";
case ApiStatus::NetworkError: return "could not reach the streamer-tools server";
case ApiStatus::NotFound: return "room not found, or the read key is wrong";
case ApiStatus::Unavailable: return "server has no LiveKit configured";
case ApiStatus::HttpError: return "unexpected response from the streamer-tools server";
case ApiStatus::MalformedResponse: return "unreadable response from the streamer-tools server";
}
return "unknown error";
}
ApiClient::ApiClient(std::shared_ptr<HttpClient> http) : http_(std::move(http)) {}
std::string ApiClient::normalizeServerUrl(const std::string &raw)
{
std::string url = trim(raw);
if (url.empty())
return url;
// A bare "streamers.example.com" is what an operator will paste half the
// time. Defaulting to https (never http) keeps the read key off the wire
// in the clear.
const bool has_scheme = url.compare(0, 7, "http://") == 0 || url.compare(0, 8, "https://") == 0;
if (!has_scheme)
url = "https://" + url;
while (!url.empty() && url.back() == '/')
url.pop_back();
// "https://" with nothing after it is not a server.
if (url == "https:/" || url == "https:" || url == "http:/" || url == "http:" ||
url == "https://" || url == "http://")
return std::string();
return url;
}
std::string ApiClient::redactedUrl(const std::string &url)
{
const std::size_t at = url.find("key=");
if (at == std::string::npos)
return url;
const std::size_t value = at + 4;
std::size_t end = url.find('&', value);
if (end == std::string::npos)
end = url.size();
return url.substr(0, value) + "***" + url.substr(end);
}
SlotsResult ApiClient::fetchSlots(const ConnectionConfig &config) const
{
SlotsResult result;
if (!config.is_valid() || normalizeServerUrl(config.server_url).empty() || !http_) {
result.status = ApiStatus::InvalidConfig;
result.message = describeApiStatus(ApiStatus::InvalidConfig);
return result;
}
HttpRequest request;
request.method = "GET";
request.url = buildUrl(config, "/slots");
const HttpResponse response = http_->send(request);
const ApiStatus status = classify(response, result.message);
if (status != ApiStatus::Ok) {
result.status = status;
return result;
}
const json::Value root = json::parse(response.body);
const json::Value &slots = root["slots"];
if (!root.isObject() || !slots.isArray()) {
result.status = ApiStatus::MalformedResponse;
result.message = "expected a JSON object with a \"slots\" array";
return result;
}
for (const json::Value &entry : slots.elements()) {
// A slot without an identity is unusable -- it is what the session
// wrapper subscribes by -- so skip it rather than surfacing a
// dropdown row that can never connect. Anything else is best-effort:
// a missing displayName falls back to the identity exactly as the
// server itself does.
const std::string identity = entry["identity"].asString();
if (identity.empty())
continue;
SlotInfo slot;
slot.identity = identity;
slot.display_name = entry["displayName"].asString(identity);
if (slot.display_name.empty())
slot.display_name = identity;
slot.live = entry["live"].asBool(false);
result.slots.push_back(std::move(slot));
}
result.status = ApiStatus::Ok;
return result;
}
TokenResult ApiClient::requestToken(const ConnectionConfig &config) const
{
TokenResult result;
if (!config.is_valid() || normalizeServerUrl(config.server_url).empty() || !http_) {
result.status = ApiStatus::InvalidConfig;
result.message = describeApiStatus(ApiStatus::InvalidConfig);
return result;
}
HttpRequest request;
request.method = "POST";
request.url = buildUrl(config, "/token");
request.content_type = "application/json";
request.body = "{}";
const HttpResponse response = http_->send(request);
const ApiStatus status = classify(response, result.message);
if (status != ApiStatus::Ok) {
result.status = status;
return result;
}
const json::Value root = json::parse(response.body);
const std::string token = root["lkToken"].asString();
const std::string ws_url = root["wsUrl"].asString();
if (!root.isObject() || token.empty() || ws_url.empty()) {
result.status = ApiStatus::MalformedResponse;
result.message = "expected a JSON object with non-empty \"lkToken\" and \"wsUrl\"";
return result;
}
result.lk_token = token;
result.ws_url = ws_url;
result.identity = root["identity"].asString();
result.status = ApiStatus::Ok;
return result;
}
} // namespace stplugin
+46
View File
@@ -0,0 +1,46 @@
/*
streamer-tools OBS Camera Plugin - HTTP helpers shared by all backends
Copyright (C) 2026 CyberCoveLLC <jknapp85@gmail.com>
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License along
with this program. If not, see <https://www.gnu.org/licenses/>
*/
#include "stplugin/http.h"
namespace stplugin {
// Hand-rolled rather than curl_easy_escape so the WinHTTP backend gets the
// same behaviour, and so this is testable without a live HTTP client.
// Unreserved set per RFC 3986 section 2.3.
std::string urlEncode(const std::string &value)
{
static const char *kHex = "0123456789ABCDEF";
std::string out;
out.reserve(value.size());
for (const char raw : value) {
const unsigned char c = static_cast<unsigned char>(raw);
const bool unreserved = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
(c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.' || c == '~';
if (unreserved) {
out.push_back(static_cast<char>(c));
} else {
out.push_back('%');
out.push_back(kHex[c >> 4]);
out.push_back(kHex[c & 0x0F]);
}
}
return out;
}
} // namespace stplugin
+137
View File
@@ -0,0 +1,137 @@
/*
streamer-tools OBS Camera Plugin - libcurl HTTP backend (Linux/macOS)
Copyright (C) 2026 CyberCoveLLC <jknapp85@gmail.com>
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License along
with this program. If not, see <https://www.gnu.org/licenses/>
*/
#include "stplugin/http.h"
#include <curl/curl.h>
#include <cstddef>
#include <mutex>
namespace stplugin {
namespace {
/// Hard cap on a response body. The two endpoints this client talks to return
/// a few hundred bytes; anything larger is a misconfigured proxy or a wrong
/// URL, and must not be allowed to grow OBS's heap without bound.
constexpr std::size_t kMaxResponseBytes = 4u * 1024u * 1024u;
struct WriteContext {
std::string body;
bool overflowed = false;
};
std::size_t writeCallback(char *ptr, std::size_t size, std::size_t nmemb, void *userdata)
{
auto *ctx = static_cast<WriteContext *>(userdata);
const std::size_t bytes = size * nmemb;
if (ctx->body.size() + bytes > kMaxResponseBytes) {
ctx->overflowed = true;
return 0; // aborts the transfer with CURLE_WRITE_ERROR
}
ctx->body.append(ptr, bytes);
return bytes;
}
/// curl_global_init is not thread-safe and must run once per process before
/// any easy handle is created. OBS may create several sources concurrently.
void ensureCurlGlobalInit()
{
static std::once_flag once;
std::call_once(once, [] { curl_global_init(CURL_GLOBAL_DEFAULT); });
}
class CurlHttpClient : public HttpClient {
public:
HttpResponse send(const HttpRequest &request) override
{
ensureCurlGlobalInit();
HttpResponse response;
CURL *curl = curl_easy_init();
if (!curl) {
response.network_error = "curl_easy_init failed";
return response;
}
WriteContext ctx;
struct curl_slist *headers = nullptr;
curl_easy_setopt(curl, CURLOPT_URL, request.url.c_str());
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, writeCallback);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &ctx);
curl_easy_setopt(curl, CURLOPT_TIMEOUT_MS, static_cast<long>(request.timeout_ms));
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT_MS, static_cast<long>(request.timeout_ms));
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
curl_easy_setopt(curl, CURLOPT_MAXREDIRS, 3L);
curl_easy_setopt(curl, CURLOPT_USERAGENT, "streamer-tools-obs-plugin/1.0");
// NOSIGNAL is required whenever curl is used off the main thread:
// without it curl installs a SIGALRM handler for DNS timeouts, which
// is process-global and would be a rude thing to do inside OBS.
curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L);
// TLS verification stays on. The read key is a credential; sending it
// to an unverified host is exactly the failure this must not have.
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 1L);
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L);
if (request.method == "POST") {
curl_easy_setopt(curl, CURLOPT_POST, 1L);
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, request.body.c_str());
curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE, static_cast<long>(request.body.size()));
} else if (request.method != "GET") {
curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, request.method.c_str());
}
if (!request.content_type.empty()) {
const std::string header = "Content-Type: " + request.content_type;
headers = curl_slist_append(headers, header.c_str());
}
// Fastify answers a bare POST with no body fine, but some proxies
// insert an Expect: 100-continue round trip; suppress it.
headers = curl_slist_append(headers, "Expect:");
if (headers)
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
const CURLcode rc = curl_easy_perform(curl);
if (rc == CURLE_OK) {
long status = 0;
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &status);
response.status = status;
response.body = std::move(ctx.body);
} else if (ctx.overflowed) {
response.network_error = "response body exceeded 4 MiB";
} else {
response.network_error = curl_easy_strerror(rc);
}
if (headers)
curl_slist_free_all(headers);
curl_easy_cleanup(curl);
return response;
}
};
} // namespace
HttpClient *createPlatformHttpClient()
{
return new CurlHttpClient();
}
} // namespace stplugin
+208
View File
@@ -0,0 +1,208 @@
/*
streamer-tools OBS Camera Plugin - WinHTTP backend (Windows)
Copyright (C) 2026 CyberCoveLLC <jknapp85@gmail.com>
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License along
with this program. If not, see <https://www.gnu.org/licenses/>
*/
// WinHTTP rather than libcurl on Windows: it ships with the OS, does TLS
// through SChannel (so no OpenSSL to build or ship), and needs no package
// manager on the self-hosted `winvm-builder` runner -- which, per the
// scaffold README, is a bare VM without even cmake preinstalled.
#include "stplugin/http.h"
#include <windows.h>
#include <winhttp.h>
#include <cstddef>
#include <string>
#include <vector>
namespace stplugin {
namespace {
constexpr std::size_t kMaxResponseBytes = 4u * 1024u * 1024u;
std::wstring widen(const std::string &s)
{
if (s.empty())
return std::wstring();
const int needed = MultiByteToWideChar(CP_UTF8, 0, s.c_str(), static_cast<int>(s.size()), nullptr, 0);
if (needed <= 0)
return std::wstring();
std::wstring out(static_cast<std::size_t>(needed), L'\0');
MultiByteToWideChar(CP_UTF8, 0, s.c_str(), static_cast<int>(s.size()), &out[0], needed);
return out;
}
std::string lastErrorMessage(const char *what)
{
return std::string(what) + " failed (GetLastError=" + std::to_string(GetLastError()) + ")";
}
/// RAII for the three WinHTTP handle kinds, which all close the same way.
class Handle {
public:
Handle() = default;
explicit Handle(HINTERNET h) : h_(h) {}
~Handle()
{
if (h_)
WinHttpCloseHandle(h_);
}
Handle(const Handle &) = delete;
Handle &operator=(const Handle &) = delete;
void reset(HINTERNET h)
{
if (h_)
WinHttpCloseHandle(h_);
h_ = h;
}
HINTERNET get() const { return h_; }
explicit operator bool() const { return h_ != nullptr; }
private:
HINTERNET h_ = nullptr;
};
class WinHttpClient : public HttpClient {
public:
HttpResponse send(const HttpRequest &request) override
{
HttpResponse response;
const std::wstring url = widen(request.url);
if (url.empty()) {
response.network_error = "empty or non-UTF-8 URL";
return response;
}
URL_COMPONENTS parts{};
parts.dwStructSize = sizeof(parts);
wchar_t host[256] = {0};
wchar_t path[4096] = {0};
wchar_t extra[4096] = {0};
parts.lpszHostName = host;
parts.dwHostNameLength = static_cast<DWORD>(sizeof(host) / sizeof(host[0]));
parts.lpszUrlPath = path;
parts.dwUrlPathLength = static_cast<DWORD>(sizeof(path) / sizeof(path[0]));
parts.lpszExtraInfo = extra;
parts.dwExtraInfoLength = static_cast<DWORD>(sizeof(extra) / sizeof(extra[0]));
if (!WinHttpCrackUrl(url.c_str(), static_cast<DWORD>(url.size()), 0, &parts)) {
response.network_error = lastErrorMessage("WinHttpCrackUrl");
return response;
}
if (parts.nScheme != INTERNET_SCHEME_HTTP && parts.nScheme != INTERNET_SCHEME_HTTPS) {
response.network_error = "unsupported URL scheme";
return response;
}
Handle session(WinHttpOpen(L"streamer-tools-obs-plugin/1.0", WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY,
WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0));
if (!session) {
response.network_error = lastErrorMessage("WinHttpOpen");
return response;
}
const DWORD timeout = static_cast<DWORD>(request.timeout_ms);
WinHttpSetTimeouts(session.get(), static_cast<int>(timeout), static_cast<int>(timeout),
static_cast<int>(timeout), static_cast<int>(timeout));
Handle connect(WinHttpConnect(session.get(), host, parts.nPort, 0));
if (!connect) {
response.network_error = lastErrorMessage("WinHttpConnect");
return response;
}
std::wstring target(path);
target += extra;
const DWORD flags = (parts.nScheme == INTERNET_SCHEME_HTTPS) ? WINHTTP_FLAG_SECURE : 0u;
Handle req(WinHttpOpenRequest(connect.get(), widen(request.method).c_str(), target.c_str(), nullptr,
WINHTTP_NO_REFERER, WINHTTP_DEFAULT_ACCEPT_TYPES, flags));
if (!req) {
response.network_error = lastErrorMessage("WinHttpOpenRequest");
return response;
}
std::wstring headers;
if (!request.content_type.empty())
headers = L"Content-Type: " + widen(request.content_type) + L"\r\n";
const LPCWSTR header_ptr = headers.empty() ? WINHTTP_NO_ADDITIONAL_HEADERS : headers.c_str();
const DWORD header_len = headers.empty() ? 0u : static_cast<DWORD>(headers.size());
void *body_ptr = request.body.empty() ? WINHTTP_NO_REQUEST_DATA
: const_cast<char *>(request.body.data());
const DWORD body_len = static_cast<DWORD>(request.body.size());
if (!WinHttpSendRequest(req.get(), header_ptr, header_len, body_ptr, body_len, body_len, 0)) {
response.network_error = lastErrorMessage("WinHttpSendRequest");
return response;
}
if (!WinHttpReceiveResponse(req.get(), nullptr)) {
response.network_error = lastErrorMessage("WinHttpReceiveResponse");
return response;
}
DWORD status = 0;
DWORD status_size = sizeof(status);
if (!WinHttpQueryHeaders(req.get(), WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER,
WINHTTP_HEADER_NAME_BY_INDEX, &status, &status_size, WINHTTP_NO_HEADER_INDEX)) {
response.network_error = lastErrorMessage("WinHttpQueryHeaders");
return response;
}
response.status = static_cast<long>(status);
std::string body;
for (;;) {
DWORD available = 0;
if (!WinHttpQueryDataAvailable(req.get(), &available)) {
response.network_error = lastErrorMessage("WinHttpQueryDataAvailable");
return response;
}
if (available == 0)
break;
if (body.size() + available > kMaxResponseBytes) {
response.network_error = "response body exceeded 4 MiB";
return response;
}
std::vector<char> chunk(available);
DWORD read = 0;
if (!WinHttpReadData(req.get(), chunk.data(), available, &read)) {
response.network_error = lastErrorMessage("WinHttpReadData");
return response;
}
if (read == 0)
break;
body.append(chunk.data(), read);
}
response.body = std::move(body);
return response;
}
};
} // namespace
HttpClient *createPlatformHttpClient()
{
return new WinHttpClient();
}
} // namespace stplugin
+442
View File
@@ -0,0 +1,442 @@
/*
streamer-tools OBS Camera Plugin - minimal JSON reader
Copyright (C) 2026 CyberCoveLLC <jknapp85@gmail.com>
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License along
with this program. If not, see <https://www.gnu.org/licenses/>
*/
#include "stplugin/json.h"
#include <cstdlib>
#include <cstring>
namespace stplugin {
namespace json {
namespace {
const Value &invalidSingleton()
{
static const Value v;
return v;
}
} // namespace
Value Value::makeNull()
{
Value v;
v.type_ = Type::Null;
return v;
}
Value Value::makeBool(bool b)
{
Value v;
v.type_ = Type::Bool;
v.bool_ = b;
return v;
}
Value Value::makeNumber(double n)
{
Value v;
v.type_ = Type::Number;
v.number_ = n;
return v;
}
Value Value::makeString(std::string s)
{
Value v;
v.type_ = Type::String;
v.string_ = std::move(s);
return v;
}
Value Value::makeArray(std::vector<Value> a)
{
Value v;
v.type_ = Type::Array;
v.array_ = std::move(a);
return v;
}
Value Value::makeObject(std::map<std::string, Value> o)
{
Value v;
v.type_ = Type::Object;
v.object_ = std::move(o);
return v;
}
const Value &Value::operator[](const std::string &key) const
{
if (type_ != Type::Object)
return invalidSingleton();
auto it = object_.find(key);
if (it == object_.end())
return invalidSingleton();
return it->second;
}
const Value &Value::at(std::size_t index) const
{
if (type_ != Type::Array || index >= array_.size())
return invalidSingleton();
return array_[index];
}
std::size_t Value::size() const
{
if (type_ == Type::Array)
return array_.size();
if (type_ == Type::Object)
return object_.size();
if (type_ == Type::String)
return string_.size();
return 0;
}
std::string Value::asString(const std::string &fallback) const
{
return type_ == Type::String ? string_ : fallback;
}
bool Value::asBool(bool fallback) const
{
return type_ == Type::Bool ? bool_ : fallback;
}
double Value::asNumber(double fallback) const
{
return type_ == Type::Number ? number_ : fallback;
}
// ---------------------------------------------------------------------------
// Parser
// ---------------------------------------------------------------------------
namespace {
class Parser {
public:
explicit Parser(const std::string &text) : s_(text) {}
bool parseDocument(Value &out)
{
skipWs();
if (!parseValue(out, 0))
return false;
skipWs();
// Trailing content is an error: "{}garbage" must not silently parse
// as an empty object.
return pos_ == s_.size();
}
private:
const std::string &s_;
std::size_t pos_ = 0;
bool eof() const { return pos_ >= s_.size(); }
char peek() const { return s_[pos_]; }
void skipWs()
{
while (!eof()) {
const char c = s_[pos_];
if (c == ' ' || c == '\t' || c == '\n' || c == '\r')
++pos_;
else
break;
}
}
bool literal(const char *lit)
{
const std::size_t n = std::strlen(lit);
if (s_.compare(pos_, n, lit) != 0)
return false;
pos_ += n;
return true;
}
bool parseValue(Value &out, int depth)
{
if (depth > kMaxDepth)
return false;
if (eof())
return false;
switch (peek()) {
case '{':
return parseObject(out, depth);
case '[':
return parseArray(out, depth);
case '"': {
std::string str;
if (!parseString(str))
return false;
out = Value::makeString(std::move(str));
return true;
}
case 't':
if (!literal("true"))
return false;
out = Value::makeBool(true);
return true;
case 'f':
if (!literal("false"))
return false;
out = Value::makeBool(false);
return true;
case 'n':
if (!literal("null"))
return false;
out = Value::makeNull();
return true;
default:
return parseNumber(out);
}
}
bool parseObject(Value &out, int depth)
{
++pos_; // '{'
std::map<std::string, Value> members;
skipWs();
if (!eof() && peek() == '}') {
++pos_;
out = Value::makeObject(std::move(members));
return true;
}
for (;;) {
skipWs();
std::string key;
if (!parseString(key))
return false;
skipWs();
if (eof() || peek() != ':')
return false;
++pos_;
skipWs();
Value v;
if (!parseValue(v, depth + 1))
return false;
members[key] = std::move(v);
skipWs();
if (eof())
return false;
if (peek() == ',') {
++pos_;
continue;
}
if (peek() == '}') {
++pos_;
out = Value::makeObject(std::move(members));
return true;
}
return false;
}
}
bool parseArray(Value &out, int depth)
{
++pos_; // '['
std::vector<Value> items;
skipWs();
if (!eof() && peek() == ']') {
++pos_;
out = Value::makeArray(std::move(items));
return true;
}
for (;;) {
skipWs();
Value v;
if (!parseValue(v, depth + 1))
return false;
items.push_back(std::move(v));
skipWs();
if (eof())
return false;
if (peek() == ',') {
++pos_;
continue;
}
if (peek() == ']') {
++pos_;
out = Value::makeArray(std::move(items));
return true;
}
return false;
}
}
bool parseHex4(unsigned &out)
{
if (pos_ + 4 > s_.size())
return false;
unsigned value = 0;
for (int i = 0; i < 4; ++i) {
const char c = s_[pos_ + static_cast<std::size_t>(i)];
unsigned digit;
if (c >= '0' && c <= '9')
digit = static_cast<unsigned>(c - '0');
else if (c >= 'a' && c <= 'f')
digit = static_cast<unsigned>(c - 'a') + 10u;
else if (c >= 'A' && c <= 'F')
digit = static_cast<unsigned>(c - 'A') + 10u;
else
return false;
value = (value << 4) | digit;
}
pos_ += 4;
out = value;
return true;
}
static void appendUtf8(std::string &out, unsigned cp)
{
if (cp < 0x80) {
out.push_back(static_cast<char>(cp));
} else if (cp < 0x800) {
out.push_back(static_cast<char>(0xC0u | (cp >> 6)));
out.push_back(static_cast<char>(0x80u | (cp & 0x3Fu)));
} else if (cp < 0x10000) {
out.push_back(static_cast<char>(0xE0u | (cp >> 12)));
out.push_back(static_cast<char>(0x80u | ((cp >> 6) & 0x3Fu)));
out.push_back(static_cast<char>(0x80u | (cp & 0x3Fu)));
} else {
out.push_back(static_cast<char>(0xF0u | (cp >> 18)));
out.push_back(static_cast<char>(0x80u | ((cp >> 12) & 0x3Fu)));
out.push_back(static_cast<char>(0x80u | ((cp >> 6) & 0x3Fu)));
out.push_back(static_cast<char>(0x80u | (cp & 0x3Fu)));
}
}
bool parseString(std::string &out)
{
if (eof() || peek() != '"')
return false;
++pos_;
out.clear();
for (;;) {
if (eof())
return false; // unterminated string
const unsigned char c = static_cast<unsigned char>(s_[pos_]);
if (c == '"') {
++pos_;
return true;
}
if (c == '\\') {
++pos_;
if (eof())
return false;
const char esc = s_[pos_++];
switch (esc) {
case '"': out.push_back('"'); break;
case '\\': out.push_back('\\'); break;
case '/': out.push_back('/'); break;
case 'b': out.push_back('\b'); break;
case 'f': out.push_back('\f'); break;
case 'n': out.push_back('\n'); break;
case 'r': out.push_back('\r'); break;
case 't': out.push_back('\t'); break;
case 'u': {
unsigned cp = 0;
if (!parseHex4(cp))
return false;
if (cp >= 0xD800 && cp <= 0xDBFF) {
// High surrogate: a low surrogate must follow.
if (pos_ + 1 < s_.size() && s_[pos_] == '\\' && s_[pos_ + 1] == 'u') {
pos_ += 2;
unsigned lo = 0;
if (!parseHex4(lo))
return false;
if (lo < 0xDC00 || lo > 0xDFFF)
return false;
cp = 0x10000u + ((cp - 0xD800u) << 10) + (lo - 0xDC00u);
} else {
return false;
}
} else if (cp >= 0xDC00 && cp <= 0xDFFF) {
return false; // lone low surrogate
}
appendUtf8(out, cp);
break;
}
default:
return false;
}
continue;
}
if (c < 0x20)
return false; // raw control character
out.push_back(static_cast<char>(c));
++pos_;
}
}
bool parseNumber(Value &out)
{
const std::size_t start = pos_;
if (!eof() && peek() == '-')
++pos_;
if (eof())
return false;
if (peek() == '0') {
++pos_;
} else if (peek() >= '1' && peek() <= '9') {
while (!eof() && peek() >= '0' && peek() <= '9')
++pos_;
} else {
return false;
}
if (!eof() && peek() == '.') {
++pos_;
if (eof() || peek() < '0' || peek() > '9')
return false;
while (!eof() && peek() >= '0' && peek() <= '9')
++pos_;
}
if (!eof() && (peek() == 'e' || peek() == 'E')) {
++pos_;
if (!eof() && (peek() == '+' || peek() == '-'))
++pos_;
if (eof() || peek() < '0' || peek() > '9')
return false;
while (!eof() && peek() >= '0' && peek() <= '9')
++pos_;
}
const std::string token = s_.substr(start, pos_ - start);
// strtod is locale-sensitive for the decimal separator, but the
// grammar above only ever hands it ASCII digits with a '.', and OBS
// does not switch the C locale away from "C". Using strtod rather
// than std::stod keeps this noexcept.
out = Value::makeNumber(std::strtod(token.c_str(), nullptr));
return true;
}
};
} // namespace
Value parse(const std::string &text)
{
Parser p(text);
Value v;
if (!p.parseDocument(v))
return Value::invalid();
return v;
}
} // namespace json
} // namespace stplugin
+21 -11
View File
@@ -1,18 +1,28 @@
add_executable(stplugin_core_tests
test_core.cpp
)
target_link_libraries(stplugin_core_tests PRIVATE stplugin_core)
add_test(NAME stplugin_core_tests COMMAND stplugin_core_tests)
# Dependency-free CTest targets (see test_util.h for why there is no gtest).
function(stplugin_add_test name)
add_executable(${name} ${name}.cpp)
target_link_libraries(${name} PRIVATE stplugin_core)
target_include_directories(${name} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
if(WIN32)
# loopback_server.h needs Winsock for the real-backend tests.
target_link_libraries(${name} PRIVATE ws2_32)
endif()
add_test(NAME ${name} COMMAND ${name})
# Nothing here should ever take a minute; a hang is a failure, not a
# reason for CI to sit for its default 1500s.
set_tests_properties(${name} PROPERTIES TIMEOUT 120)
endfunction()
stplugin_add_test(test_core)
stplugin_add_test(test_json)
stplugin_add_test(test_api_client)
# Smoke test for the LiveKit SDK link: initialize()/shutdown() must succeed
# in-process. This is the cheapest possible proof that LiveKit::livekit is
# not just linked but loadable and callable (it dlopen-chains into
# liblivekit_ffi, which is where a broken RPATH would show up).
add_executable(stplugin_livekit_smoke
test_livekit_smoke.cpp
)
target_link_libraries(stplugin_livekit_smoke PRIVATE stplugin_core)
target_compile_definitions(stplugin_livekit_smoke PRIVATE
stplugin_add_test(test_livekit_smoke)
target_compile_definitions(test_livekit_smoke PRIVATE
STPLUGIN_EXPECTED_LIVEKIT_VERSION="${LIVEKIT_SDK_VERSION_RESOLVED}"
)
add_test(NAME stplugin_livekit_smoke COMMAND stplugin_livekit_smoke)
+246
View File
@@ -0,0 +1,246 @@
/*
streamer-tools OBS Camera Plugin - minimal loopback HTTP server for tests
Copyright (C) 2026 CyberCoveLLC <jknapp85@gmail.com>
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License along
with this program. If not, see <https://www.gnu.org/licenses/>
*/
#pragma once
// A single-threaded, one-request-at-a-time HTTP/1.1 server on 127.0.0.1, used
// to exercise the *real* platform HTTP backend (libcurl on Linux/macOS,
// WinHTTP on Windows) rather than only a fake. The handler returns raw bytes,
// so tests can serve deliberately malformed responses and half-closed
// connections -- the cases that must not hang or crash OBS.
//
// Plain HTTP only: a TLS listener would need a certificate and would test
// libcurl/SChannel rather than this plugin.
#include <atomic>
#include <cstdlib>
#include <cstring>
#include <functional>
#include <mutex>
#include <string>
#include <thread>
#ifdef _WIN32
#include <winsock2.h>
#include <ws2tcpip.h>
using st_socket_t = SOCKET;
#define ST_INVALID_SOCKET INVALID_SOCKET
#define ST_CLOSE_SOCKET closesocket
#else
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/select.h>
#include <sys/socket.h>
#include <unistd.h>
using st_socket_t = int;
#define ST_INVALID_SOCKET (-1)
#define ST_CLOSE_SOCKET ::close
#endif
namespace sttest {
/// Returns raw response bytes for a received raw request. Returning an empty
/// string means "close the connection without replying".
using LoopbackHandler = std::function<std::string(const std::string &request)>;
class LoopbackServer {
public:
explicit LoopbackServer(LoopbackHandler handler) : handler_(std::move(handler))
{
#ifdef _WIN32
WSADATA wsa;
WSAStartup(MAKEWORD(2, 2), &wsa);
#endif
listen_ = ::socket(AF_INET, SOCK_STREAM, 0);
if (listen_ == ST_INVALID_SOCKET)
return;
int reuse = 1;
::setsockopt(listen_, SOL_SOCKET, SO_REUSEADDR, reinterpret_cast<const char *>(&reuse), sizeof(reuse));
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
addr.sin_port = 0; // let the OS pick a free port
if (::bind(listen_, reinterpret_cast<sockaddr *>(&addr), sizeof(addr)) != 0) {
ST_CLOSE_SOCKET(listen_);
listen_ = ST_INVALID_SOCKET;
return;
}
if (::listen(listen_, 4) != 0) {
ST_CLOSE_SOCKET(listen_);
listen_ = ST_INVALID_SOCKET;
return;
}
sockaddr_in bound{};
#ifdef _WIN32
int len = sizeof(bound);
#else
socklen_t len = sizeof(bound);
#endif
if (::getsockname(listen_, reinterpret_cast<sockaddr *>(&bound), &len) != 0) {
ST_CLOSE_SOCKET(listen_);
listen_ = ST_INVALID_SOCKET;
return;
}
port_ = ntohs(bound.sin_port);
thread_ = std::thread([this] { run(); });
}
~LoopbackServer()
{
stop_.store(true);
if (thread_.joinable())
thread_.join();
if (listen_ != ST_INVALID_SOCKET)
ST_CLOSE_SOCKET(listen_);
#ifdef _WIN32
WSACleanup();
#endif
}
LoopbackServer(const LoopbackServer &) = delete;
LoopbackServer &operator=(const LoopbackServer &) = delete;
bool valid() const { return listen_ != ST_INVALID_SOCKET; }
int port() const { return port_; }
std::string baseUrl() const { return "http://127.0.0.1:" + std::to_string(port_); }
int requestCount() const { return requests_.load(); }
/// The most recent raw request, for asserting on method/path/body.
std::string lastRequest() const
{
std::lock_guard<std::mutex> guard(mutex_);
return last_request_;
}
private:
void run()
{
while (!stop_.load()) {
// select() with a short timeout rather than a blocking accept(),
// so the destructor's stop flag is honoured promptly on every
// platform (closing a socket another thread is blocked in
// accept() on is not portable).
fd_set readable;
FD_ZERO(&readable);
FD_SET(listen_, &readable);
timeval tv{};
tv.tv_sec = 0;
tv.tv_usec = 50000; // 50ms
const int ready = ::select(static_cast<int>(listen_) + 1, &readable, nullptr, nullptr, &tv);
if (ready <= 0)
continue;
st_socket_t client = ::accept(listen_, nullptr, nullptr);
if (client == ST_INVALID_SOCKET)
continue;
const std::string request = readRequest(client);
{
std::lock_guard<std::mutex> guard(mutex_);
last_request_ = request;
}
requests_.fetch_add(1);
const std::string response = handler_ ? handler_(request) : std::string();
if (!response.empty())
sendAll(client, response);
ST_CLOSE_SOCKET(client);
}
}
static std::string readRequest(st_socket_t client)
{
std::string data;
char buffer[4096];
std::size_t header_end = std::string::npos;
long content_length = 0;
for (;;) {
#ifdef _WIN32
const int n = ::recv(client, buffer, static_cast<int>(sizeof(buffer)), 0);
#else
const ssize_t n = ::recv(client, buffer, sizeof(buffer), 0);
#endif
if (n <= 0)
break;
data.append(buffer, static_cast<std::size_t>(n));
if (header_end == std::string::npos) {
header_end = data.find("\r\n\r\n");
if (header_end != std::string::npos)
content_length = parseContentLength(data.substr(0, header_end));
}
if (header_end != std::string::npos &&
data.size() >= header_end + 4 + static_cast<std::size_t>(content_length))
break;
}
return data;
}
static long parseContentLength(const std::string &headers)
{
std::string lower;
lower.reserve(headers.size());
for (char c : headers)
lower.push_back(static_cast<char>(c >= 'A' && c <= 'Z' ? c + 32 : c));
const std::size_t at = lower.find("content-length:");
if (at == std::string::npos)
return 0;
return std::strtol(headers.c_str() + at + 15, nullptr, 10);
}
static void sendAll(st_socket_t client, const std::string &data)
{
std::size_t sent = 0;
while (sent < data.size()) {
#ifdef _WIN32
const int n = ::send(client, data.data() + sent, static_cast<int>(data.size() - sent), 0);
#else
const ssize_t n = ::send(client, data.data() + sent, data.size() - sent, 0);
#endif
if (n <= 0)
return;
sent += static_cast<std::size_t>(n);
}
}
LoopbackHandler handler_;
st_socket_t listen_ = ST_INVALID_SOCKET;
int port_ = 0;
std::thread thread_;
std::atomic<bool> stop_{false};
std::atomic<int> requests_{0};
mutable std::mutex mutex_;
std::string last_request_;
};
/// Build a well-formed HTTP/1.1 response with an explicit Content-Length and
/// Connection: close, so the client never waits for keep-alive reuse.
inline std::string httpResponse(int status, const std::string &reason, const std::string &body,
const std::string &content_type = "application/json")
{
return "HTTP/1.1 " + std::to_string(status) + " " + reason + "\r\n" + "Content-Type: " + content_type +
"\r\n" + "Content-Length: " + std::to_string(body.size()) + "\r\n" + "Connection: close\r\n\r\n" +
body;
}
} // namespace sttest
+434
View File
@@ -0,0 +1,434 @@
/*
streamer-tools OBS Camera Plugin - API client tests
Copyright (C) 2026 CyberCoveLLC <jknapp85@gmail.com>
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License along
with this program. If not, see <https://www.gnu.org/licenses/>
*/
// Two layers of coverage:
// 1. a fake HttpClient, for response parsing and every error branch;
// 2. a real loopback HTTP server driven through the *platform* backend
// (libcurl or WinHTTP), so the backend itself is exercised in CI on all
// three runners rather than assumed to work.
#include <chrono>
#include <memory>
#include <string>
#include <thread>
#include <vector>
#include "stplugin/api_client.h"
#include "stplugin/http.h"
#include "loopback_server.h"
#include "test_util.h"
using namespace stplugin;
namespace {
class FakeHttpClient : public HttpClient {
public:
HttpResponse next;
HttpRequest last;
int calls = 0;
HttpResponse send(const HttpRequest &request) override
{
last = request;
++calls;
return next;
}
};
ConnectionConfig testConfig()
{
return ConnectionConfig{"https://streamers.example.com", "main-room", "readkey123"};
}
std::shared_ptr<FakeHttpClient> makeFake(long status, const std::string &body)
{
auto fake = std::make_shared<FakeHttpClient>();
fake->next.status = status;
fake->next.body = body;
return fake;
}
// ---------------------------------------------------------------------------
// URL handling
// ---------------------------------------------------------------------------
void testNormalizeServerUrl()
{
ST_ASSERT_EQ(ApiClient::normalizeServerUrl("https://a.example.com"), std::string("https://a.example.com"));
ST_ASSERT_EQ(ApiClient::normalizeServerUrl("https://a.example.com/"), std::string("https://a.example.com"));
ST_ASSERT_EQ(ApiClient::normalizeServerUrl("https://a.example.com///"), std::string("https://a.example.com"));
ST_ASSERT_EQ(ApiClient::normalizeServerUrl(" https://a.example.com "), std::string("https://a.example.com"));
// No scheme defaults to https, never http: the read key is a credential.
ST_ASSERT_EQ(ApiClient::normalizeServerUrl("a.example.com"), std::string("https://a.example.com"));
// An explicit http:// is honoured -- the test LXC is reachable that way.
ST_ASSERT_EQ(ApiClient::normalizeServerUrl("http://192.168.1.175:3000"), std::string("http://192.168.1.175:3000"));
ST_ASSERT_EQ(ApiClient::normalizeServerUrl(""), std::string(""));
ST_ASSERT_EQ(ApiClient::normalizeServerUrl(" "), std::string(""));
ST_ASSERT_EQ(ApiClient::normalizeServerUrl("https://"), std::string(""));
ST_ASSERT_EQ(ApiClient::normalizeServerUrl("/"), std::string(""));
}
void testUrlEncodeAndRedaction()
{
ST_ASSERT_EQ(urlEncode("plain-slug_1.0~"), std::string("plain-slug_1.0~"));
ST_ASSERT_EQ(urlEncode("a b"), std::string("a%20b"));
ST_ASSERT_EQ(urlEncode("a/b?c=d&e"), std::string("a%2Fb%3Fc%3Dd%26e"));
ST_ASSERT_EQ(urlEncode("k\xc3\xa9y"), std::string("k%C3%A9y"));
ST_ASSERT_EQ(ApiClient::redactedUrl("https://h/api/obs/r/slots?key=secret"),
std::string("https://h/api/obs/r/slots?key=***"));
ST_ASSERT_EQ(ApiClient::redactedUrl("https://h/api/obs/r/slots?key=secret&x=1"),
std::string("https://h/api/obs/r/slots?key=***&x=1"));
ST_ASSERT_EQ(ApiClient::redactedUrl("https://h/nothing"), std::string("https://h/nothing"));
}
void testRequestShape()
{
auto fake = makeFake(200, R"({"slots":[]})");
ApiClient client(fake);
ConnectionConfig config = testConfig();
// Values that need encoding, and stray whitespace an operator would paste.
config.room_slug = " main room ";
config.read_key = " a+b/c ";
(void)client.fetchSlots(config);
ST_ASSERT_EQ(fake->last.method, std::string("GET"));
ST_ASSERT_EQ(fake->last.url,
std::string("https://streamers.example.com/api/obs/main%20room/slots?key=a%2Bb%2Fc"));
auto fake2 = makeFake(200, R"({"lkToken":"t","wsUrl":"wss://x","identity":"obs:r:1"})");
ApiClient client2(fake2);
(void)client2.requestToken(testConfig());
ST_ASSERT_EQ(fake2->last.method, std::string("POST"));
ST_ASSERT_EQ(fake2->last.url,
std::string("https://streamers.example.com/api/obs/main-room/token?key=readkey123"));
ST_ASSERT_EQ(fake2->last.content_type, std::string("application/json"));
}
// ---------------------------------------------------------------------------
// Response parsing
// ---------------------------------------------------------------------------
void testSlotsHappyPath()
{
auto fake = makeFake(200,
R"({"slots":[{"identity":"cam1","displayName":"Alice","live":true},)"
R"({"identity":"cam2","displayName":"Bob","live":false}]})");
ApiClient client(fake);
const SlotsResult result = client.fetchSlots(testConfig());
ST_ASSERT(result.ok());
ST_ASSERT_EQ(result.slots.size(), std::size_t(2));
ST_ASSERT_EQ(result.slots[0].identity, std::string("cam1"));
ST_ASSERT_EQ(result.slots[0].display_name, std::string("Alice"));
ST_ASSERT_EQ(result.slots[0].live, true);
ST_ASSERT_EQ(result.slots[1].live, false);
}
void testSlotsEdgeCases()
{
// Empty room: a valid answer, not an error.
{
ApiClient client(makeFake(200, R"({"slots":[]})"));
const SlotsResult result = client.fetchSlots(testConfig());
ST_ASSERT(result.ok());
ST_ASSERT_EQ(result.slots.size(), std::size_t(0));
}
// A missing/blank displayName falls back to the identity, matching what
// the server itself does for a slot with no display_name.
{
ApiClient client(makeFake(200, R"({"slots":[{"identity":"cam1"},{"identity":"cam2","displayName":""}]})"));
const SlotsResult result = client.fetchSlots(testConfig());
ST_ASSERT(result.ok());
ST_ASSERT_EQ(result.slots.size(), std::size_t(2));
ST_ASSERT_EQ(result.slots[0].display_name, std::string("cam1"));
ST_ASSERT_EQ(result.slots[1].display_name, std::string("cam2"));
ST_ASSERT_EQ(result.slots[0].live, false); // missing `live` is not live
}
// An entry with no identity is unusable and is dropped, not surfaced as a
// dropdown row that could never connect.
{
ApiClient client(makeFake(200, R"({"slots":[{"displayName":"ghost"},{"identity":"cam1"}]})"));
const SlotsResult result = client.fetchSlots(testConfig());
ST_ASSERT(result.ok());
ST_ASSERT_EQ(result.slots.size(), std::size_t(1));
ST_ASSERT_EQ(result.slots[0].identity, std::string("cam1"));
}
// Wrong types where the shape is otherwise right: don't crash, don't
// invent values.
{
ApiClient client(makeFake(200, R"({"slots":[{"identity":"cam1","displayName":42,"live":"yes"}]})"));
const SlotsResult result = client.fetchSlots(testConfig());
ST_ASSERT(result.ok());
ST_ASSERT_EQ(result.slots.size(), std::size_t(1));
ST_ASSERT_EQ(result.slots[0].display_name, std::string("cam1"));
ST_ASSERT_EQ(result.slots[0].live, false);
}
}
void testTokenHappyPath()
{
ApiClient client(makeFake(200,
R"({"lkToken":"eyJhbGciOiJIUzI1NiJ9.abc.def",)"
R"("wsUrl":"wss://streamers.example.com","identity":"obs:main-room:Ab_1"})"));
const TokenResult result = client.requestToken(testConfig());
ST_ASSERT(result.ok());
ST_ASSERT_EQ(result.lk_token, std::string("eyJhbGciOiJIUzI1NiJ9.abc.def"));
ST_ASSERT_EQ(result.ws_url, std::string("wss://streamers.example.com"));
ST_ASSERT_EQ(result.identity, std::string("obs:main-room:Ab_1"));
}
void testHttpErrorStatuses()
{
// 404 -- a wrong read key and an unknown slug are deliberately
// indistinguishable server-side, so the message must not claim to know.
{
ApiClient client(makeFake(404, R"({"error":"not found"})"));
const SlotsResult slots = client.fetchSlots(testConfig());
ST_ASSERT(!slots.ok());
ST_ASSERT(slots.status == ApiStatus::NotFound);
ST_ASSERT_EQ(slots.slots.size(), std::size_t(0));
const TokenResult token = client.requestToken(testConfig());
ST_ASSERT(token.status == ApiStatus::NotFound);
ST_ASSERT_EQ(token.lk_token, std::string(""));
}
// 503 -- server reachable, LiveKit not configured.
{
ApiClient client(makeFake(503, R"({"error":"livekit not configured"})"));
ST_ASSERT(client.fetchSlots(testConfig()).status == ApiStatus::Unavailable);
ST_ASSERT(client.requestToken(testConfig()).status == ApiStatus::Unavailable);
}
// Anything else, e.g. a reverse proxy answering before the app does.
{
ApiClient client(makeFake(502, "<html>502 Bad Gateway</html>"));
const SlotsResult result = client.fetchSlots(testConfig());
ST_ASSERT(result.status == ApiStatus::HttpError);
ST_ASSERT_EQ(result.message, std::string("HTTP 502"));
}
{
ApiClient client(makeFake(401, ""));
ST_ASSERT(client.fetchSlots(testConfig()).status == ApiStatus::HttpError);
}
}
void testMalformedSuccessBodies()
{
// 200 with a body that is not the expected shape must be reported, not
// silently treated as "no slots".
const char *bad_slots[] = {
"",
"not json at all",
"{}",
R"({"slots":null})",
R"({"slots":{}})",
R"({"slots":"cam1"})",
"[]",
R"({"slots":[)",
"<!DOCTYPE html><html>login page</html>",
};
for (const char *body : bad_slots) {
ApiClient client(makeFake(200, body));
const SlotsResult result = client.fetchSlots(testConfig());
ST_ASSERT(result.status == ApiStatus::MalformedResponse);
ST_ASSERT_EQ(result.slots.size(), std::size_t(0));
}
const char *bad_token[] = {
"",
"{}",
R"({"lkToken":""})",
R"({"lkToken":"t"})", // no wsUrl
R"({"wsUrl":"wss://x"})", // no token
R"({"lkToken":123,"wsUrl":"wss://x"})", // wrong type
R"({"lkToken":"t","wsUrl":""})",
"[]",
"\xff\xfe binary",
};
for (const char *body : bad_token) {
ApiClient client(makeFake(200, body));
const TokenResult result = client.requestToken(testConfig());
ST_ASSERT(result.status == ApiStatus::MalformedResponse);
ST_ASSERT_EQ(result.lk_token, std::string(""));
}
}
void testNetworkErrorAndInvalidConfig()
{
{
auto fake = std::make_shared<FakeHttpClient>();
fake->next.network_error = "Could not resolve host";
ApiClient client(fake);
const SlotsResult result = client.fetchSlots(testConfig());
ST_ASSERT(result.status == ApiStatus::NetworkError);
ST_ASSERT_EQ(result.message, std::string("Could not resolve host"));
}
// An incomplete config must never reach the HTTP layer at all.
{
auto fake = makeFake(200, R"({"slots":[]})");
ApiClient client(fake);
ST_ASSERT(client.fetchSlots(ConnectionConfig{"", "r", "k"}).status == ApiStatus::InvalidConfig);
ST_ASSERT(client.fetchSlots(ConnectionConfig{"https://h", "", "k"}).status == ApiStatus::InvalidConfig);
ST_ASSERT(client.fetchSlots(ConnectionConfig{"https://h", "r", ""}).status == ApiStatus::InvalidConfig);
ST_ASSERT(client.requestToken(ConnectionConfig{"https://", "r", "k"}).status == ApiStatus::InvalidConfig);
ST_ASSERT_EQ(fake->calls, 0);
}
// A null HTTP client is a programming error, not a crash.
{
ApiClient client(nullptr);
ST_ASSERT(client.fetchSlots(testConfig()).status == ApiStatus::InvalidConfig);
}
}
// ---------------------------------------------------------------------------
// Real platform HTTP backend, against a real loopback socket
// ---------------------------------------------------------------------------
ConnectionConfig loopbackConfig(const sttest::LoopbackServer &server)
{
return ConnectionConfig{server.baseUrl(), "main-room", "readkey123"};
}
void testPlatformBackendAgainstLoopback()
{
std::shared_ptr<HttpClient> http(createPlatformHttpClient());
ST_ASSERT(http != nullptr);
if (!http)
return;
ApiClient client(http);
// 200 with real slots, and the request line/headers the server sees.
{
sttest::LoopbackServer server([](const std::string &) {
return sttest::httpResponse(200, "OK",
R"({"slots":[{"identity":"cam1","displayName":"Alice","live":true}]})");
});
ST_ASSERT(server.valid());
const SlotsResult result = client.fetchSlots(loopbackConfig(server));
ST_ASSERT(result.ok());
ST_ASSERT_EQ(result.slots.size(), std::size_t(1));
ST_ASSERT_EQ(result.slots[0].identity, std::string("cam1"));
ST_ASSERT(server.lastRequest().find("GET /api/obs/main-room/slots?key=readkey123") == 0);
}
// POST /token: verify the method and that a body is actually sent.
{
sttest::LoopbackServer server([](const std::string &) {
return sttest::httpResponse(200, "OK", R"({"lkToken":"tok","wsUrl":"wss://lk.example","identity":"obs:r:1"})");
});
ST_ASSERT(server.valid());
const TokenResult result = client.requestToken(loopbackConfig(server));
ST_ASSERT(result.ok());
ST_ASSERT_EQ(result.lk_token, std::string("tok"));
ST_ASSERT_EQ(result.ws_url, std::string("wss://lk.example"));
ST_ASSERT(server.lastRequest().find("POST /api/obs/main-room/token?key=readkey123") == 0);
}
// 404 and 503 over a real socket.
{
sttest::LoopbackServer server([](const std::string &) {
return sttest::httpResponse(404, "Not Found", R"({"error":"not found"})");
});
ST_ASSERT(client.fetchSlots(loopbackConfig(server)).status == ApiStatus::NotFound);
}
{
sttest::LoopbackServer server([](const std::string &) {
return sttest::httpResponse(503, "Service Unavailable", R"({"error":"livekit not configured"})");
});
ST_ASSERT(client.requestToken(loopbackConfig(server)).status == ApiStatus::Unavailable);
}
// 200 with a truncated JSON body: must be MalformedResponse, not a hang
// and not a crash.
{
sttest::LoopbackServer server([](const std::string &) {
return sttest::httpResponse(200, "OK", R"({"slots":[{"identity":)");
});
ST_ASSERT(client.fetchSlots(loopbackConfig(server)).status == ApiStatus::MalformedResponse);
}
// A server that accepts the connection and closes without replying at
// all. This is a network error, and it must come back promptly.
{
sttest::LoopbackServer server([](const std::string &) { return std::string(); });
const auto start = std::chrono::steady_clock::now();
const SlotsResult result = client.fetchSlots(loopbackConfig(server));
const auto elapsed = std::chrono::steady_clock::now() - start;
ST_ASSERT(result.status == ApiStatus::NetworkError);
ST_ASSERT(std::chrono::duration_cast<std::chrono::seconds>(elapsed).count() < 15);
}
// Garbage that is not HTTP at all.
{
sttest::LoopbackServer server([](const std::string &) { return std::string("\x01\x02not http\r\n\r\n"); });
const SlotsResult result = client.fetchSlots(loopbackConfig(server));
ST_ASSERT(result.status == ApiStatus::NetworkError || result.status == ApiStatus::MalformedResponse ||
result.status == ApiStatus::HttpError);
}
// Nothing listening on the port at all: a clean NetworkError.
{
int dead_port = 0;
{
sttest::LoopbackServer server([](const std::string &) { return std::string(); });
dead_port = server.port();
} // server destroyed, port closed
ConnectionConfig config{"http://127.0.0.1:" + std::to_string(dead_port), "main-room", "readkey123"};
ST_ASSERT(client.fetchSlots(config).status == ApiStatus::NetworkError);
}
}
void testPlatformBackendTimeout()
{
// A server that accepts and then stalls. The plugin must give up on its
// own timeout rather than blocking an OBS thread indefinitely.
sttest::LoopbackServer server([](const std::string &) {
std::this_thread::sleep_for(std::chrono::seconds(5));
return sttest::httpResponse(200, "OK", R"({"slots":[]})");
});
ST_ASSERT(server.valid());
std::shared_ptr<HttpClient> http(createPlatformHttpClient());
HttpRequest request;
request.url = server.baseUrl() + "/api/obs/main-room/slots?key=k";
request.timeout_ms = 700;
const auto start = std::chrono::steady_clock::now();
const HttpResponse response = http->send(request);
const auto elapsed = std::chrono::steady_clock::now() - start;
ST_ASSERT(!response.ok());
ST_ASSERT(std::chrono::duration_cast<std::chrono::milliseconds>(elapsed).count() < 4000);
}
} // namespace
int main()
{
testNormalizeServerUrl();
testUrlEncodeAndRedaction();
testRequestShape();
testSlotsHappyPath();
testSlotsEdgeCases();
testTokenHappyPath();
testHttpErrorStatuses();
testMalformedSuccessBodies();
testNetworkErrorAndInvalidConfig();
testPlatformBackendAgainstLoopback();
testPlatformBackendTimeout();
return st_test_report("api_client");
}
+163
View File
@@ -0,0 +1,163 @@
/*
streamer-tools OBS Camera Plugin - JSON reader tests
Copyright (C) 2026 CyberCoveLLC <jknapp85@gmail.com>
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License along
with this program. If not, see <https://www.gnu.org/licenses/>
*/
#include <string>
#include "stplugin/json.h"
#include "test_util.h"
using stplugin::json::Value;
using stplugin::json::parse;
static void testRealResponses()
{
// The exact shape apps/server/src/obs/plugin.routes.ts returns.
const Value slots = parse(
R"({"slots":[{"identity":"cam1","displayName":"Alice","live":true},)"
R"({"identity":"cam2","displayName":"Bob","live":false}]})");
ST_ASSERT(slots.valid());
ST_ASSERT(slots.isObject());
ST_ASSERT(slots["slots"].isArray());
ST_ASSERT_EQ(slots["slots"].size(), std::size_t(2));
ST_ASSERT_EQ(slots["slots"].at(0)["identity"].asString(), std::string("cam1"));
ST_ASSERT_EQ(slots["slots"].at(0)["displayName"].asString(), std::string("Alice"));
ST_ASSERT_EQ(slots["slots"].at(0)["live"].asBool(), true);
ST_ASSERT_EQ(slots["slots"].at(1)["live"].asBool(true), false);
const Value token = parse(
R"({"lkToken":"eyJhbGciOiJIUzI1NiJ9.abc.def","wsUrl":"wss://streamers.example.com",)"
R"("identity":"obs:main-room:Ab_1-cd2"})");
ST_ASSERT_EQ(token["lkToken"].asString(), std::string("eyJhbGciOiJIUzI1NiJ9.abc.def"));
ST_ASSERT_EQ(token["wsUrl"].asString(), std::string("wss://streamers.example.com"));
ST_ASSERT_EQ(token["identity"].asString(), std::string("obs:main-room:Ab_1-cd2"));
const Value error = parse(R"({"error":"not found"})");
ST_ASSERT_EQ(error["error"].asString(), std::string("not found"));
}
static void testScalarsAndEscapes()
{
ST_ASSERT(parse("null").isNull());
ST_ASSERT_EQ(parse("true").asBool(), true);
ST_ASSERT_EQ(parse("false").asBool(true), false);
ST_ASSERT_EQ(parse("0").asNumber(), 0.0);
ST_ASSERT_EQ(parse("-12").asNumber(), -12.0);
ST_ASSERT_EQ(parse("1.5e2").asNumber(), 150.0);
ST_ASSERT_EQ(parse("\"\"").asString("x"), std::string(""));
ST_ASSERT_EQ(parse(R"("a\"b\\c\/d")").asString(), std::string("a\"b\\c/d"));
ST_ASSERT_EQ(parse(R"("\n\t\r\b\f")").asString(), std::string("\n\t\r\b\f"));
// \u escapes, including a surrogate pair (an emoji in a display name is
// entirely plausible and must not corrupt the dropdown).
ST_ASSERT_EQ(parse(R"("\u0041")").asString(), std::string("A"));
ST_ASSERT_EQ(parse(R"("caf\u00e9")").asString(), std::string("caf\xc3\xa9"));
ST_ASSERT_EQ(parse(R"("\ud83d\ude00")").asString(), std::string("\xf0\x9f\x98\x80"));
// Whitespace everywhere legal.
ST_ASSERT_EQ(parse(" {\n \"a\" :\t[ 1 , 2 ]\r\n} ")["a"].size(), std::size_t(2));
}
static void testMalformedIsRejectedNotCrashed()
{
const char *bad[] = {
"",
" ",
"{",
"}",
"[",
"[1,",
"[1,]",
"{\"a\"}",
"{\"a\":}",
"{\"a\":1,}",
"{a:1}",
"{'a':1}",
"\"unterminated",
"\"bad\\escape\"",
"\"\\u00\"",
"\"\\uZZZZ\"",
"\"\\ud83d\"", // lone high surrogate
"\"\\ude00\"", // lone low surrogate
"01", // leading zero
"+1",
".5",
"1.",
"1e",
"1e+",
"tru",
"nulll",
"{}garbage", // trailing content
"[1,2] [3]",
"\"raw\ncontrol\"", // literal control char inside a string
"\xff\xfe", // binary garbage, e.g. an HTML error page prefix
"<!DOCTYPE html><html><body>502 Bad Gateway</body></html>",
};
for (const char *text : bad) {
const Value v = parse(text);
ST_ASSERT(!v.valid());
// Accessors on an invalid value must still be safe and return the
// caller's fallback.
ST_ASSERT_EQ(v["anything"].asString("fallback"), std::string("fallback"));
ST_ASSERT_EQ(v.at(0).asNumber(-1.0), -1.0);
ST_ASSERT_EQ(v.size(), std::size_t(0));
}
}
static void testDepthLimit()
{
// Deep-but-legal nesting is rejected rather than recursed into, so a
// hostile response cannot overflow the stack inside OBS.
std::string deep;
const int depth = stplugin::json::kMaxDepth + 50;
for (int i = 0; i < depth; ++i)
deep += "[";
for (int i = 0; i < depth; ++i)
deep += "]";
ST_ASSERT(!parse(deep).valid());
// Just inside the limit still parses.
std::string shallow;
for (int i = 0; i < stplugin::json::kMaxDepth - 1; ++i)
shallow += "[";
shallow += "1";
for (int i = 0; i < stplugin::json::kMaxDepth - 1; ++i)
shallow += "]";
ST_ASSERT(parse(shallow).valid());
}
static void testWrongTypesFallBack()
{
const Value v = parse(R"({"n":5,"s":"x","b":true,"arr":[1],"obj":{}})");
ST_ASSERT_EQ(v["n"].asString("fallback"), std::string("fallback"));
ST_ASSERT_EQ(v["s"].asNumber(-1.0), -1.0);
ST_ASSERT_EQ(v["s"].asBool(true), true);
ST_ASSERT_EQ(v["missing"].asString("fallback"), std::string("fallback"));
ST_ASSERT_EQ(v["arr"].at(5).asNumber(-1.0), -1.0);
ST_ASSERT_EQ(v["obj"].at(0).asNumber(-1.0), -1.0);
ST_ASSERT_EQ(v["n"]["deeper"].asString("fallback"), std::string("fallback"));
}
int main()
{
testRealResponses();
testScalarsAndEscapes();
testMalformedIsRejectedNotCrashed();
testDepthLimit();
testWrongTypesFallBack();
return st_test_report("json");
}