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
+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