Skip to content

Add an HTTP API for configuration management#2283

Closed
robertlestak wants to merge 7 commits into
dreeveapp:masterfrom
robertlestak:configuration-api
Closed

Add an HTTP API for configuration management#2283
robertlestak wants to merge 7 commits into
dreeveapp:masterfrom
robertlestak:configuration-api

Conversation

@robertlestak

Copy link
Copy Markdown

What type of PR is this? (check all applicable)

  • Refactor
  • Feature
  • Bug Fix
  • Improvement
  • Translations
  • Documentation Update

Description

First off — thank you for this project. Dreeve is one of the most regularly used
tools in my homelab, and it has been running there happily for a long time. The
v5 migration was painless too: everything came across from my split
config-*.yaml files on the first boot.

So this comes from a place of wanting to build on something that already works
well, not from wanting it to be different.

New feature, no existing issue — happy to open one first if you would prefer to
discuss the shape before reviewing code.

Adds an opt-in HTTP API for reading and updating configuration, so settings that
originate outside Dreeve can be kept in sync without a human opening the admin
panel. My own motivation is a nightly job that pushes weight and resting heart
rate from an Apple Health export; today that means writing to the KeyValue
table directly, which is not something I want to depend on.

The emphasis is on the extension point rather than the six endpoints. Adding
configuration to the API should mean writing one class, not touching a
controller or routing.

Design

ConfigResource is a tagged interface with getName() and read().
WritableConfigResource extends it with buildUpdateCommand(). The registry
collects every tagged service, so a new resource is picked up automatically and
appears in the discovery endpoint with the methods it actually supports.

GET  /api/v1/config                                 # discovery
GET  /api/v1/config/athlete/weight-history
PUT  /api/v1/config/athlete/weight-history
GET  /api/v1/config/athlete/ftp-history/{cycling|running}
PUT  /api/v1/config/athlete/ftp-history/{cycling|running}
GET  /api/v1/config/athlete/max-heart-rate
PUT  /api/v1/config/athlete/max-heart-rate
GET  /api/v1/config/athlete/resting-heart-rate
PUT  /api/v1/config/athlete/resting-heart-rate
GET  /api/v1/config/zwift
PUT  /api/v1/config/zwift

Resources build a command and hand it back; the controller dispatches it. That
keeps validation in front of any write, and reuses the existing bus rather than
introducing a second write path. ZwiftConfigResource returns the existing
UpdateSettings instead of defining a command of its own, to show a resource
does not need bespoke machinery.

The write commands carry #[RequiresRebuild], since weight, FTP and heart rate
all feed derived metrics.

Authentication

Off by default. Setting API_TOKEN enables it; requests use
Authorization: Bearer <token>, handled by a stateless api firewall requiring
ROLE_API. Tokens shorter than 32 characters are rejected at boot rather than
quietly accepted.

ADMIN_ALLOWED_IPS now also covers /api/v1, so one setting restricts both
routes into configuration rather than leaving the API as an unguarded second
door for anyone already using the allowlist. This changes the meaning of an
existing setting
— happy to split it into its own API_ALLOWED_IPS if you
would rather not overload it.

Changes to existing behaviour

Three, all small, all deliberate:

  1. ConditionalRedirectGate defers for /api/v1. Without it the setup-flow
    gates answer an API client with a 302 to a page it cannot render. Only
    redirects are skipped — AdminAllowedIpGate is not a redirect gate and still
    applies. Tests cover both directions, including that the prebuilt /api/*
    the frontend consumes is untouched.
  2. AdminAllowedIpGate guards /api/v1 as well as /admin.
  3. FtpHistory uses a new FtpSport enum in place of its two private
    string constants, so the new code does not duplicate 'cycling'/'running'.
    Pure refactor. Happy to pull this into a separate PR if you prefer.

Validation

Reuses what already exists rather than parallel logic:

  • AthleteSettingsPayload::normalize() handles the heart-rate payloads, so the
    API and the settings form agree on shape, coercion and duplicate-date
    rejection.
  • FTP goes through FtpHistory::fromArray() with an explicitly keyed array, so
    the legacy-BC shim cannot be tripped by accident.
  • Weight is validated with AthleteWeightHistory::fromArray(). Worth flagging:
    GeneralSettings::fromArray() stores weightHistory verbatim without
    validating it, so a bad entry currently surfaces much later at read time. The
    API validates up front rather than inheriting that.

Two smaller things the API had to be careful about, both of which are really
properties of the existing model:

  • Stored weights are unit-ambiguous — a bare number interpreted via
    AppearanceSettings. GET returns the active unit, and PUT accepts an
    optional unit that must match, so a client cannot silently write pounds into
    a kilogram history.
  • Updating ftpHistory.cycling must not wipe running. The commands are scoped
    per sport, and a pre-split history is migrated to the keyed shape rather than
    dropped.

Not included

integrations is the obvious next candidate and is deliberately absent. The AI
config is already #[\SensitiveParameter] and notification services are
shoutrrr URLs with embedded credentials, so a GET would hand out API keys and
passwords to any token holder. That needs a redaction or write-only design
first. The read-only half of the interface exists partly to make that possible
later, and the interface contract states that read() must not expose secrets.
Same reasoning for import.webhooks.verifyToken.

appearance, metrics and daemon are omitted as human preferences with no
automation driver. appearance.unitSystem in particular would let a client flip
the meaning of every stored weight.

Testing

2373 tests pass, plus the caddy suite. PHPStan level 8, rector and cs-fixer
clean. New code has no uncovered lines; overall line coverage goes from 98.77%
to 98.87%.

Coverage includes the auth paths, per-sport isolation, the legacy FTP shape, the
unit guard, and the read-only branch of the interface via a stub resource, since
every shipped resource is writable.

Also exercised against a live instance, which is where the imperial unit path
and the #[RequiresRebuild] flag actually got verified — a full weight history
round-tripped through the API byte-identical.

Docs

New page at docs/integrations/api.md, registered in the sidebar, plus
API_TOKEN in the installation docs and .env.

Disclosure

This PR was developed with AI assistance (Claude). The design decisions, the
review of what it produced, and the testing against a live instance are mine,
but the bulk of the implementation and test code was AI-written. Flagging it so
you can weight your review accordingly.


This is unsolicited, so if the shape is wrong for where you want the project to
go, say so and I will rework or drop it. The auth model and whether
ADMIN_ALLOWED_IPS should be overloaded are the two decisions I would most like
a second opinion on.

robertlestak added 7 commits July 20, 2026 17:04
Introduces an opt-in bearer token, enabled by setting API_TOKEN, backed by a
stateless firewall requiring ROLE_API. The API is unreachable until the token
is set, and tokens shorter than 32 characters are rejected at boot rather than
quietly accepted.
Configuration is exposed through tagged ConfigResource services rather than
endpoints enumerated in the controller, so adding a resource needs no routing
or controller change. Write support is a separate interface so a resource can
be read-only, which a redacted view of secret-bearing settings would need.
The setup-flow gates would redirect an API client to a page it cannot render,
so ConditionalRedirectGate now defers for the API. Only redirects are skipped;
AdminAllowedIpGate is not a redirect gate and now covers the API too, so
ADMIN_ALLOWED_IPS restricts both routes into configuration.
Weight history is validated up front, since GeneralSettings stores it verbatim
and an invalid entry would otherwise only surface later at read time. Because
the stored number is interpreted via the configured unit system, GET reports
the active unit and PUT can pin it, so a client cannot silently write pounds
into a kilogram history.

FTP is scoped per sport so updating cycling cannot wipe running, and a history
predating the cycling/running split is migrated rather than dropped. A FtpSport
enum replaces the two private constants in FtpHistory.
Heart rate payloads reuse AthleteSettingsPayload::normalize(), so the API and
the settings form agree on shape, coercion and duplicate-date rejection.

Zwift returns the existing UpdateSettings command rather than defining its own,
showing a resource does not need bespoke machinery.
@robiningelbrecht

robiningelbrecht commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

Hey. first, thank you.

I'm going to take you up on your own offer and say the shape is wrong for where I want the project to go. So I won't be merging this, and I'd rather tell you that clearly than let it sit.

The core issue isn't the code, it's the commitment. A public HTTP API is a contract: once automations depend on these endpoints, every future refactor of the settings model has to carry them, and any auth issue becomes my security problem. This project is deliberately a simple self-hosted app with a small surface, and I want to keep the freedom to reshape internals without versioning an API around them. The ADMIN_ALLOWED_IPS overload you flagged is a good example of the kind of subtle semantic question every addition would bring.

Sorry to say no to your work. It's a scope decision, not a quality one

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants