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: ¤tPage, PageSize: ¤tPageSize, 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) }