googlehealth

package
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 12, 2026 License: MIT Imports: 14 Imported by: 0

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

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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

View Source
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).

View Source
var ErrUnauthorized = errors.New("Google Health rejected stored Connection token; run `gohealthcli connect` again")

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.

View Source
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

func IdentityEndpointScopes(endpoint string) []string

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

func IsDefaultConfigDataType(dataType string) bool

IsDefaultConfigDataType reports whether dataType is a catalog entry flagged DefaultConfigType — the predicate config validation applies to each default_data_types entry.

func IsUnreachableError

func IsUnreachableError(err error) bool

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

func NormalizeError(err error) error

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

func ParseRangeBoundary(value string) (time.Time, bool)

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 ScopesForDataType(dataType string) []string

func SourceFamilyFilterName

func SourceFamilyFilterName(dataType, sourceFamily string) (string, error)

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

func SupportsSyncDataPoints(dataType string) bool

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 UsesDateRangeDefault(dataType string) bool

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

type Doer interface {
	Do(request *http.Request) (*http.Response, error)
}

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

func NewGET(doer Doer) GET

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

func (get GET) FetchJSON(ctx context.Context, url, label, accessToken string) ([]byte, error)

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.

func (*HTTPError) Error

func (err *HTTPError) 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

func BuildRawRequest(target []string, from, to string, pageSize int64, pageToken string) (RawRequest, error)

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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL