Write a connector
The connector contract, a minimal device connector, registration and fixture tests.
A connector teaches Open Health Sync one provider. It lives in connectors/<slug>/, implements
the contract in internal/connector, and registers itself in init(). The core is not touched.
The contract
// Every connector.
type Connector interface {
Slug() string // "withings", "apple_health"
Enabled(cfg Config) bool // true when its keys are set
// Map turns one raw payload into canonical records. Pure, no I/O.
Map(raw RawRecord) (model.MapResult, error)
}On top of it, implement the interfaces that match how the data arrives. A connector can be several at once; the registry checks which ones it implements.
| Interface | Methods | For |
|---|---|---|
OAuthConnector | AuthorizeURL, Exchange, Refresh, Fetch | providers the server pulls from |
PushConnector | HandleInbound | providers that send notifications |
StateExchanger | ExchangeState | providers that require PKCE: the callback hands the OAuth state to the exchange, so the code verifier can be derived from it (an HMAC under the client secret) instead of stored |
NotifiedFetcher | FetchNotified | pull only what a notification names (its InboundEvent.DataType), to spare the provider's rate limit |
DeviceConnector | ParseUpload | on-device stores uploaded by an SDK |
KeyedConnector | RequiredKeys | operator keys: the connector is off until each is set |
Configurable | Configure | receives its keys once at startup |
PostConnectHook | AfterConnect | work after a user connects, such as subscribing to notifications |
Rules that matter:
Mapis pure and deterministic. The ETL may map the same raw object again after a fix, and content hashes must come out the same, or duplicates appear.- Raw first.
FetchandParseUploadhand provider payloads to aRawSink(or, for uploads, the handler stores the original bytes). They never write canonical tables. The ETL callsMaplater on each stored line. One raw object holds one data type;RawRecord.DataTypetellsMapwhich. - Set
Metadata["timezone"](an IANA name) on every record when the provider gives one. The ETL derives the user's local day from it and falls back to UTC. - Use canonical names and units. Quantity names come from data-model.md. Unknown names still land, unconverted. Give a known name a known unit, or the record is parked as a unit error.
- Content hash each record from the fields that identify it (user, provider, type, time,
value), never from a field that changes between re-sends.
model.ComputeContentHashandmodel.CanonicalFloatdo the work. - Return
connector.ErrNeedsReauthfromRefreshorFetchwhen the provider rejects the grant, andconnector.RateLimited(headers, now)on HTTP 429, so the scheduler backs off without counting a failed attempt. - Never log tokens or health values.
Deleted records
When the provider says one of its records is gone (a sleep the user removed, a deleted
workout), Map returns a model.Deletion{ExternalID: <provider record id>} in
MapResult.Deletions instead of nothing. The ETL then, in the job's transaction and for the
job's tenant and user only:
- removes the sleep sessions (with their phases), workouts (with route points and track
summaries) and nutrition logs stored with that provider and
ExternalID; - removes the quantity and category samples of the record:
ExternalIDequal to the id or<id>_<suffix>(one record giving several samples), whenever they start, and whether or not a sleep or workout of that id is stored (a record that only gave samples is deleted the same way). The lookup uses an index on provider andExternalID, so it does not scan the table; - when the deletion names
ClearSummaryFields(daily summary columns such asrecovery_score,resting_hr), sets those figures to NULL in the provider's daily summaries of the days its removed samples name inMetadata["summary_date"](model.MetadataSummaryDate,YYYY-MM-DD), and deletes a summary left without any figure. Only summary figure names are accepted; any other name is ignored; - records a tombstone in
ohs_data.deleted_records. A record whose id is tombstoned is never written again, so a re-pull, an old raw object processed again, or a deletion that arrives before the record cannot bring it back. Applying a deletion twice is harmless.
So give every record you may later delete a stable ExternalID, and its derived samples the id
or <id>_<suffix>. A deletion is final for that id: do not emit one for a record that can come
back. When a record also fills figures of your daily summary, stamp the summary's day on its
samples (Metadata["summary_date"]), set DailySummary.RecordID to the record id when the
summary carries that record's figures only (a tombstoned record's summary is then not written
again), and list the figures in the deletion's ClearSummaryFields.
Records whose start moves
A Replaceable record is updated in place when its copy comes back under the same content hash
and start. A sleep session or workout whose start moved supersedes the stored copy of the same
ExternalID automatically. A quantity sample does so only when it sets SupersedeMoved: the
writer then deletes the stored samples of the same provider, ExternalID and quantity type
under another start before writing it. Set it only when the ExternalID names one figure of
one record (<record id>_<quantity>), never on samples that share an ExternalID across starts
(a day's heart rate series under one id), or all but one of them would be deleted.
A deletion that arrives as a notification follows the raw-first rule like everything else:
HandleInbound returns an InboundEvent with Lines set (usually the notification body,
verbatim) and a data type of your choosing. The core stores those lines as a raw object for each
connection of the provider user and queues it for the ETL, and Map turns the stored line into
the Deletion. Map stays pure, and the raw store keeps the evidence.
A minimal device connector
connectors/example/ holds a complete, compiling connector for a Bluetooth scale whose SDK
uploads one weigh-in per line. It sits behind the docs build tag so it never ships; check it
with:
go vet -tags docs ./connectors/example/
go test -tags docs ./connectors/example/The heart of it:
const Slug = "example_scale"
func init() { connector.Register(New()) }
type Connector struct{}
func (c *Connector) Slug() string { return Slug }
func (c *Connector) Enabled(connector.Config) bool { return true } // no keys needed
// ParseUpload rejects what the ETL could never read. The upload handler keeps the
// original bytes, so nothing is emitted.
func (c *Connector) ParseUpload(_ connector.UploadMeta, body io.Reader, _ connector.RawSink) error {
// every non-blank line must be a JSON object; at least one line
}
// Map: one stored line in, canonical records out.
func (c *Connector) Map(raw connector.RawRecord) (model.MapResult, error) {
// decode raw.Fields into a weighIn, then:
return model.MapResult{QuantitySamples: []model.QuantitySample{{
UserID: raw.UserID, Provider: Slug, QuantityType: "body_mass",
Value: w.Kilograms, Unit: "kg", StartDate: at, EndDate: at,
Metadata: map[string]any{"timezone": w.Timezone},
ContentHash: model.ComputeContentHash(/* user, provider, type, time, value */),
}}}, nil
}Read connectors/example/example.go for the full version.
Register it
- Put the package under
connectors/<slug>/with a package comment. - Add a blank import to
connectors/all/all.goso the binary links it:_ "github.com/soma-intelligence/open-health-sync/connectors/<slug>". - For a keyed connector, implement
RequiredKeys(for exampleCLIENT_ID,CLIENT_SECRET); the operator setsOHS_<SLUG>_CLIENT_IDand so on. Without them the connector stays off andohs doctorsays why. For an OAuth connectorohs doctoralso prints the callback URL to register with the provider. - Document it in
docs/connectors/<slug>.md: where to get keys, the callback URL, scopes.
Test it with fixtures
Every connector ships fixture tests: raw lines in, expected canonical records out.
- Record real provider responses (or upload lines) into
connectors/<slug>/testdata/, with personal values replaced by invented ones. - Feed each line to
Mapand compare the result with the records you expect, written by hand or as golden JSON. Check the quantity name, the unit, the timestamps, thetimezonemetadata and that the content hash is stable across two runs.connectors/example/example_test.goshows the shape;connectors/applehealth/andconnectors/withings/are full examples. - For a pulling connector, test
AuthorizeURL,Exchange,RefreshandFetchagainst anhttptestfake of the provider API, asconnectors/withings/withingsfakedoes. No test may call a real provider. - Add the unit cases: an unknown type gives an empty result and no error, a malformed line gives an error, a missing value is skipped.
- Add the connector to the
Providerslist intest/recorded/recorded.go. When you have an account, record it: CI then replays the real answers on every change.
Run go test ./connectors/<slug>/ and make guard before opening a pull request.