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
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:
@@ -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()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user