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)
|
||||
}
|
||||
Reference in new issue
Block a user