syncova-backup/apps/api/internal/httpapi/response.go
Jerrit Fritzsche 610719c316
Some checks failed
CI / Backend (Go) (push) Failing after 3m7s
CI / Frontend (React/TypeScript) (push) Successful in 37s
CI / Sicherheitsprüfungen (push) Successful in 44s
Syncova Backups V1
Enterprise-Backup-, Recovery-, Verification-, Security- und
Monitoring-Plattform fuer Proxmox VE, Windows, Linux und Dateisysteme.

Der Leitsatz, der fast jede Entscheidung erklaert: Ein Backup gilt erst als
vertrauenswuerdig, wenn Integritaet geprueft und Wiederherstellbarkeit
nachgewiesen wurde. Deshalb steigt ein Wiederherstellungspunkt erst nach einem
tatsaechlich durchgefuehrten Restore-Test auf "recoverable", und Unbekanntes
geht in keine Bewertung als "gut" ein.

Umfang (Phasen 0-23):

- Repository Engine: inhaltsadressierte Bloecke, atomares Commit-Protokoll,
  Katalogaufbau allein aus den Manifesten — ohne Datenbank
- Backup Engine: inhaltsabhaengiges Chunking, Deduplizierung trotz
  Verschluesselung, zstd, AES-256-GCM, Streaming mit Gegendruck
- Agenten fuer Windows und Linux mit Auftragsabholung (Pull-Modell)
- Proxmox-Provider mit beiden Zugriffswegen auf die Sicherungsarchive
- Scheduler, Recovery Engine mit Pruefpunkt, Verification, Unveraenderlichkeit
- Weboberflaeche, Kennzahlen, Meldungen, Berichte, Security Center,
  Ransomware-Heuristik (meldet, handelt nie)
- Disaster Recovery, Haertung, Leistungsmessung, Chaos Testing
- Eingefrorene Vertraege fuer API, Migrationen, Backup-Format und Repository
- Auslieferungspaket fuer linux/amd64, linux/arm64 und windows/amd64

Nicht enthalten und als solches gekennzeichnet: Kapazitaetsprognose, Backup
Copy, Changed Block Tracking bei Proxmox, erweiterte Attribute und ACLs.

Gebaut, aber nie auf echter Hardware gefahren: der Windows-Dienst, die
systemd-Einheit und der verpflichtende Proxmox-Meilenstein — ob eine
wiederhergestellte VM startet, ist ungeprueft. Einzelheiten in CHANGELOG.md
und docs/release-candidate.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 09:10:54 +02:00

148 lines
5.2 KiB
Go

package httpapi
import (
"encoding/json"
"log/slog"
"net/http"
"github.com/syncova/syncova/packages/platform/logging"
)
// SuccessResponse ist die einheitliche Hülle erfolgreicher Antworten
// (SYNCOVA_API.md §1).
type SuccessResponse struct {
// Data trägt die eigentliche Nutzlast.
Data any `json:"data"`
// Meta trägt Kontextinformationen wie Request-ID und Pagination.
Meta ResponseMeta `json:"meta"`
}
// ResponseMeta beschreibt den Kontext einer Antwort.
type ResponseMeta struct {
// RequestID identifiziert diesen Request eindeutig und taucht auch im Log auf.
RequestID string `json:"request_id"`
// Page ist die aktuelle Seitennummer; nur bei paginierten Listen gesetzt.
Page *int `json:"page,omitempty"`
// PageSize ist die Seitengröße; nur bei paginierten Listen gesetzt.
PageSize *int `json:"page_size,omitempty"`
// Total ist die Gesamtzahl verfügbarer Einträge; nur bei paginierten Listen gesetzt.
Total *int64 `json:"total,omitempty"`
}
// ErrorResponse ist die einheitliche Hülle fehlerhafter Antworten
// (SYNCOVA_API.md §1).
type ErrorResponse struct {
// Error beschreibt den aufgetretenen Fehler.
Error ErrorBody `json:"error"`
}
// ErrorBody ist der Fehlerkörper einer Antwort.
type ErrorBody struct {
// Code ist der stabile maschinenlesbare Fehlercode.
Code ErrorCode `json:"code"`
// Message erklärt den Fehler verständlich (PROMPT.md §124).
Message string `json:"message"`
// Details trägt optionale unbedenkliche Zusatzinformationen.
Details map[string]any `json:"details,omitempty"`
// RequestID verknüpft die Fehlermeldung mit dem Serverlog.
RequestID string `json:"request_id"`
}
// PaginationMeta beschreibt die Seiteninformationen einer Liste.
type PaginationMeta struct {
// Page ist die aktuelle Seitennummer, beginnend bei 1.
Page int
// PageSize ist die Anzahl der Einträge pro Seite.
PageSize int
// Total ist die Gesamtzahl verfügbarer Einträge.
Total int64
}
// WriteSuccess schreibt eine erfolgreiche Antwort in der Standard-Hülle.
func WriteSuccess(responseWriter http.ResponseWriter, request *http.Request, statusCode int, payloadData any) {
writeJSON(responseWriter, request, statusCode, SuccessResponse{
Data: payloadData,
Meta: ResponseMeta{RequestID: RequestIDFromContext(request.Context())},
})
}
// WritePaginatedSuccess schreibt eine paginierte Liste in der Standard-Hülle
// (SYNCOVA_API.md §28).
func WritePaginatedSuccess(responseWriter http.ResponseWriter, request *http.Request, payloadData any, pagination PaginationMeta) {
// Die Werte werden als Zeiger übergeben, damit sie bei nicht paginierten
// Antworten vollständig aus dem JSON verschwinden.
currentPage := pagination.Page
currentPageSize := pagination.PageSize
totalEntries := pagination.Total
writeJSON(responseWriter, request, http.StatusOK, SuccessResponse{
Data: payloadData,
Meta: ResponseMeta{
RequestID: RequestIDFromContext(request.Context()),
Page: &currentPage,
PageSize: &currentPageSize,
Total: &totalEntries,
},
})
}
// WriteError schreibt eine Fehlerantwort und protokolliert die interne Ursache.
//
// Der Aufrufer erhält ausschließlich die freigegebene Darstellung; die Ursache
// bleibt im Log (SYNCOVA_API.md §26).
func WriteError(responseWriter http.ResponseWriter, request *http.Request, requestLogger *slog.Logger, apiError *APIError) {
requestID := RequestIDFromContext(request.Context())
// Serverfehler sind Betriebsprobleme, Client-Fehler nur Hinweise —
// die Log-Level unterscheiden sich deshalb bewusst.
logAttributes := []any{
slog.String(logging.FieldErrorCode, string(apiError.Code)),
slog.Int("status_code", apiError.StatusCode),
slog.String("error", apiError.Error()),
}
if apiError.StatusCode >= http.StatusInternalServerError {
requestLogger.Error("request fehlgeschlagen", logAttributes...)
} else {
requestLogger.Warn("request abgelehnt", logAttributes...)
}
writeJSON(responseWriter, request, apiError.StatusCode, ErrorResponse{
Error: ErrorBody{
Code: apiError.Code,
Message: apiError.Message,
Details: apiError.Details,
RequestID: requestID,
},
})
}
// writeJSON serialisiert einen Antwortkörper und setzt die passenden Header.
func writeJSON(responseWriter http.ResponseWriter, request *http.Request, statusCode int, responseBody any) {
responseWriter.Header().Set("Content-Type", "application/json; charset=utf-8")
// Die Request-ID gehört auch in den Header, damit sie bei leerem Body
// (etwa 204) nicht verloren geht.
if requestID := RequestIDFromContext(request.Context()); requestID != "" {
responseWriter.Header().Set(headerRequestID, requestID)
}
// 204 darf per HTTP-Spezifikation keinen Body besitzen.
if statusCode == http.StatusNoContent {
responseWriter.WriteHeader(statusCode)
return
}
encodedBody, encodeError := json.Marshal(responseBody)
if encodeError != nil {
// Der Body ließ sich nicht serialisieren. Ein halb geschriebener Body
// wäre schlimmer als eine klare, minimale Fehlerantwort.
responseWriter.WriteHeader(http.StatusInternalServerError)
_, _ = responseWriter.Write([]byte(`{"error":{"code":"INTERNAL_ERROR","message":"Die Antwort konnte nicht erzeugt werden."}}`))
return
}
responseWriter.WriteHeader(statusCode)
_, _ = responseWriter.Write(encodedBody)
}