Umbau-Projekt (SSR und Rest)
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// Fehler-Taxonomie: trennt die drei Informationen, die früher an jeder
|
||||
// Fehler-Aufrufstelle verklebt waren -- wie schwer der Fehler ist (Kind),
|
||||
// was genau passiert ist (Code) und was der Nutzer lesen soll (Msg).
|
||||
//
|
||||
// Der Sinn der Trennung: derselbe Fehler muss künftig von zwei Adaptern
|
||||
// unterschiedlich dargestellt werden. Der HTML-Pfad braucht Msg und Field, um
|
||||
// ein Formular mit Fehlermarkierung neu zu rendern; ein API-Client braucht
|
||||
// Code, um ohne Parsen deutscher Strings reagieren zu können.
|
||||
|
||||
type Kind uint8
|
||||
|
||||
const (
|
||||
KindInternal Kind = iota // Bug oder Infrastruktur -- Ursache nie nach außen
|
||||
KindInvalid // Eingabe passt nicht
|
||||
KindUnauth // nicht angemeldet
|
||||
KindForbidden // angemeldet, aber nicht erlaubt
|
||||
KindNotFound // Objekt existiert nicht (oder soll nicht sichtbar sein)
|
||||
KindConflict // Name vergeben, Doppel-Post
|
||||
KindTooLarge // Upload über dem Limit
|
||||
KindRateLimited // zu viele Anfragen
|
||||
KindUnavailable // Feature nicht konfiguriert
|
||||
)
|
||||
|
||||
// Error ist der Fehlertyp, den die Service-Schicht nach oben gibt. Kind ist
|
||||
// bewusst kein HTTP-Status: die Zuordnung passiert erst im Adapter, damit ein
|
||||
// künftiger CLI- oder App-Client dieselben Fehler auf eigene Weise abbilden
|
||||
// kann (Exit-Code, Dialog, Retry).
|
||||
type Error struct {
|
||||
Kind Kind
|
||||
Code string // stabil und maschinenlesbar, Schema "<bereich>.<sache>"
|
||||
Msg string // deutsche Nutzermeldung
|
||||
Field string // optional: betroffenes Formularfeld
|
||||
Fields map[string]string // optional: mehrere Feldfehler auf einmal
|
||||
cause error // nur fürs Log, nie für den Nutzer
|
||||
}
|
||||
|
||||
func (e *Error) Error() string { return e.Code + ": " + e.Msg }
|
||||
|
||||
// Unwrap gibt die eingepackte Ursache frei, damit errors.Is/As über den
|
||||
// Taxonomie-Fehler hinweg bis zum ursprünglichen Fehler (etwa sql.ErrNoRows)
|
||||
// durchsuchen kann.
|
||||
func (e *Error) Unwrap() error { return e.cause }
|
||||
|
||||
func Invalid(code, msg string) *Error { return &Error{Kind: KindInvalid, Code: code, Msg: msg} }
|
||||
func Unauth(code, msg string) *Error { return &Error{Kind: KindUnauth, Code: code, Msg: msg} }
|
||||
func Forbidden(code, msg string) *Error { return &Error{Kind: KindForbidden, Code: code, Msg: msg} }
|
||||
func NotFound(code, msg string) *Error { return &Error{Kind: KindNotFound, Code: code, Msg: msg} }
|
||||
func Conflict(code, msg string) *Error { return &Error{Kind: KindConflict, Code: code, Msg: msg} }
|
||||
func TooLarge(code, msg string) *Error { return &Error{Kind: KindTooLarge, Code: code, Msg: msg} }
|
||||
func RateLimited(code, msg string) *Error { return &Error{Kind: KindRateLimited, Code: code, Msg: msg} }
|
||||
func Unavailable(code, msg string) *Error { return &Error{Kind: KindUnavailable, Code: code, Msg: msg} }
|
||||
|
||||
// Internal verschluckt die Ursache absichtlich: der Nutzer sieht nie einen
|
||||
// DB- oder Dateisystemfehler (der Interna und Pfade verraten würde), das Log
|
||||
// sieht ihn immer. Deshalb hat Internal auch keine freie Msg -- eine
|
||||
// nutzerseitige Unterscheidung zwischen "Datenbankfehler" und "Speichern
|
||||
// fehlgeschlagen" ist bedeutungslos.
|
||||
func Internal(cause error) *Error {
|
||||
return &Error{
|
||||
Kind: KindInternal,
|
||||
Code: "internal",
|
||||
Msg: "Es ist ein Fehler aufgetreten.",
|
||||
cause: cause,
|
||||
}
|
||||
}
|
||||
|
||||
// At markiert das Formularfeld, an dem der Fehler hängt. Verkettbar, damit die
|
||||
// Aufrufstelle einzeilig bleibt: return Invalid(...).At("content")
|
||||
//
|
||||
// Achtung: At und WithCause verändern den Empfänger. Auf einen frisch
|
||||
// konstruierten Fehler anwenden, NICHT auf ein geteiltes Sentinel wie
|
||||
// errEntryNotFound -- das würde den globalen Wert für alle Requests umschreiben.
|
||||
func (e *Error) At(field string) *Error {
|
||||
e.Field = field
|
||||
return e
|
||||
}
|
||||
|
||||
// WithCause hängt eine technische Ursache an einen fachlichen Fehler. Nützlich,
|
||||
// wenn ein DB-Fehler fachlich eindeutig ist (UNIQUE-Verletzung -> Conflict),
|
||||
// die Ursache aber trotzdem ins Log soll.
|
||||
func (e *Error) WithCause(cause error) *Error {
|
||||
e.cause = cause
|
||||
return e
|
||||
}
|
||||
|
||||
// asError normalisiert alles, was kein *Error ist, zu einem Internal. Damit
|
||||
// müssen die Adapter keinen Sonderfall für "nackte" Fehler kennen, die aus
|
||||
// Bibliotheken oder noch nicht umgestellten Stellen kommen.
|
||||
func asError(err error) *Error {
|
||||
var e *Error
|
||||
if errors.As(err, &e) {
|
||||
return e
|
||||
}
|
||||
return Internal(err)
|
||||
}
|
||||
|
||||
// statusOf bildet Kind auf HTTP ab. Beide HTTP-Adapter (HTML und JSON) teilen
|
||||
// sich diese Zuordnung -- unterschiedliche Statuscodes für denselben Fehler
|
||||
// wären genau die Divergenz, die die Taxonomie verhindern soll.
|
||||
func statusOf(k Kind) int {
|
||||
switch k {
|
||||
case KindInvalid:
|
||||
return http.StatusBadRequest
|
||||
case KindUnauth:
|
||||
return http.StatusUnauthorized
|
||||
case KindForbidden:
|
||||
return http.StatusForbidden
|
||||
case KindNotFound:
|
||||
return http.StatusNotFound
|
||||
case KindConflict:
|
||||
return http.StatusConflict
|
||||
case KindTooLarge:
|
||||
return http.StatusRequestEntityTooLarge
|
||||
case KindRateLimited:
|
||||
return http.StatusTooManyRequests
|
||||
case KindUnavailable:
|
||||
return http.StatusServiceUnavailable
|
||||
default:
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
}
|
||||
|
||||
// logInternal protokolliert nur echte Serverfehler -- Validierungs- und
|
||||
// Rechtefehler sind normaler Betrieb und würden das Log zumüllen. Beide
|
||||
// Adapter rufen das auf, damit ein Fehler unabhängig vom Ausgabeformat
|
||||
// genau einmal im Log landet.
|
||||
func logInternal(e *Error) {
|
||||
if e.Kind != KindInternal {
|
||||
return
|
||||
}
|
||||
// Die Ursache ist das Einzige, was hier wirklich interessiert; fehlt sie,
|
||||
// bleibt wenigstens die Meldung.
|
||||
if e.cause != nil {
|
||||
log.Printf("internal: %v", e.cause)
|
||||
} else {
|
||||
log.Printf("internal: %s", e.Msg)
|
||||
}
|
||||
}
|
||||
|
||||
// apiErrorBody ist das Drahtformat des JSON-Adapters. Eigener Typ statt einer
|
||||
// Map, damit die Feldnamen an genau einer Stelle stehen -- sie sind Teil des
|
||||
// API-Vertrags, sobald ein externer Client existiert.
|
||||
type apiErrorBody struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Field string `json:"field,omitempty"`
|
||||
Fields map[string]string `json:"fields,omitempty"`
|
||||
}
|
||||
|
||||
// writeAPIError ist der einzige Ausgang für Fehler im JSON-Adapter. Hier -- an
|
||||
// der Adaptergrenze -- wird auch geloggt, und zwar nur einmal: die
|
||||
// Service-Schicht loggt nicht, sonst steht derselbe Fehler mehrfach im Log.
|
||||
func writeAPIError(w http.ResponseWriter, err error) {
|
||||
e := asError(err)
|
||||
logInternal(e)
|
||||
|
||||
writeJSON(w, statusOf(e.Kind), map[string]any{
|
||||
"error": apiErrorBody{
|
||||
Code: e.Code,
|
||||
Message: e.Msg,
|
||||
Field: e.Field,
|
||||
Fields: e.Fields,
|
||||
},
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user