Documentation
¶
Overview ¶
Package googlehealth is the Google Health API Provider client (ADR-0001, ADR-0007): the Data Type catalog, raw request builders and the single-attempt fetch, Sync Run ingestion with pagination and the bounded retry middleware, the Data Point and Rollup parsers, the shared Provider GET module, the typed error translation layer, the `--rollup` spec, the OAuth scope constants, and the identity endpoint catalog.
The package depends only on internal/archived (the shared archived-row types) and the standard library. Main supplies the transport through the Doer seam — production binds the shared timeout HTTPClient via the runtime adapters — and consumes the ingestion through NewIngestion / Execute. `raw` exploration uses BuildRawRequest + the fetchRawProvider seam; Identity Snapshot fetchers in main ride GET.FetchJSON.
Extracted from the main dispatch package in issue #287; it is the first internal package per ADR-0007's sequencing and sets the pattern for a later internal/archive extraction.
Index ¶
- Constants
- Variables
- func DefaultDataTypes() []string
- func FetchRaw(ctx context.Context, doer Doer, request RawRequest, accessToken string) ([]byte, error)
- func IdentityEndpointScopes(endpoint string) []string
- func IsDefaultConfigDataType(dataType string) bool
- func IsUnreachableError(err error) bool
- func NormalizeError(err error) error
- func ParseRangeBoundary(value string) (time.Time, bool)
- func ScopesForDataType(dataType string) []string
- func SourceFamilyFilterName(dataType, sourceFamily string) (string, error)
- func SupportedRollupKinds() []string
- func SupportsSyncDataPoints(dataType string) bool
- func UsesDateRangeDefault(dataType string) bool
- func ValidateRollupAgainstDataType(spec RollupSpec, dataType string) error
- type Archive
- type Doer
- type GET
- type HTTPError
- type Ingestion
- type IngestionPlan
- type IngestionRequest
- type IngestionResult
- type RawRequest
- type RollupSpec
Constants ¶
const ( ScopeActivityReadonly = "https://www.googleapis.com/auth/googlehealth.activity_and_fitness.readonly" ScopeHealthMetricsReadonly = "https://www.googleapis.com/auth/googlehealth.health_metrics_and_measurements.readonly" ScopeSleepReadonly = "https://www.googleapis.com/auth/googlehealth.sleep.readonly" ScopeNutritionReadonly = "https://www.googleapis.com/auth/googlehealth.nutrition.readonly" ScopeProfileReadonly = "https://www.googleapis.com/auth/googlehealth.profile.readonly" )
OAuth scope URLs for the Google Health API. The Data Type catalog (catalog.go) references these per entry; main's OAuth flow composes its requested-scope set from them via ScopesForDataType and the `connect --add-scopes` keyword map.
const ( ScopeEcgReadonly = "https://www.googleapis.com/auth/googlehealth.electrocardiogram.readonly" ScopeIrnReadonly = "https://www.googleapis.com/auth/googlehealth.irn.readonly" ScopeSettingsReadonly = "https://www.googleapis.com/auth/googlehealth.settings.readonly" )
Tier 2 opt-in scopes (#104, #176). Users grant these via `gohealthcli connect --add-scopes ecg,irn,settings`. The CLI-side keyword→scope mapping lives in main (connect_add_scopes.go); this file owns the constants the catalog references. `settings.readonly` (#176) is what Google's `users.getSettings` and `users.pairedDevices.list` actually require — `profile.readonly` alone returns HTTP 403 for those.
const ( IdentityURL = "https://health.googleapis.com/v4/users/me/identity" ProfileURL = "https://health.googleapis.com/v4/users/me/profile" SettingsURL = "https://health.googleapis.com/v4/users/me/settings" PairedDevicesURL = "https://health.googleapis.com/v4/users/me/pairedDevices" IRNProfileURL = "https://health.googleapis.com/v4/users/me/irnProfile" )
Identity-endpoint URLs. Each is the upstream Google Health URL one Identity Snapshot fetcher in main GETs through the shared Provider GET module; `raw endpoint <name>` dispatches to the same URLs via the identityEndpointURLs catalog below.
const HTTPTimeout = 60 * time.Second
HTTPTimeout bounds every Provider HTTP request end to end: dial, TLS handshake, response headers, and body read. Without a deadline a stalled connection hangs a Sync Run forever — its heartbeat goes quiet and the abandoned-run fence (syncRunFenceStaleAfter, 5 minutes) can fence a run whose process is still alive. Sixty seconds covers the largest Provider page (googleHealthRawResponseLimit, 10 MiB) on a slow link while staying well inside the fence window, so a stall surfaces as a request error the run can report instead of a fenced-while-alive run.
const ScopeLocationReadonly = "https://www.googleapis.com/auth/googlehealth.location.readonly"
ScopeLocationReadonly is the Tier 2 optional scope from #140: `googlehealth.location.readonly` is the scope Google requires (on top of `activity_and_fitness.readonly`) to authorise `users.dataTypes.dataPoints.exportExerciseTcx`. Users opt in via `gohealthcli connect --add-scopes tcx`; the exercise sync then archives TCX route bytes as a `tcx`-kind Attachment per ADR-0009. Without it, exercise sync skips the TCX hook cleanly (no 403 round-trip) — see attachExerciseTcxIfAvailable.
Variables ¶
var ErrSyncCanceled = errors.New("Sync Run canceled")
ErrSyncCanceled is the sentinel returned by ingestion when the run's context was canceled — between pages or mid-fetch (#284). Main's Sync Run lifecycle translates it into the canceled outcome, which leaves the Sync Cursor un-advanced (ADR-0008).
ErrUnauthorized is the Provider auth-rejection sentinel: the stored Connection token was rejected upstream with HTTP 401 and the user recovers by running `gohealthcli connect` again. The message text is the historical errCurrentConnectionProviderUnauthorized wording verbatim — it surfaces in CLI output and JSON envelopes, so changing it is a user-visible behavior change. Main matches the category via errors.Is on this value.
var HTTPClient = newHTTPClient(HTTPTimeout)
HTTPClient is the one shared HTTP client for every Provider request: Identity Snapshot fetchers, Google identity and profile fetchers, OAuth token exchange and refresh, and raw Provider fetch. Production code must not use http.DefaultClient — it carries no timeout. This value is wiring only: it is bound as the production HTTP doer (runtime adapters, ProductionGET) and is never reassigned; request paths receive a doer instead of reading it.
Functions ¶
func DefaultDataTypes ¶
func DefaultDataTypes() []string
DefaultDataTypes returns the ordered Data Types whose catalog entry is flagged DefaultConfigType — the set `init` writes into a fresh config and `sync --all` fans out over. The returned slice is shared package state; callers treat it as read-only (the sync preflight gate deliberately avoids copying it on every Validate call).
func FetchRaw ¶
func FetchRaw(ctx context.Context, doer Doer, request RawRequest, accessToken string) ([]byte, error)
FetchRaw is the single-attempt raw Provider fetch. The HTTP doer is injected (#281): production binds the shared timeout client via the fetchRawProvider seam and the runtime adapters; tests bind a fake doer to exercise this body directly. The request is scoped to ctx (#284), so canceling it aborts the in-flight call.
func IdentityEndpointScopes ¶
IdentityEndpointScopes returns the OAuth scopes the named identity endpoint requires, or nil for an unknown endpoint. The Identity Snapshot command engine in main uses this for its pre-call scope check so the per-command scope literals cannot drift from the `raw endpoint` dispatcher's catalog (PRD #142).
func IsDefaultConfigDataType ¶
IsDefaultConfigDataType reports whether dataType is a catalog entry flagged DefaultConfigType — the predicate config validation applies to each default_data_types entry.
func IsUnreachableError ¶
IsUnreachableError reports whether err is a non-auth Provider HTTP or network failure — the provider_unreachable category. A typed upstream HTTP error counts unless it is the 401 auth rejection (that is a Connection problem the user fixes with `connect`, not an outage); a *url.Error is net/http's transport- level failure shape (dial refused, DNS, TLS, deadline) and always counts.
func NormalizeError ¶
NormalizeError translates an upstream Provider failure into the user-facing error category every Provider-touching command shares. A typed HTTP 401 becomes the ErrUnauthorized "run `gohealthcli connect` again" category with the original cause kept in the chain; every other error passes through unchanged. Detection is errors.As on the typed HTTPError only — message text never participates.
func ParseRangeBoundary ¶
ParseRangeBoundary accepts either civil-date (YYYY-MM-DD, interpreted as start-of-UTC-day) or RFC3339. Both shapes are supported by every rollup kind as an input ergonomic, even when the emitted shape is restricted by the upstream endpoint.
func ScopesForDataType ¶
func SourceFamilyFilterName ¶
func SupportedRollupKinds ¶
func SupportedRollupKinds() []string
SupportedRollupKinds returns the literal --rollup values ParseRollupSpec accepts, in the order the rejection message prints them. Main's help/registry drift guard compares the sync command's two usage surfaces against this list so a new kind cannot land without updating the user-facing flag documentation.
func SupportsSyncDataPoints ¶
SupportsSyncDataPoints returns true if the catalog has at least one list/reconcile endpoint for the Data Type. Replaces the previous parallel-boolean field SupportsSyncDataPoint.
func UsesDateRangeDefault ¶
func ValidateRollupAgainstDataType ¶
func ValidateRollupAgainstDataType(spec RollupSpec, dataType string) error
ValidateRollupAgainstDataType checks whether the rollup kind the operator asked for is wired into the Data Type's catalog row. Failure quotes the actual SupportedEndpoints map keys — the #106 AC requires this verbatim so operators can see what alternatives the Data Type does support.
Types ¶
type Archive ¶
type Archive interface {
// The archive writes carry a context (#305), but the pagination
// drivers deliberately pass a WithoutCancel-derived one: a page that
// was already fetched is archived in full before cancellation lands
// at the next loop boundary, so SIGINT never discards paid-for
// upstream data (TestSyncOrchestratorCancelsActiveDataTypeMidPagination
// pins this; upsert dedupe absorbs the overlap on resume).
UpsertDataPoint(ctx context.Context, point archived.DataPoint, now string) (string, error)
UpsertRollup(ctx context.Context, rollup archived.Rollup, now string) (string, error)
// StoreAttachment is invoked by the TCX-ingestion hook for #107
// slice D: after upserting an exercise Data Point, the ingestion
// calls this with the just-upserted point + the bytes returned by
// `users.dataTypes.dataPoints.exportExerciseTcx`. The archive impl
// resolves the data_point row id from the point's identity columns
// and writes the sidecar via the Attachment Store (ADR-0009).
StoreAttachment(ctx context.Context, point archived.DataPoint, kind string, payload []byte, fetchedAt string) error
}
type Doer ¶
Doer is the HTTP transport seam on the runtime adapters (#281): exactly (*http.Client).Do, so the production adapter binds the shared timeout client below directly and tests inject a fake doer (an http.Client over a stub RoundTripper) without touching any global.
type GET ¶
type GET struct {
// contains filtered or unexported fields
}
GET is the shared Provider GET module. doer is the HTTP transport seam (#281) — required; production constructs the module via ProductionGET or NewGET over the runtime adapters' doer, tests bind a fake. sleeper and jitter are retry test seams that mirror retryFetchProvider's — production leaves them nil and fetchWithRetry falls back to sleepWithCancel + defaultRetryJitter.
func NewGET ¶
NewGET builds the Provider GET module over the given HTTP doer. Main's runtime adapters use this to derive the module from whatever transport the adapters carry (production: the shared timeout client; tests: a fake doer). Retry seams stay nil — real backoff sleeps.
func ProductionGET ¶
func ProductionGET() GET
ProductionGET is the module configuration every production call site outside the runtime adapters uses: the shared timeout client as the doer, real backoff sleeps.
func (GET) FetchJSON ¶
FetchJSON is the module's entry point: one Provider GET against url through the module value, labeled per fetch for error messages. It wraps the single-attempt GET in the same bounded retry/Retry-After middleware the Sync Run ingestion path uses (retry.go): up to googleHealthRetryMaxAttempts attempts for 429/5xx with exponential backoff capped at googleHealthRetryMaxDelay, Retry-After as the sleep floor, and immediate surfacing of non-transient failures. ctx scopes the HTTP request and the retry backoff sleeps (#284) — a canceled ctx aborts the in-flight request and short-circuits backoff sleeps; callers without cancellation instrumentation pass context.Background().
type HTTPError ¶
type HTTPError struct {
StatusCode int
RetryAfter time.Duration
Body []byte
// Endpoint labels which Provider request failed ("identity",
// "pairedDevices", ...) so each fetcher keeps its historical
// user-facing message verbatim. Empty means the raw Provider fetch
// path, whose message predates the label. Exported so main's tests
// can fake labeled upstream failures.
Endpoint string
}
HTTPError carries the upstream status code plus an optional Retry-After hint. The ingestion retry middleware uses these to decide whether to retry transient failures (429, 5xx) and how long to wait before doing so; the Provider error translation layer (errors.go) reads StatusCode via errors.As to detect auth rejections and provider_unreachable failures without matching on message text (issue #272). Other callers can still read the error string.
type Ingestion ¶
type Ingestion struct {
// contains filtered or unexported fields
}
func NewIngestion ¶
func NewIngestion(fetch func(ctx context.Context, request RawRequest, accessToken string) ([]byte, error), now func() time.Time) Ingestion
NewIngestion builds the production-shaped ingestion over a single-attempt fetch and a clock. Main's runtime adapters bind fetch to their fetchRawProvider seam (production: FetchRaw over the shared timeout client; tests: a fake), and the package wraps it in the bounded retry middleware (retry.go) exactly as before the extraction. now must be non-nil; main passes the adapters' clock.
func (Ingestion) Execute ¶
func (ingestion Ingestion) Execute(ctx context.Context, archive Archive, request IngestionRequest) (IngestionResult, error)
func (Ingestion) Plan ¶
func (ingestion Ingestion) Plan(request IngestionRequest) (IngestionPlan, error)
type IngestionPlan ¶
type IngestionPlan struct {
EndpointFamily string
// contains filtered or unexported fields
}
IngestionPlan names the endpoint family a request dispatches to. EndpointFamily is exported because main's Sync Run lifecycle records it on the sync_runs audit row and in the result envelope.
type IngestionRequest ¶
type IngestionRequest struct {
Connection archived.Connection
DataType string
From string
To string
Rollup string
SourceFamily string
AccessToken string
// grantedScopes mirrors the granted scope set on the stored
// Connection token. Sync wires it from
// `connectionTokenExpiryAndScopes(connection.tokenMetadataJSON)`
// before calling Execute. Optional ingestion hooks (today: the
// TCX archival in attachExerciseTcxIfAvailable, #140) gate on
// this to skip endpoints whose scope was not granted, avoiding
// a guaranteed-403 round-trip. The gate fails closed: a nil or
// empty slice means the optional hook does NOT fire. Tests that
// want to exercise the hook must inject the granting scope
// explicitly.
GrantedScopes []string
// RefreshAccessToken, when set, lets a Sync Run survive access-token
// expiry mid-run. Google access tokens live about an hour; a long
// backfill's pagination can outlive one. When an upstream call
// returns HTTP 401, ingestion calls this hook — sync wires it to the
// same refresh-and-persist path the pre-run auto-refresh uses — and
// retries the failed request once with the returned token. Later
// requests in the same run keep using the refreshed token. nil
// preserves the historical behavior: the first 401 fails the run.
RefreshAccessToken func() (string, error)
// Progress, when non-nil, is invoked at the TOP of every page
// iteration — before the fetch — with the counts archived so far,
// so the caller can persist a heartbeat on the sync_runs row
// (#236). Heartbeating before the fetch (rather than after the
// page's upserts) means a slow first page — large backfill, 429
// retry backoff — still shows a live heartbeat from second zero,
// so the abandoned-run fence cannot mis-flag a run that is merely
// waiting on upstream. The callback owns its own error policy —
// ingestion never fails a Sync Run because a progress write
// misfired, which is why the hook takes no error return. nil
// disables heartbeats (raw fetch paths and tests that predate #236).
Progress func(result IngestionResult)
}
IngestionRequest carries one Sync Run's ingestion parameters from main's Sync Run lifecycle into Execute. Fields are exported because main populates them.
type IngestionResult ¶
type IngestionResult struct {
EndpointFamily string
DataPointsSeen int
DataPointsNew int
DataPointsUpdated int
RollupsSeen int
RollupsNew int
RollupsUpdated int
}
IngestionResult carries the per-run counts back to main, which folds them into the sync result envelope and the per-page heartbeat.
type RawRequest ¶
type RawRequest struct {
EndpointName string
DataType string
Method string
URL string
Body []byte
RequiredScopes []string
SourceFamilyFilter string
}
RawRequest describes one raw Provider request: the endpoint-shaped descriptor the builders in this package produce and the fetchRawProvider seam carries. Fields are exported because the request crosses the package boundary: main's runtime adapters seam is typed over it, `raw` reads RequiredScopes for its scope check, and main's sync tests inspect URL / DataType / EndpointName on the requests their fake providers receive.
func BuildRawRequest ¶
type RollupSpec ¶
type RollupSpec struct {
// contains filtered or unexported fields
}
RollupSpec is the parsed form of `--rollup` (`daily | weekly | hourly | window=<duration>`). It carries the windowSize used by the upstream Google Health rollup endpoint, the endpoint family the planner will dispatch to, and the cursor-kind discriminator used by the Sync Cursor (so each (Data Type, source-family, rollup-kind) triple has its own durable highwater).
func ParseRollupSpec ¶
func ParseRollupSpec(value string) (RollupSpec, error)
ParseRollupSpec parses the operator-facing `--rollup` value into a RollupSpec. Returns a typed error for unknown literals and for malformed window=… durations.
func (RollupSpec) NormalizeRange ¶
func (spec RollupSpec) NormalizeRange(from, to string, now time.Time) (normFrom string, normTo string, err error)
NormalizeRange owns the civil-vs-RFC3339 input-shape rule per rollup kind (PRD #141 slice 3). The planner downstream consumes only the normalized values, so the catalog's SupportedEndpoints data stays authoritative — civil-vs-RFC3339 is purely an input ergonomic decision concentrated here.
Acceptance per rollup kind:
- daily: civil dates AND RFC3339; emits civil (YYYY-MM-DD). RFC3339 inputs are projected to their UTC calendar day so the downstream dailyRollUp call body receives the catalog-required civil-time interval.
- hourly / weekly / window=<dur>: civil dates (interpreted as start-of-UTC-day) AND RFC3339; emits RFC3339 so the windowed rollUp call body carries the upstream-required RFC3339 range.
Empty inputs pass through: --from "" is the cursor-resume signal the lifecycle resolves later, and --to "" is the gate-defaulting signal; the gate normalises a resolved --to before calling this helper, but treating empty as pass-through keeps the contract composable for callers that have not yet defaulted.
Parse failures surface a local message naming both supported shapes for this rollup kind so the operator no longer sees an opaque upstream HTTP 400 for civil-on-hourly etc.