// 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 }