111 lines
2.9 KiB
Go
111 lines
2.9 KiB
Go
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,
|
|
}})
|
|
}
|