feat(api): HTTP-Vertrag des Daemons — Endpunkte, Validierung und CLI-Client
- POST /speak (202 {id}; bei Mute 202 {muted:true}), GET /status,
POST /cancel, POST /mute, POST /unmute (methodengebunden: 405/404)
- Request/Response-Typen als einzige Vertragsschicht für Daemon und CLI
- Client inkl. HTTPError: Netz-Fehler (Daemon tot) bleibt von HTTP-Fehlern
unterscheidbar — Basis für den Degraded Mode
This commit is contained in:
1 parent
e492baddac
commit
39c3563195
4 files changed
+382
-3
No files matched your search
@@ -0,0 +1,123 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client ist der HTTP-Client der CLI für den Daemon auf 127.0.0.1 —
|
||||||
|
// dieselbe Vertragsschnittstelle, die server.go bedient.
|
||||||
|
type Client struct {
|
||||||
|
// BaseURL ist die Daemon-Adresse (http://127.0.0.1:<port>).
|
||||||
|
BaseURL string
|
||||||
|
|
||||||
|
// HTTP ist der verwendete Client; nil wird von NewClient gesetzt.
|
||||||
|
HTTP *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClient baut den Client für einen Daemon-Port. Zeit-Budgets steuert der
|
||||||
|
// Aufrufer pro Aufruf über den Context (Daemon-Erkennung kurz, /speak
|
||||||
|
// großzügiger); das Client-Timeout ist nur die harte Decke.
|
||||||
|
func NewClient(port int) *Client {
|
||||||
|
return &Client{
|
||||||
|
BaseURL: fmt.Sprintf("http://127.0.0.1:%d", port),
|
||||||
|
HTTP: &http.Client{Timeout: 10 * time.Second},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTPError ist eine Daemon-Antwort mit unerwartetem Status-Code (z. B.
|
||||||
|
// 400 Validierung). Netz-Fehler (Daemon tot) sind dagegen reguläre
|
||||||
|
// *url.Error-Werte — die CLI unterscheidet danach: HTTP-Fehler anzeigen,
|
||||||
|
// Netz-Fehler → Degraded Mode.
|
||||||
|
type HTTPError struct {
|
||||||
|
Status int
|
||||||
|
Body string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *HTTPError) Error() string {
|
||||||
|
return fmt.Sprintf("HTTP %d: %s", e.Status, e.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status ruft GET /status (Daemon-Erkennung, --wait-Polling).
|
||||||
|
func (c *Client) Status(ctx context.Context) (*StatusResponse, error) {
|
||||||
|
var st StatusResponse
|
||||||
|
if err := c.do(ctx, http.MethodGet, "/status", nil, http.StatusOK, &st); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &st, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Speak ruft POST /speak (enqueue).
|
||||||
|
func (c *Client) Speak(ctx context.Context, req SpeakRequest) (*SpeakResponse, error) {
|
||||||
|
var resp SpeakResponse
|
||||||
|
if err := c.do(ctx, http.MethodPost, "/speak", req, http.StatusAccepted, &resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel ruft POST /cancel; Rückgabe ist die Zahl der abgebrochenen
|
||||||
|
// Ansagen.
|
||||||
|
func (c *Client) Cancel(ctx context.Context) (int, error) {
|
||||||
|
var resp CancelResponse
|
||||||
|
if err := c.do(ctx, http.MethodPost, "/cancel", nil, http.StatusOK, &resp); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return resp.Cancelled, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetMute ruft POST /mute (muted=true) bzw. POST /unmute (muted=false).
|
||||||
|
func (c *Client) SetMute(ctx context.Context, muted bool) error {
|
||||||
|
path := "/mute"
|
||||||
|
if !muted {
|
||||||
|
path = "/unmute"
|
||||||
|
}
|
||||||
|
return c.do(ctx, http.MethodPost, path, nil, http.StatusOK, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// do ist der gemeinsame Kern: Request bauen, senden, Status prüfen,
|
||||||
|
// dekodieren. Netz-Fehler kommen ungefiltert zurück (Aufrufer unterscheidet
|
||||||
|
// über errors.As(&HTTPError)).
|
||||||
|
func (c *Client) do(ctx context.Context, method, path string, body any, wantStatus int, out any) error {
|
||||||
|
var rd io.Reader
|
||||||
|
if body != nil {
|
||||||
|
b, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rd = bytes.NewReader(b)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+path, rd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if body != nil {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
client := c.HTTP
|
||||||
|
if client == nil {
|
||||||
|
client = http.DefaultClient
|
||||||
|
}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
data, err := io.ReadAll(io.LimitReader(resp.Body, maxBodyBytes))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if resp.StatusCode != wantStatus {
|
||||||
|
return &HTTPError{Status: resp.StatusCode, Body: strings.TrimSpace(string(data))}
|
||||||
|
}
|
||||||
|
if out == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return json.Unmarshal(data, out)
|
||||||
|
}
|
||||||
+5
-3
@@ -1,4 +1,6 @@
|
|||||||
// Package api implementiert die HTTP-Oberfläche des Daemons auf 127.0.0.1:
|
// Package api definiert die HTTP-Oberfläche des Daemons auf 127.0.0.1:
|
||||||
// POST /speak, GET /status, POST /cancel, POST /mute, POST /unmute (Task 04),
|
// POST /speak (enqueue, 202 {id}; bei Mute 202 {muted:true}), GET /status,
|
||||||
// CORS für /speak und den MCP-Endpoint (Streamable HTTP, Task 06).
|
// POST /cancel, POST /mute, POST /unmute (Task 04). CORS für /speak und
|
||||||
|
// der MCP-Endpoint (Streamable HTTP) folgen in Task 06. Der Client-Teil
|
||||||
|
// ist die CLI-Seite derselben Vertragsschnittstelle.
|
||||||
package api
|
package api
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"code.m3ta.dev/m3tam3re/talk/internal/queue"
|
||||||
|
)
|
||||||
|
|
||||||
|
// maxBodyBytes begrenzt Anfrage-Körper (Ansagen sind kurz; Schutz gegen
|
||||||
|
// versehentliche Riesen-Payloads).
|
||||||
|
const maxBodyBytes = 64 << 10
|
||||||
|
|
||||||
|
// HandlerConfig bündelt die Abhängigkeiten der HTTP-Oberfläche. Der Handler
|
||||||
|
// kennt weder Config-Dateien noch Env — alles wird vom Daemon (cmd/talkd)
|
||||||
|
// injiziert; Prozessgrenzen-Tests fahren genau diese Oberfläche als Black Box.
|
||||||
|
type HandlerConfig struct {
|
||||||
|
// Queue ist die Daemon-Queue (Enqueue/Cancel/Current/Pending).
|
||||||
|
Queue *queue.Queue
|
||||||
|
|
||||||
|
// Mute ist der persistente Mute-State (Datei mute im Config-Dir).
|
||||||
|
Mute queue.MuteStore
|
||||||
|
|
||||||
|
// Version ist die Daemon-Version für GET /status.
|
||||||
|
Version string
|
||||||
|
|
||||||
|
// KnownProviders sind die gültigen Provider-Namen (Validierung des
|
||||||
|
// provider-Felds in POST /speak; talkd liefert provider.Names()).
|
||||||
|
KnownProviders []string
|
||||||
|
|
||||||
|
// Provider löst den aktiven Provider für GET /status auf (Daemon-seitig:
|
||||||
|
// Pin des laufenden Items bzw. erste verfügbare Ketten-Stufe). Optional.
|
||||||
|
Provider func() string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHandler liefert die HTTP-Routen des Daemons. Methoden-Bindung per
|
||||||
|
// ServeMux-Muster: falsche Methode → 405, unbekannter Pfad → 404.
|
||||||
|
func NewHandler(cfg HandlerConfig) http.Handler {
|
||||||
|
h := &handler{
|
||||||
|
queue: cfg.Queue,
|
||||||
|
mute: cfg.Mute,
|
||||||
|
version: cfg.Version,
|
||||||
|
known: make(map[string]bool, len(cfg.KnownProviders)),
|
||||||
|
provider: cfg.Provider,
|
||||||
|
}
|
||||||
|
for _, name := range cfg.KnownProviders {
|
||||||
|
h.known[name] = true
|
||||||
|
}
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("POST /speak", h.speak)
|
||||||
|
mux.HandleFunc("GET /status", h.status)
|
||||||
|
mux.HandleFunc("POST /cancel", h.cancel)
|
||||||
|
mux.HandleFunc("POST /mute", h.muteEndpoint(true))
|
||||||
|
mux.HandleFunc("POST /unmute", h.muteEndpoint(false))
|
||||||
|
return mux
|
||||||
|
}
|
||||||
|
|
||||||
|
// handler ist der interne Zustand der HTTP-Oberfläche.
|
||||||
|
type handler struct {
|
||||||
|
queue *queue.Queue
|
||||||
|
mute queue.MuteStore
|
||||||
|
version string
|
||||||
|
known map[string]bool
|
||||||
|
provider func() string
|
||||||
|
}
|
||||||
|
|
||||||
|
// speak behandelt POST /speak: validieren, bei Mute verwerfen (202,
|
||||||
|
// dokumentiertes Verhalten — bewusst kein Fehler, damit Aufrufer keine
|
||||||
|
// Sonderbehandlung brauchen), sonst enqueue → 202 {id}.
|
||||||
|
func (h *handler) speak(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req SpeakRequest
|
||||||
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxBodyBytes)).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "ungültiger JSON-Body: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.Text) == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "text fehlt oder ist leer")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Priority == "" {
|
||||||
|
req.Priority = string(queue.PriorityNormal)
|
||||||
|
}
|
||||||
|
if !queue.Priority(req.Priority).Valid() {
|
||||||
|
writeError(w, http.StatusBadRequest, fmt.Sprintf("priority: ungültiger Wert %q (erlaubt: low, normal, high)", req.Priority))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Provider != "" && !h.known[req.Provider] {
|
||||||
|
writeError(w, http.StatusBadRequest, fmt.Sprintf("provider: unbekannter Wert %q", req.Provider))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Lang != "" && req.Lang != "de" && req.Lang != "en" {
|
||||||
|
writeError(w, http.StatusBadRequest, fmt.Sprintf("lang: ungültiger Wert %q (erlaubt: de, en)", req.Lang))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if h.mute.Muted() {
|
||||||
|
writeJSON(w, http.StatusAccepted, SpeakResponse{Muted: true})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := h.queue.Enqueue(queue.Item{
|
||||||
|
Text: req.Text,
|
||||||
|
Lang: req.Lang,
|
||||||
|
Provider: req.Provider,
|
||||||
|
Voice: req.Voice,
|
||||||
|
Priority: queue.Priority(req.Priority),
|
||||||
|
Now: req.Now,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "enqueue: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusAccepted, SpeakResponse{ID: id})
|
||||||
|
}
|
||||||
|
|
||||||
|
// status behandelt GET /status: Queue-Länge (+ wartende IDs), aktuelles
|
||||||
|
// Item, Mute-State, aktiver Provider, Version.
|
||||||
|
func (h *handler) status(w http.ResponseWriter, r *http.Request) {
|
||||||
|
pending := h.queue.Pending()
|
||||||
|
ids := make([]string, len(pending))
|
||||||
|
for i, it := range pending {
|
||||||
|
ids[i] = it.ID
|
||||||
|
}
|
||||||
|
st := StatusResponse{
|
||||||
|
Version: h.version,
|
||||||
|
Queue: len(pending),
|
||||||
|
QueueIDs: ids,
|
||||||
|
Muted: h.mute.Muted(),
|
||||||
|
}
|
||||||
|
if cur, ok := h.queue.Current(); ok {
|
||||||
|
st.Current = &CurrentItem{
|
||||||
|
ID: cur.ID,
|
||||||
|
Text: cur.Text,
|
||||||
|
Priority: string(cur.Priority),
|
||||||
|
Now: cur.Now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if h.provider != nil {
|
||||||
|
st.Provider = h.provider()
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, st)
|
||||||
|
}
|
||||||
|
|
||||||
|
// cancel behandelt POST /cancel: laufende Ansage abbrechen + Queue leeren.
|
||||||
|
func (h *handler) cancel(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeJSON(w, http.StatusOK, CancelResponse{Cancelled: h.queue.Cancel()})
|
||||||
|
}
|
||||||
|
|
||||||
|
// muteEndpoint behandelt POST /mute bzw. POST /unmute: persistenter
|
||||||
|
// Mute-State (Datei im Config-Dir, überlebt Daemon-Restarts).
|
||||||
|
func (h *handler) muteEndpoint(muted bool) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := h.mute.Set(muted); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, MuteResponse{Muted: muted})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeJSON schreibt eine JSON-Antwort mit Status-Code.
|
||||||
|
func writeJSON(w http.ResponseWriter, code int, body any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(code)
|
||||||
|
_ = json.NewEncoder(w).Encode(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeError schreibt einen Fehler als {"error": "…"} mit Status-Code.
|
||||||
|
func writeError(w http.ResponseWriter, code int, msg string) {
|
||||||
|
writeJSON(w, code, map[string]string{"error": msg})
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
// Request/Response-Typen des Daemon-HTTP-Vertrags (Task-04-Brief, bindende
|
||||||
|
// Felder). Die CLI und spätere Konsumenten (MCP, Task 06) benutzen dieselben
|
||||||
|
// Typen — eine Quelle, kein Contract-Drift zwischen Daemon und Client.
|
||||||
|
|
||||||
|
// SpeakRequest ist der Body von POST /speak.
|
||||||
|
type SpeakRequest struct {
|
||||||
|
// Text ist der Ansage-Text (Pflicht).
|
||||||
|
Text string `json:"text"`
|
||||||
|
|
||||||
|
// Priority: low | normal | high (Default normal, wenn leer).
|
||||||
|
Priority string `json:"priority,omitempty"`
|
||||||
|
|
||||||
|
// Provider pinnt die Provider-Kette (--provider).
|
||||||
|
Provider string `json:"provider,omitempty"`
|
||||||
|
|
||||||
|
// Voice pinnt eine Voice-ID (--voice).
|
||||||
|
Voice string `json:"voice,omitempty"`
|
||||||
|
|
||||||
|
// Lang pinnt die Sprache de | en (leer = automatische Erkennung).
|
||||||
|
Lang string `json:"lang,omitempty"`
|
||||||
|
|
||||||
|
// Now lässt die Ansage vor allen wartenden springen (--now).
|
||||||
|
Now bool `json:"now,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SpeakResponse ist die Antwort von POST /speak: 202 {"id":"…"} — bei
|
||||||
|
// aktivem Mute 202 {"id":"","muted":true} (Ansage verworfen; dokumentiertes
|
||||||
|
// Verhalten, kein Fehler).
|
||||||
|
type SpeakResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Muted bool `json:"muted,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CurrentItem beschreibt das laufende Item in GET /status.
|
||||||
|
type CurrentItem struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
Priority string `json:"priority"`
|
||||||
|
Now bool `json:"now"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// StatusResponse ist die Antwort von GET /status: Queue-Länge, aktuelles
|
||||||
|
// Item (null im Leerlauf), Mute-State, aktiver Provider, Daemon-Version.
|
||||||
|
// QueueIDs ist die Ergänzung der wartenden IDs in Abarbeitungs-Reihenfolge
|
||||||
|
// — die CLI braucht sie für ihr --wait-Polling.
|
||||||
|
type StatusResponse struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
Queue int `json:"queue"`
|
||||||
|
QueueIDs []string `json:"queue_ids,omitempty"`
|
||||||
|
Current *CurrentItem `json:"current"`
|
||||||
|
Muted bool `json:"muted"`
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PendingOrCurrent meldet, ob die Ansage mit der ID noch wartet oder
|
||||||
|
// gerade läuft — Grundlage des --wait-Pollings der CLI. Ist die Ansage
|
||||||
|
// weder wartend noch laufend, ist sie fertig (oder abgebrochen) — in
|
||||||
|
// beiden Fällen hat der Aufrufer nichts mehr zu warten.
|
||||||
|
func (s StatusResponse) PendingOrCurrent(id string) bool {
|
||||||
|
if s.Current != nil && s.Current.ID == id {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, qid := range s.QueueIDs {
|
||||||
|
if qid == id {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// CancelResponse ist die Antwort von POST /cancel.
|
||||||
|
type CancelResponse struct {
|
||||||
|
Cancelled int `json:"cancelled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MuteResponse ist die Antwort von POST /mute und POST /unmute.
|
||||||
|
type MuteResponse struct {
|
||||||
|
Muted bool `json:"muted"`
|
||||||
|
}
|
||||||
Reference in new issue
Block a user