feat(host/library): external provider API — declarative reconcile (M4)
apple / swift (push) Successful in 1m15s
apple / screenshots (push) Successful in 4m37s
windows-host / package (push) Successful in 8m57s
ci / web (push) Successful in 53s
ci / docs-site (push) Successful in 58s
ci / bench (push) Successful in 5m51s
decky / build-publish (push) Successful in 26s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 13s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 43s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 16s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m5s
arch / build-publish (push) Successful in 16m51s
android / android (push) Successful in 17m6s
deb / build-publish (push) Successful in 17m13s
ci / rust (push) Successful in 18m41s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 14m0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 13m31s
docker / deploy-docs (push) Successful in 10s

External game-library providers become first-class (RFC §8): a plugin
computes its desired title list and PUTs it — the host owns the diff.

- CustomEntry gains `provider` + `external_id` (API-set only; never on
  manual entries). GameEntry surfaces `provider` for console attribution
  and the new `GET /library?provider=` filter.
- PUT /api/v1/library/provider/{p}: atomic declarative reconcile keyed
  on the provider's `external_id` — host ids stay stable across syncs,
  orphans drop, manual entries and other providers are never touched,
  an empty array clears the set. Validated: provider id [a-z0-9._-]
  (`manual` reserved), unique non-empty external_ids.
- DELETE /api/v1/library/provider/{p}: clean uninstall, returns the
  removed count.
- Ownership is unambiguous both ways: manual CRUD now returns 409 for a
  provider-owned entry (MutateOutcome::ProviderOwned) instead of letting
  an edit be silently clobbered at the next sync.
- library.changed now carries the mutating source (`manual` or the
  provider id) — hooks and the SDK filter on it.
- Spec + SDK schemas regenerated; sdk/examples/provider-sync.ts is the
  provider-plugin skeleton.

347 host tests green (pure reconcile: stable ids, orphan drop,
idempotence, bystanders untouched; name/payload validation; route 400s)
+ 11 SDK tests. Live-verified end to end THROUGH the SDK against a real
host: sync → filtered list → manual-delete 409 → re-sync with stable id
+ orphan drop → uninstall (removed=2), with three
library.changed(source=romm) events observed on the live stream.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-17 00:33:42 +02:00
parent 87114ab186
commit f2a58f3a91
15 changed files with 815 additions and 35 deletions
+122 -7
View File
@@ -4,23 +4,39 @@
use super::shared::*;
use axum::http::header;
#[derive(Deserialize)]
pub(crate) struct LibraryQuery {
/// Only entries owned by this external provider (RFC §8).
provider: Option<String>,
}
/// List the game library
///
/// Every installed-store title (Steam, read from the host's local files — no Steam API key)
/// merged with the user's custom entries, sorted by title. Artwork fields are URLs the client
/// fetches directly (the public Steam CDN for Steam titles).
/// fetches directly (the public Steam CDN for Steam titles). `?provider=` narrows to the
/// entries a given external provider owns.
#[utoipa::path(
get,
path = "/library",
tag = "library",
operation_id = "getLibrary",
params(
("provider" = Option<String>, Query, description = "Only entries owned by this external provider"),
),
responses(
(status = OK, description = "Unified library across all stores", body = [crate::library::GameEntry]),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
pub(crate) async fn get_library() -> Json<Vec<crate::library::GameEntry>> {
Json(crate::library::all_games())
pub(crate) async fn get_library(
Query(q): Query<LibraryQuery>,
) -> Json<Vec<crate::library::GameEntry>> {
let mut games = crate::library::all_games();
if let Some(provider) = q.provider.filter(|p| !p.is_empty()) {
games.retain(|g| g.provider.as_deref() == Some(provider.as_str()));
}
Json(games)
}
/// Add a custom library entry
@@ -75,9 +91,16 @@ pub(crate) async fn update_custom_game(
if input.title.trim().is_empty() {
return api_error(StatusCode::BAD_REQUEST, "title must not be empty");
}
use crate::library::MutateOutcome;
match crate::library::update_custom(&id, input) {
Ok(Some(entry)) => Json(entry).into_response(),
Ok(None) => api_error(StatusCode::NOT_FOUND, "no custom entry with that id"),
Ok(MutateOutcome::Done(entry)) => Json(entry).into_response(),
Ok(MutateOutcome::NotFound) => {
api_error(StatusCode::NOT_FOUND, "no custom entry with that id")
}
Ok(MutateOutcome::ProviderOwned(p)) => api_error(
StatusCode::CONFLICT,
&format!("entry is owned by provider `{p}` — update it through its reconcile"),
),
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
}
}
@@ -97,9 +120,101 @@ pub(crate) async fn update_custom_game(
)
)]
pub(crate) async fn delete_custom_game(Path(id): Path<String>) -> Response {
use crate::library::MutateOutcome;
match crate::library::delete_custom(&id) {
Ok(true) => StatusCode::NO_CONTENT.into_response(),
Ok(false) => api_error(StatusCode::NOT_FOUND, "no custom entry with that id"),
Ok(MutateOutcome::Done(())) => StatusCode::NO_CONTENT.into_response(),
Ok(MutateOutcome::NotFound) => {
api_error(StatusCode::NOT_FOUND, "no custom entry with that id")
}
Ok(MutateOutcome::ProviderOwned(p)) => api_error(
StatusCode::CONFLICT,
&format!(
"entry is owned by provider `{p}` — remove it there, or DELETE the provider set"
),
),
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
}
}
/// The count envelope a provider uninstall returns.
#[derive(Serialize, ToSchema)]
pub(crate) struct ProviderRemoved {
/// How many entries the provider owned (and were removed).
removed: usize,
}
/// Replace a provider's library entries (declarative reconcile)
///
/// Atomically replaces the full entry set owned by `{provider}` (RFC §8): the payload is the
/// provider's desired list, keyed by its own stable `external_id` — the host diffs, keeps each
/// surviving title's host id stable across reconciles, drops orphans, and never touches manual
/// entries or other providers'. An empty array removes everything the provider owns. Emits
/// `library.changed` with the provider as `source`.
#[utoipa::path(
put,
path = "/library/provider/{provider}",
tag = "library",
operation_id = "reconcileProviderEntries",
params(("provider" = String, Path, description = "The provider id ([a-z0-9._-], `manual` reserved)")),
request_body = Vec<crate::library::ProviderEntryInput>,
responses(
(status = OK, description = "The provider's resulting entries (host ids assigned/kept)", body = [crate::library::CustomEntry]),
(status = BAD_REQUEST, description = "Invalid provider id or payload", body = ApiError),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
(status = INTERNAL_SERVER_ERROR, description = "Could not persist the catalog", body = ApiError),
)
)]
pub(crate) async fn reconcile_provider_entries(
Path(provider): Path<String>,
ApiJson(inputs): ApiJson<Vec<crate::library::ProviderEntryInput>>,
) -> Response {
if let Err(e) = crate::library::validate_provider_name(&provider) {
return api_error(StatusCode::BAD_REQUEST, &e);
}
if let Err(e) = crate::library::validate_provider_payload(&inputs) {
return api_error(StatusCode::BAD_REQUEST, &e);
}
match crate::library::reconcile_provider(&provider, inputs) {
Ok(entries) => {
tracing::info!(
provider,
count = entries.len(),
"library provider reconciled"
);
Json(entries).into_response()
}
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
}
}
/// Remove a provider's library entries
///
/// Deletes every entry owned by `{provider}` — the clean-uninstall path for a provider plugin
/// (RFC §8). Emits `library.changed` when anything was removed.
#[utoipa::path(
delete,
path = "/library/provider/{provider}",
tag = "library",
operation_id = "deleteProviderEntries",
params(("provider" = String, Path, description = "The provider id")),
responses(
(status = OK, description = "How many entries were removed", body = ProviderRemoved),
(status = BAD_REQUEST, description = "Invalid provider id", body = ApiError),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
(status = INTERNAL_SERVER_ERROR, description = "Could not persist the catalog", body = ApiError),
)
)]
pub(crate) async fn delete_provider_entries(Path(provider): Path<String>) -> Response {
if let Err(e) = crate::library::validate_provider_name(&provider) {
return api_error(StatusCode::BAD_REQUEST, &e);
}
match crate::library::delete_provider(&provider) {
Ok(removed) => {
if removed > 0 {
tracing::info!(provider, removed, "library provider entries removed");
}
Json(ProviderRemoved { removed }).into_response()
}
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
}
}
+53
View File
@@ -1206,3 +1206,56 @@ async fn hooks_get_shape_and_put_validation() {
let resp = app.clone().oneshot(req).await.expect("infallible");
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
// ------------------------------------------------------------------ library providers
/// Provider reconcile validation (the write path itself is unit-tested in `library::custom`
/// against pure functions — a successful PUT here would touch the developer's real catalog).
#[tokio::test]
async fn provider_reconcile_validation() {
let app = test_app(test_state(), None);
let put = |provider: &str, body: serde_json::Value| {
axum::http::Request::put(format!("/api/v1/library/provider/{provider}"))
.header(axum::http::header::CONTENT_TYPE, "application/json")
.body(Body::from(body.to_string()))
.unwrap()
};
// Reserved / malformed provider ids.
let (s, json) = send(&app, put("manual", serde_json::json!([]))).await;
assert_eq!(s, StatusCode::BAD_REQUEST);
assert!(json["error"].as_str().unwrap().contains("reserved"));
let (s, _) = send(&app, put("Bad%2FName", serde_json::json!([]))).await;
assert_eq!(s, StatusCode::BAD_REQUEST);
// Payload rules: empty external_id, duplicate external_id.
let (s, _) = send(
&app,
put(
"romm",
serde_json::json!([{"external_id": "", "title": "X"}]),
),
)
.await;
assert_eq!(s, StatusCode::BAD_REQUEST);
let (s, json) = send(
&app,
put(
"romm",
serde_json::json!([
{"external_id": "a", "title": "A"},
{"external_id": "a", "title": "B"}
]),
),
)
.await;
assert_eq!(s, StatusCode::BAD_REQUEST);
assert!(json["error"].as_str().unwrap().contains("duplicate"));
// DELETE validates the name too.
let del = axum::http::Request::delete("/api/v1/library/provider/manual")
.body(Body::empty())
.unwrap();
let (s, _) = send(&app, del).await;
assert_eq!(s, StatusCode::BAD_REQUEST);
}