init
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user