first commit
This commit is contained in:
110
go-api/internal/httpserver/response.go
Normal file
110
go-api/internal/httpserver/response.go
Normal file
@@ -0,0 +1,110 @@
|
||||
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,
|
||||
}})
|
||||
}
|
||||
Reference in New Issue
Block a user