Skip to content
Get Started

Development

Building a Vendor Client

Implement a new health-data source as a C++ vendor::Vendor: the class, the factory, and the registry entry.

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.

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.

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.

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:

src/health/vendor/device/acme.cpp
#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));
}
}}

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().

OperationImplement when…Otherwise
info()always — populate VendorInfo fully(required)
fetch(user_id, domain, start, end)the data endpoint(s) are documentedstub
authorize_url(redirect_uri, state, user_id, provider)consent is a plain redirect URL you can buildstub (e.g. stateful PKCE belongs elsewhere)
handle_webhook(raw_headers, body)the signature scheme is publicstub if unsigned / undocumented
revoke(user_id, provider)a disconnect endpoint is documentedstub
list_providers(user_id)the source has a provider cataloguestub / N/A for single brands
exchange_code(code, redirect_uri)the source has an OAuth2 authorization-code token endpoint — exchange the code for a TokenSetstub
refresh(refresh_token)the source issues expiring tokens with a refresh grant — trade the refresh token for a fresh TokenSetstub (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_url and throw if it’s empty (like the ehr client).
  • 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.

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 set

A 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.

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_URL

VendorConfig::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.)

Terminal window
./build.sh
./build/mirobody # your vendor is now in the registry

Then verify by hand against a running server — see Provider Testing.