Files
pages/internal/httpx/json.go
T
2026-08-15 07:13:00 +00:00

128 lines
4.3 KiB
Go

// Package httpx holds the HTTP plumbing shared by both listeners: the JSON
// error envelope, the middleware chain, and server lifecycle management.
package httpx
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"github.com/iceBear67/simplepages/api"
)
// StatusFor maps a machine-readable code to its HTTP status.
func StatusFor(code api.Code) int {
switch code {
case api.CodeBadRequest, api.CodeInvalidProjectName, api.CodeInvalidPath,
api.CodeDigestMismatch, api.CodeSizeMismatch:
return http.StatusBadRequest
case api.CodeUnauthorized:
return http.StatusUnauthorized
case api.CodeForbidden:
return http.StatusForbidden
case api.CodeNotFound:
return http.StatusNotFound
case api.CodeMethodNotAllowed:
return http.StatusMethodNotAllowed
case api.CodeConflict, api.CodeProjectExists, api.CodeDeploymentNotReady,
api.CodeDeploymentActive, api.CodeBlobsMissing:
return http.StatusConflict
case api.CodePayloadTooLarge, api.CodeLimitExceeded:
return http.StatusRequestEntityTooLarge
case api.CodeRateLimited:
return http.StatusTooManyRequests
case api.CodeUnavailable:
return http.StatusServiceUnavailable
default:
return http.StatusInternalServerError
}
}
// WriteJSON writes v as JSON with the given status.
func WriteJSON(w http.ResponseWriter, status int, v any) {
buf, err := json.Marshal(v)
if err != nil {
// Marshalling our own response types should never fail; if it does the
// handler already wrote nothing, so a bare 500 is the honest answer.
http.Error(w, `{"error":{"code":"internal","message":"response encoding failed"}}`,
http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_, _ = w.Write(buf)
_, _ = w.Write([]byte("\n"))
}
// WriteError renders err as the standard error envelope. Non-api errors become
// an opaque 500: internal failure text may name paths or SQL and must not reach
// the client. The full error is logged instead.
func WriteError(w http.ResponseWriter, r *http.Request, log *slog.Logger, err error) {
var apiErr *api.Error
if !errors.As(err, &apiErr) {
var maxErr *http.MaxBytesError
if errors.As(err, &maxErr) {
apiErr = api.Errorf(api.CodePayloadTooLarge, "request body exceeds %d bytes", maxErr.Limit)
} else {
if log != nil {
log.ErrorContext(r.Context(), "unhandled error", "err", err,
"method", r.Method, "path", r.URL.Path)
}
apiErr = api.Errorf(api.CodeInternal, "internal error")
}
}
status := StatusFor(apiErr.Code)
if status >= 500 && log != nil {
log.ErrorContext(r.Context(), "request failed", "err", err,
"code", string(apiErr.Code), "method", r.Method, "path", r.URL.Path)
}
WriteJSON(w, status, api.ErrorEnvelope{Error: *apiErr})
}
// DecodeJSON reads a JSON body into v, capped at maxBytes. It rejects unknown
// fields (a misspelled key in a deploy script should fail, not be ignored) and
// trailing content after the top-level value.
func DecodeJSON(w http.ResponseWriter, r *http.Request, maxBytes int64, v any) error {
r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(v); err != nil {
var maxErr *http.MaxBytesError
if errors.As(err, &maxErr) {
return api.Errorf(api.CodePayloadTooLarge, "request body exceeds %d bytes", maxErr.Limit)
}
var syn *json.SyntaxError
if errors.As(err, &syn) {
return api.Errorf(api.CodeBadRequest, "malformed JSON at byte %d", syn.Offset)
}
var typeErr *json.UnmarshalTypeError
if errors.As(err, &typeErr) {
return api.Errorf(api.CodeBadRequest, "field %q: want %s", typeErr.Field, typeErr.Type)
}
return api.Errorf(api.CodeBadRequest, "%s", err)
}
if dec.More() {
return api.Errorf(api.CodeBadRequest, "unexpected content after JSON value")
}
return nil
}
// newInternalEnvelope builds the opaque 500 body. The request id is included so
// an operator can find the corresponding log line, which holds the real cause.
func newInternalEnvelope(reqID string) api.ErrorEnvelope {
e := api.Errorf(api.CodeInternal, "internal error")
if reqID != "" {
e = e.WithDetail("request_id", reqID)
}
return api.ErrorEnvelope{Error: *e}
}
// NoBody rejects requests that carry a body where none is expected.
func NoBody(r *http.Request) error {
if r.ContentLength > 0 {
return api.Errorf(api.CodeBadRequest, "unexpected request body")
}
return nil
}