LiveKit integration: real camera feed pipeline #1
+24
-1
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user