Development
Building a Vendor Client
Implement a new health-data source as a C++ vendor::Vendor: the class, the factory, and the registry entry.
Overview
Section titled “Overview”Adding a health-data source to Mirobody means writing a C++ vendor::Vendor — one .cpp file under src/health/vendor/, plus one line in the registry. Vendors are compiled in and resolved by id.
Prerequisites
Section titled “Prerequisites”Build toolchain
A working C++11 build of the engine (./build.sh). See Contributing.
The source's public API
The vendor’s public API reference: base URL, auth scheme, the endpoint(s) for the data domains you’ll support, and any webhook signature scheme. Implement only what’s publicly confirmable — leave the rest an honest stub.
Credentials
An API key or OAuth client_id / client_secret, delivered to VendorConfig through the per-vendor <ID>_* config keys (e.g. ACME_CLIENT_ID), set in config.yml or as same-named env vars.
The three edits
Section titled “The three edits”Create the vendor file
Pick the right bucket directory: platform/ (B2B aggregator), device/ (consumer brand), phone/ (smartphone-vendor cloud API), or ehr/. Create src/health/vendor/<bucket>/<id>.cpp.
Add it to the CMake source list
Vendor files are listed explicitly, not globbed. Add your .cpp beside the sibling vendor entries in MIROBODY_CORE_SOURCES in CMakeLists.txt — the block right after src/health/vendor/registry.cpp.
Register it
Declare the factory and add a row in src/health/vendor/registry.cpp.
The vendor class
Section titled “The vendor class”A vendor is an anonymous-namespace class deriving from VendorBase, plus a free make_<id>() factory. It overrides only the operations it implements; everything else inherits VendorBase’s “not implemented” stub. Skeleton:
#include "health/vendor/vendor.hpp"#include "client/http_client.hpp"
#include <string>#include <utility>
namespace mirobody { namespace vendor {namespace {
class Acme : public VendorBase {public: explicit Acme(VendorConfig cfg) : VendorBase(make_info(), std::move(cfg)) {}
// Fetch a normalized DataDomain over [start_iso, end_iso]; return the vendor's JSON. std::string fetch(const std::string& user_id, DataDomain domain, const std::string& start_iso, const std::string& end_iso) override { require_token(); const std::string url = base_url() + endpoint(domain, user_id, start_iso, end_iso); const std::vector<std::string> headers = { "Authorization: Bearer " + config().api_key, "Accept: application/json", }; client::HttpResponse res = client::HttpClient().get(url, /*timeout_ms=*/30000, headers); if (res.status < 200 || res.status >= 300) { throw VendorError(info_.id + ": fetch failed (HTTP " + std::to_string(res.status) + "): " + res.body.substr(0, 300)); } return res.body; }
// Implement authorize_url / handle_webhook / revoke / list_providers ONLY if the // contract is publicly documented. Otherwise leave them as inherited stubs.
private: static std::string endpoint(DataDomain d, const std::string& u, const std::string& s, const std::string& e) { switch (d) { case DataDomain::HeartRate: return "/v1/users/" + u + "/heart?from=" + s + "&to=" + e; // … the domains this source actually brokers … default: throw VendorError(std::string("acme: unsupported domain '") + to_string(d) + "'"); } }
std::string base_url() const { std::string b = config().base_url.empty() ? std::string("https://api.acme.example") : config().base_url; while (!b.empty() && b.back() == '/') b.pop_back(); return b; }
void require_token() const { if (config().api_key.empty()) { throw VendorError(info_.id + ": api_key is required — set ACME_API_KEY"); } }
static VendorInfo make_info() { VendorInfo i; i.id = "acme"; i.display_name = "Acme Wearables"; i.positioning = "Consumer wearable brand with a public OAuth2 REST API"; i.data_source_coverage = "Acme bands and watches"; i.integration_method = "REST (OAuth2)"; i.docs_url = "https://developer.acme.example/"; i.region = Region::Global; i.open_source = false; i.domains = {DataDomain::Activity, DataDomain::HeartRate, DataDomain::Sleep}; i.integrations = {Integration::Rest}; return i; }};
} // namespace
std::unique_ptr<Vendor> make_acme(const VendorConfig& cfg) { return std::unique_ptr<Vendor>(new Acme(cfg));}
}}Register the factory
Section titled “Register the factory”In registry.cpp, declare the factory alongside the others and add one row to the kVendors table (matrix order):
// forward declaration (top of registry.cpp)std::unique_ptr<Vendor> make_acme(const VendorConfig&);
// … inside the kVendors[] table …{"acme", &make_acme},That’s the whole wiring: open_vendor("acme", cfg) now constructs it, and it appears in vendor_ids() / all_vendor_info().
The contract you’re implementing
Section titled “The contract you’re implementing”| Operation | Implement when… | Otherwise |
|---|---|---|
info() | always — populate VendorInfo fully | (required) |
fetch(user_id, domain, start, end) | the data endpoint(s) are documented | stub |
authorize_url(redirect_uri, state, user_id, provider) | consent is a plain redirect URL you can build | stub (e.g. stateful PKCE belongs elsewhere) |
handle_webhook(raw_headers, body) | the signature scheme is public | stub if unsigned / undocumented |
revoke(user_id, provider) | a disconnect endpoint is documented | stub |
list_providers(user_id) | the source has a provider catalogue | stub / N/A for single brands |
exchange_code(code, redirect_uri) | the source has an OAuth2 authorization-code token endpoint — exchange the code for a TokenSet | stub |
refresh(refresh_token) | the source issues expiring tokens with a refresh grant — trade the refresh token for a fresh TokenSet | stub (e.g. Polar’s tokens never expire) |
For standard RFC 6749 token endpoints, don’t hand-roll the POST: the shared helper oauth2_token_request(...) in src/health/vendor/oauth2.hpp does the form-encoded request (body or HTTP Basic client auth) and parses the {access_token, refresh_token?, expires_in?} response — Fitbit, Dexcom, Oura, Whoop, and Polar all implement exchange_code / refresh through it (Withings has its own envelope).
Rules of the road:
- Never invent a base URL or endpoint. If the host is per-deployment, require
base_urland throw if it’s empty (like theehrclient). - Record why any operation is a stub in the file header — one of: gated/undocumented, contract mismatch, no such endpoint.
- Flag inferred field names with a confirmed-vs-inferred comment if the vendor’s reference is gated.
- Return the vendor’s JSON from
fetch(or FHIR JSON for FHIR-native sources). Normalization is a downstream concern.
Data mapping
Section titled “Data mapping”Three separate concerns hide behind “mapping”, and only the first is yours to write.
1 — Domain mapping. Every fetch takes one normalized DataDomain, and each vendor maps it onto the endpoint(s) it actually brokers:
enum class DataDomain { Activity, Sleep, HeartRate, Glucose, Nutrition, BodyMetrics, Labs, Clinical};A REST vendor switches on the domain to pick a path — that’s the endpoint() helper in the skeleton above. A FHIR-native vendor maps the domain onto the FHIR Observation category token instead (the ehr SMART-on-FHIR client):
case DataDomain::HeartRate: return "vital-signs";case DataDomain::BodyMetrics: return "vital-signs";case DataDomain::Glucose: return "laboratory";case DataDomain::Labs: return "laboratory";case DataDomain::Activity: return "activity";case DataDomain::Clinical: return nullptr; // unfiltered: the full observation setA vendor throws for domains it doesn’t broker rather than returning empty — the caller learns the source’s scope instead of an ambiguous nothing.
2 — Unit normalization. Free-text units fold to canonical UCUM in src/fhir/units/. Shipped, and not your concern in fetch — see File Processing.
3 — FHIR representation. The canonical store is FHIR R4. A measurement is an Observation with a normalized valueQuantity:
{ "resourceType": "Observation", "status": "final", "category": [{ "coding": [{ "system": "http://terminology.hl7.org/CodeSystem/observation-category", "code": "vital-signs" }] }], "code": { "text": "Heart rate" }, "valueQuantity": { "value": 72, "unit": "/min", "system": "http://unitsofmeasure.org", "code": "/min" }}So for a new source: declare the domains in VendorInfo.domains, map each one in fetch, throw for the rest, and return the source’s own JSON — normalization and FHIR conversion are downstream, shared concerns you should not pre-empt.
Config convention
Section titled “Config convention”The server builds VendorConfig from the central config, using clean per-vendor keys settable in config.yml or as same-named env vars (see config.example.yml):
ACME_API_KEY / ACME_CLIENT_ID / ACME_CLIENT_SECRET / ACME_BASE_URLVendorConfig::configured() is true when there’s an API key or a client id+secret pair. (The older MIROBODY_VENDOR_<ID>_* env convention survives only in VendorConfig::from_env(), which the standalone vendor CLI still uses.)
Build & verify
Section titled “Build & verify”./build.sh./build/mirobody # your vendor is now in the registryThen verify by hand against a running server — see Provider Testing.
Next steps
Section titled “Next steps”OAuth 2.0 / PKCE patterns in the clients
Domains, UCUM units, and FHIR output
Verify your vendor end-to-end
Real clients against the contract