Add an HTTP API for configuration management#2283
Conversation
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.
|
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 Sorry to say no to your work. It's a scope decision, not a quality one |
What type of PR is this? (check all applicable)
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-*.yamlfiles 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
KeyValuetable 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
ConfigResourceis a tagged interface withgetName()andread().WritableConfigResourceextends it withbuildUpdateCommand(). The registrycollects every tagged service, so a new resource is picked up automatically and
appears in the discovery endpoint with the methods it actually supports.
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.
ZwiftConfigResourcereturns the existingUpdateSettingsinstead of defining a command of its own, to show a resourcedoes not need bespoke machinery.
The write commands carry
#[RequiresRebuild], since weight, FTP and heart rateall feed derived metrics.
Authentication
Off by default. Setting
API_TOKENenables it; requests useAuthorization: Bearer <token>, handled by a statelessapifirewall requiringROLE_API. Tokens shorter than 32 characters are rejected at boot rather thanquietly accepted.
ADMIN_ALLOWED_IPSnow also covers/api/v1, so one setting restricts bothroutes 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_IPSif youwould rather not overload it.
Changes to existing behaviour
Three, all small, all deliberate:
ConditionalRedirectGatedefers for/api/v1. Without it the setup-flowgates answer an API client with a 302 to a page it cannot render. Only
redirects are skipped —
AdminAllowedIpGateis not a redirect gate and stillapplies. Tests cover both directions, including that the prebuilt
/api/*the frontend consumes is untouched.
AdminAllowedIpGateguards/api/v1as well as/admin.FtpHistoryuses a newFtpSportenum in place of its two privatestring 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 theAPI and the settings form agree on shape, coercion and duplicate-date
rejection.
FtpHistory::fromArray()with an explicitly keyed array, sothe legacy-BC shim cannot be tripped by accident.
AthleteWeightHistory::fromArray(). Worth flagging:GeneralSettings::fromArray()storesweightHistoryverbatim withoutvalidating 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:
AppearanceSettings.GETreturns the activeunit, andPUTaccepts anoptional
unitthat must match, so a client cannot silently write pounds intoa kilogram history.
ftpHistory.cyclingmust not wiperunning. The commands are scopedper sport, and a pre-split history is migrated to the keyed shape rather than
dropped.
Not included
integrationsis the obvious next candidate and is deliberately absent. The AIconfig is already
#[\SensitiveParameter]and notification services areshoutrrr URLs with embedded credentials, so a
GETwould hand out API keys andpasswords 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,metricsanddaemonare omitted as human preferences with noautomation driver.
appearance.unitSystemin particular would let a client flipthe 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 historyround-tripped through the API byte-identical.
Docs
New page at
docs/integrations/api.md, registered in the sidebar, plusAPI_TOKENin 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_IPSshould be overloaded are the two decisions I would most likea second opinion on.