feat(server/m7-cover-sources): admin cover-sources HTTP handlers
Three endpoints for the admin Settings UI:
- GET /api/admin/cover-sources — lists registered providers with
current settings + capability badges + sources_version
- PATCH /api/admin/cover-sources/{provider_id} — updates enabled
and/or api_key; returns version_bumped: true only when the
enabled set changed
- POST /api/admin/cover-sources/{provider_id}/test — invokes
TestableProvider.TestConnection; returns ok:bool + duration_ms +
error string. Returns 200 in both success and failure cases (the
operation completed; the test result is data, not an error).
api_key is never echoed in GET — replaced with api_key_set: bool
to follow the standard credential-display convention. PATCH
api_key: "" clears; omitting the field leaves it unchanged. PATCH
on an unknown provider_id returns 404.
All under existing RequireAdmin middleware. Routes registered
alongside the library/coverage endpoint in the /api/admin/ tree.
handlers struct gains a coverSettings *coverart.SettingsService
field; server.New + cmd/minstrel/main.go plumb the existing
coverSettings instance through.
coverart.ResetRegistryForTests() exported wrapper added so api
package tests can reset the registry without importing internals.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
|
||||
)
|
||||
|
||||
// coverProviderResp is the wire shape for one provider in the
|
||||
// GET /api/admin/cover-sources list. Mirrors coverart.ProviderInfo
|
||||
// but uses snake_case JSON tags.
|
||||
type coverProviderResp struct {
|
||||
ID string `json:"id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
RequiresAPIKey bool `json:"requires_api_key"`
|
||||
Supports []string `json:"supports"`
|
||||
Enabled bool `json:"enabled"`
|
||||
APIKeySet bool `json:"api_key_set"`
|
||||
DisplayOrder int32 `json:"display_order"`
|
||||
Testable bool `json:"testable"`
|
||||
}
|
||||
|
||||
type coverProvidersListResp struct {
|
||||
Providers []coverProviderResp `json:"providers"`
|
||||
SourcesVersion int32 `json:"sources_version"`
|
||||
}
|
||||
|
||||
// handleListCoverSources implements GET /api/admin/cover-sources.
|
||||
func (h *handlers) handleListCoverSources(w http.ResponseWriter, _ *http.Request) {
|
||||
infos := h.coverSettings.ListProviderInfo()
|
||||
out := coverProvidersListResp{
|
||||
Providers: make([]coverProviderResp, 0, len(infos)),
|
||||
SourcesVersion: h.coverSettings.CurrentVersion(),
|
||||
}
|
||||
for _, info := range infos {
|
||||
supports := info.Supports
|
||||
if supports == nil {
|
||||
supports = []string{}
|
||||
}
|
||||
out.Providers = append(out.Providers, coverProviderResp{
|
||||
ID: info.ID,
|
||||
DisplayName: info.DisplayName,
|
||||
RequiresAPIKey: info.RequiresAPIKey,
|
||||
Supports: supports,
|
||||
Enabled: info.Enabled,
|
||||
APIKeySet: info.APIKeySet,
|
||||
DisplayOrder: info.DisplayOrder,
|
||||
Testable: info.Testable,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// updateCoverSourceReq is the PATCH body. nil pointers mean
|
||||
// "leave unchanged"; non-nil means "set to this value (which may be
|
||||
// empty string for api_key clearing)".
|
||||
type updateCoverSourceReq struct {
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
APIKey *string `json:"api_key,omitempty"`
|
||||
}
|
||||
|
||||
type updateCoverSourceResp struct {
|
||||
coverProviderResp
|
||||
VersionBumped bool `json:"version_bumped"`
|
||||
}
|
||||
|
||||
// handleUpdateCoverSource implements PATCH /api/admin/cover-sources/{provider_id}.
|
||||
func (h *handlers) handleUpdateCoverSource(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "provider_id")
|
||||
|
||||
var body updateCoverSourceReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "bad_body", "invalid JSON")
|
||||
return
|
||||
}
|
||||
|
||||
bumped, err := h.coverSettings.UpdateProvider(r.Context(), id, coverart.ProviderUpdatePatch{
|
||||
Enabled: body.Enabled,
|
||||
APIKey: body.APIKey,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, coverart.ErrProviderNotFound) {
|
||||
writeErr(w, http.StatusNotFound, "not_found", "no such provider")
|
||||
return
|
||||
}
|
||||
h.logger.Error("admin: update cover source", "id", id, "err", err)
|
||||
writeErr(w, http.StatusInternalServerError, "server_error", "update failed")
|
||||
return
|
||||
}
|
||||
|
||||
// Re-fetch the provider info to return.
|
||||
infos := h.coverSettings.ListProviderInfo()
|
||||
var found coverProviderResp
|
||||
for _, info := range infos {
|
||||
if info.ID == id {
|
||||
supports := info.Supports
|
||||
if supports == nil {
|
||||
supports = []string{}
|
||||
}
|
||||
found = coverProviderResp{
|
||||
ID: info.ID,
|
||||
DisplayName: info.DisplayName,
|
||||
RequiresAPIKey: info.RequiresAPIKey,
|
||||
Supports: supports,
|
||||
Enabled: info.Enabled,
|
||||
APIKeySet: info.APIKeySet,
|
||||
DisplayOrder: info.DisplayOrder,
|
||||
Testable: info.Testable,
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, updateCoverSourceResp{
|
||||
coverProviderResp: found,
|
||||
VersionBumped: bumped,
|
||||
})
|
||||
}
|
||||
|
||||
type testCoverSourceResp struct {
|
||||
OK bool `json:"ok"`
|
||||
DurationMs int64 `json:"duration_ms,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// handleTestCoverSource implements POST /api/admin/cover-sources/{provider_id}/test.
|
||||
func (h *handlers) handleTestCoverSource(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "provider_id")
|
||||
|
||||
start := time.Now()
|
||||
err := h.coverSettings.TestProvider(r.Context(), id)
|
||||
duration := time.Since(start).Milliseconds()
|
||||
|
||||
if err == nil {
|
||||
writeJSON(w, http.StatusOK, testCoverSourceResp{OK: true, DurationMs: duration})
|
||||
return
|
||||
}
|
||||
|
||||
if errors.Is(err, coverart.ErrProviderNotFound) {
|
||||
writeErr(w, http.StatusNotFound, "not_found", "no such provider")
|
||||
return
|
||||
}
|
||||
|
||||
// Not testable, or test failed — both surface as ok=false.
|
||||
// 200 status because the operation completed; the test result is
|
||||
// data, not an error.
|
||||
writeJSON(w, http.StatusOK, testCoverSourceResp{
|
||||
OK: false,
|
||||
DurationMs: duration,
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user