package httpserver import ( "encoding/json" "errors" "log/slog" "net/http" "github.com/krow/krow-backend/go-api/internal/domain" ) // envelope is the success shape from api-contract.md §4. type envelope struct { Data any `json:"data"` Meta *meta `json:"meta,omitempty"` } // meta accompanies a collection. `truncated` exists so the silent-truncation // problem in §12.2 is fixable without another contract change. type meta struct { Total int `json:"total"` Limit int `json:"limit"` Offset int `json:"offset"` Returned int `json:"returned"` Truncated bool `json:"truncated"` } // errorEnvelope is the failure shape from api-contract.md §5. type errorEnvelope struct { Error errorBody `json:"error"` } type errorBody struct { Code string `json:"code"` Message string `json:"message"` Details map[string]string `json:"details"` } func writeJSON(w http.ResponseWriter, code int, body any) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Cache-Control", "no-store") w.WriteHeader(code) enc := json.NewEncoder(w) enc.SetIndent("", " ") _ = enc.Encode(body) } func writeRecord(w http.ResponseWriter, code int, rec domain.Record) { writeJSON(w, code, envelope{Data: rec}) } func writePage(w http.ResponseWriter, page *domain.Page) { writeJSON(w, http.StatusOK, envelope{ Data: page.Records, Meta: &meta{ Total: page.Total, Limit: page.Limit, Offset: page.Offset, Returned: len(page.Records), Truncated: page.Total > page.Offset+len(page.Records), }, }) } // statusFor maps the contract's error codes onto HTTP status codes. func statusFor(code string) int { switch code { case "unauthorized": return http.StatusUnauthorized case "forbidden": return http.StatusForbidden case "rate_limited": return http.StatusTooManyRequests case "not_found": return http.StatusNotFound case "validation_failed": return http.StatusUnprocessableEntity case "invalid_query": return http.StatusBadRequest case "conflict": return http.StatusConflict default: return http.StatusInternalServerError } } // writeError renders any error as the documented envelope. // // An unrecognised error is deliberately flattened to a generic message: the // detail goes to the log, not to the client. func writeError(w http.ResponseWriter, log *slog.Logger, err error) { var de *domain.Error if !errors.As(err, &de) { log.Error("unhandled error", "error", err) writeJSON(w, http.StatusInternalServerError, errorEnvelope{Error: errorBody{ Code: "internal", Message: "internal error", Details: map[string]string{}, }}) return } if de.Code == "internal" { log.Error("internal error", "error", de.Unwrap()) } details := de.Details if details == nil { details = map[string]string{} } writeJSON(w, statusFor(de.Code), errorEnvelope{Error: errorBody{ Code: de.Code, Message: de.Message, Details: details, }}) }