128 lines
4.3 KiB
Go
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
|
|
}
|