bodyapi

package
v0.0.1 Latest Latest
Warning

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

Go to latest
Published: Nov 10, 2026 License: AGPL-3.0 Imports: 21 Imported by: 0

Documentation

Overview

Package bodyapi is the contract between the main backend and the body service, the part that holds a user's scale readings and DEXA scans and computes their estimates.

Every call carries two headers. X-Dexafix-Body is the body secret that the two services share. X-Dexafix-Body-Token is a short-lived token that the main backend signs with an Ed25519 key for one user and one scope; the body service only holds the public key, so it can check tokens but never make one. The token also carries the settings of the user's row that the estimates depend on, so the body service does not have to trust a request body for them.

The package uses nothing but the standard library and pkg/models and pkg/estimate (pure data and maths); it imports neither a backend nor the store.

Index

Constants

View Source
const (
	// Prefix is the path every endpoint lives under.
	Prefix = "/body/v1"
	// HeaderSecret carries the secret the main backend and the body service share.
	HeaderSecret = "X-Dexafix-Body"
	// HeaderToken carries the signed token for the user and scope of one call.
	HeaderToken = "X-Dexafix-Body-Token"
	// Audience is what a token must name, so a token made for another service is not accepted.
	Audience = "DexaFix body"
	// MaxTokenAge is the longest a token may live. The signer sets it; the verifier refuses more.
	MaxTokenAge = 60 * time.Second
)
View Source
const (
	CodeNotFound     = "not_found"
	CodeInvalid      = "invalid"
	CodeUnauthorized = "unauthorized"
	CodeForbidden    = "forbidden"
	CodeUnavailable  = "unavailable"
	CodeInternal     = "internal"
)

Error codes in an error answer.

Variables

View Source
var (
	ErrNotFound     = &Error{Code: CodeNotFound}
	ErrInvalid      = &Error{Code: CodeInvalid}
	ErrUnauthorized = &Error{Code: CodeUnauthorized}
	ErrForbidden    = &Error{Code: CodeForbidden}
	ErrUnavailable  = &Error{Code: CodeUnavailable}
)

Sentinels for errors.Is.

Functions

func EncodePrivateKey

func EncodePrivateKey(priv ed25519.PrivateKey) string

EncodePrivateKey writes a private key as text.

func EncodePublicKey

func EncodePublicKey(pub ed25519.PublicKey) string

EncodePublicKey writes a public key as text.

func GenerateKey

func GenerateKey() (keyID string, pub ed25519.PublicKey, priv ed25519.PrivateKey)

GenerateKey makes a signing key pair. The key id names the public key, so several can be trusted side by side while one is being replaced.

func HTTPClient

func HTTPClient(tlsConfig *tls.Config) *http.Client

HTTPClient is the client for a body service in another process, with a TLS configuration such as the mutual TLS configuration of package mtls (nil means plain HTTP). An import of a long history takes a while to answer, so the wait for a response is generous; connecting is not.

func KeyID

func KeyID(pub ed25519.PublicKey) string

KeyID is the first eight hexadecimal characters of the SHA-256 of a public key.

func ParsePrivateKey

func ParsePrivateKey(text string) (ed25519.PrivateKey, error)

ParsePrivateKey reads the private key of a key file. A file holds exactly one.

func ParsePublicKeys

func ParsePublicKeys(text string) ([]ed25519.PublicKey, error)

ParsePublicKeys reads the public keys of a key file, one per line. At least one is required.

func StatusFor

func StatusFor(code string) int

StatusFor is the HTTP status that goes with a code.

Types

type Claims

type Claims struct {
	KeyID    string          `json:"kid"`
	Subject  int64           `json:"sub"` // the user; 0 for a call that is about no user in particular
	Audience string          `json:"aud"`
	Scope    Scope           `json:"scope"`
	Expires  int64           `json:"exp"` // Unix seconds
	Params   *EstimateParams `json:"params,omitempty"`
}

Claims are what a token says. They are signed, so the body service can rely on all of them.

type Client

type Client struct {
	// contains filtered or unexported fields
}

Client calls the body service on behalf of the main backend. It makes a token for every call.

func New

func New(base string, hc *http.Client, secret string, signer *Signer) (*Client, error)

New returns a Client for the service at base. hc carries the transport (TLS, mutual TLS, or the in-process transport of apiclient); nil means http.DefaultClient.

func (*Client) CreateScan

func (c *Client) CreateScan(ctx context.Context, u Subject, s *models.DexaScan) error

CreateScan stores a scan for the user; the scan's ID is set.

func (*Client) DeleteReading

func (c *Client) DeleteReading(ctx context.Context, u Subject, id int64) error

DeleteReading removes one of the user's readings.

func (*Client) DeleteScan

func (c *Client) DeleteScan(ctx context.Context, u Subject, id int64) error

DeleteScan removes one of the user's scans.

func (*Client) DeleteUser

func (c *Client) DeleteUser(ctx context.Context, userID int64) error

DeleteUser removes everything the body service holds for a user. It is not an error when there is nothing.

func (*Client) Estimates

func (c *Client) Estimates(ctx context.Context, u Subject, ifNoneMatch string) (EstimatesReply, error)

Estimates returns the user's estimates. ifNoneMatch is the ETag of a copy the caller already has, or empty.

func (*Client) EstimatesWindow

func (c *Client) EstimatesWindow(ctx context.Context, u Subject, ifNoneMatch string, days int) (EstimatesReply, error)

EstimatesWindow is Estimates for callers that only need the newest days: with days > 0 the answer holds the days within that many days of the newest one (estimate.WithinDays) and all calibrations. The ETag differs per window, so a validator from one window never matches another.

func (*Client) Export

func (c *Client) Export(ctx context.Context, u Subject) (Export, error)

Export returns all of the user's scans and readings.

func (*Client) Health

func (c *Client) Health(ctx context.Context) error

Health asks the service whether it is up. Its /healthz needs no secret or token, so a wrong secret or key shows up on the first real call, not here.

func (*Client) Readings

func (c *Client) Readings(ctx context.Context, u Subject, p Page) (readings []models.ScaleReading, next string, err error)

Readings lists a user's scale readings, newest first. next is empty on the last page.

func (*Client) Reconcile

func (c *Client) Reconcile(ctx context.Context, u Subject, req ReconcileRequest) (ImportResult, error)

Reconcile stores what a source returned for the user. The body service decides what to write.

func (*Client) Scans

func (c *Client) Scans(ctx context.Context, u Subject, p Page) (scans []models.DexaScan, next string, err error)

Scans lists a user's DEXA scans, newest first. next is empty on the last page.

func (*Client) Stats

func (c *Client) Stats(ctx context.Context) (Stats, error)

Stats counts the scans and readings.

func (*Client) UpsertReadings

func (c *Client) UpsertReadings(ctx context.Context, u Subject, rs []models.ScaleReading) (int, error)

UpsertReadings stores readings; one with an external id replaces the stored one of the same source and id. It returns how many were stored.

func (*Client) UserIDs

func (c *Client) UserIDs(ctx context.Context) ([]int64, error)

UserIDs lists the users that have data, in id order.

type Error

type Error struct {
	Status  int    `json:"-"`
	Code    string `json:"code"`
	Message string `json:"message"`
}

Error is an error answer. Compare with errors.Is against the Err* values below.

func (*Error) Error

func (e *Error) Error() string

func (*Error) Is

func (e *Error) Is(target error) bool

Is lets errors.Is match any *Error with the same code to the sentinels below.

type EstimateParams

type EstimateParams struct {
	Timezone       string   `json:"timezone"`
	Ranking        []string `json:"ranking"`
	AthleteDefault bool     `json:"athlete_default"`
	HeightCm       *float64 `json:"height_cm,omitempty"`
}

EstimateParams are the settings of a user's row that the estimates and the import of readings depend on. The main backend puts them in the token; the body service uses nothing else.

func (EstimateParams) Location

func (p EstimateParams) Location() *time.Location

Location is the user's time zone; an empty or unknown name is UTC, as for a user.

type EstimatesReply

type EstimatesReply struct {
	Result      estimate.Result
	ETag        string
	NotModified bool // the caller's copy, named by ifNoneMatch, is still good; Result is empty
}

EstimatesReply is the answer to Estimates.

type Export

type Export struct {
	DexaScans     []models.DexaScan     `json:"dexa_scans"`
	ScaleReadings []models.ScaleReading `json:"scale_readings"`
}

Export is all of a user's scans and readings.

type ImportResult

type ImportResult struct {
	Imported    int      `json:"imported"`               // new readings stored
	Updated     int      `json:"updated"`                // existing readings whose values changed
	Unchanged   int      `json:"unchanged"`              // readings already stored with identical values
	Removed     int      `json:"removed"`                // stored readings the source no longer returns (day re-import only)
	Skipped     int      `json:"skipped"`                // days left alone because they hold manual readings
	SkippedDays []string `json:"skipped_days,omitempty"` // those days as YYYY-MM-DD
}

ImportResult reports what an import did.

type Page

type Page struct {
	Limit        int       // at most this many; 0 means all
	Before       string    // continue after the cursor an earlier page returned
	Since, Until time.Time // inclusive bounds on the measurement time; zero means no bound
}

Page selects a window of scans or readings, newest first. The zero value means all of them.

type ReconcileRequest

type ReconcileRequest struct {
	Source  string                `json:"source"`
	Fetched []models.ScaleReading `json:"fetched"`
	Seen    []string              `json:"seen"`
	DayMode bool                  `json:"day_mode"`
	Day     string                `json:"day,omitempty"` // YYYY-MM-DD, set when DayMode is
}

ReconcileRequest is what a source (Withings, ...) returned for a user. The body service decides what to store (see the rules on plan in pkg/backend/body).

type Scope

type Scope string

Scope says what a token allows. Each scope includes the ones before it.

const (
	// ScopeRead reads one user's data.
	ScopeRead Scope = "read"
	// ScopeWrite also changes one user's data.
	ScopeWrite Scope = "write"
	// ScopeAdmin also deletes a user's data and, with subject 0, reads totals and lists users.
	ScopeAdmin Scope = "admin"
)

func (Scope) Allows

func (s Scope) Allows(need Scope) bool

Allows reports whether a token of scope s may do what need asks for.

type Signer

type Signer struct {
	// contains filtered or unexported fields
}

Signer makes tokens. Only the main backend holds one.

func NewSigner

func NewSigner(priv ed25519.PrivateKey) *Signer

NewSigner returns a Signer for a private key.

func (*Signer) Sign

func (s *Signer) Sign(subject int64, scope Scope, params *EstimateParams, now time.Time) string

Sign makes a token for subject and scope that expires MaxTokenAge after now.

type Stats

type Stats struct {
	DexaScans     int `json:"dexa_scans"`
	ScaleReadings int `json:"scale_readings"`
}

Stats counts what the body service holds.

type Subject

type Subject struct {
	ID     int64
	Params EstimateParams
}

Subject is who a call is for: the user's id and the settings the call may need.

type Verifier

type Verifier struct {
	// contains filtered or unexported fields
}

Verifier checks tokens. The body service holds one, with public keys only.

func NewVerifier

func NewVerifier(keys ...ed25519.PublicKey) *Verifier

NewVerifier trusts the given public keys.

func (*Verifier) Verify

func (v *Verifier) Verify(token string, now time.Time) (Claims, error)

Verify returns the claims of a token that is signed by a trusted key, meant for this service, not expired and not made to last longer than MaxTokenAge. Every failure looks the same.

Jump to

Keyboard shortcuts

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