package server import ( "encoding/json" "errors" "fmt" "io" "net/http" "strconv" "strings" "jarl/internal/api" "jarl/internal/auth" "jarl/internal/engine" "jarl/internal/store" ) // httpError is an error with an HTTP status. type httpError struct { status int code string msg string } func (e *httpError) Error() string { return e.msg } func badRequest(format string, args ...any) error { return &httpError{http.StatusBadRequest, "bad_request", fmt.Sprintf(format, args...)} } func notFound(format string, args ...any) error { return &httpError{http.StatusNotFound, "not_found", fmt.Sprintf(format, args...)} } func forbidden(format string, args ...any) error { return &httpError{http.StatusForbidden, "forbidden", fmt.Sprintf(format, args...)} } func conflict(format string, args ...any) error { return &httpError{http.StatusConflict, "conflict", fmt.Sprintf(format, args...)} } func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Cache-Control", "no-store") w.WriteHeader(status) enc := json.NewEncoder(w) enc.SetEscapeHTML(false) enc.Encode(v) } func writeError(w http.ResponseWriter, status int, code, msg string) { writeJSON(w, status, api.ErrorBody{Error: api.ErrorInfo{Code: code, Message: msg}}) } func engineStatus(k engine.ErrorKind) int { switch k { case engine.KindBadRequest: return http.StatusBadRequest case engine.KindForbidden: return http.StatusForbidden case engine.KindNotFound: return http.StatusNotFound case engine.KindConflict, engine.KindDrift, engine.KindNotAdopted: return http.StatusConflict case engine.KindValidation: return http.StatusUnprocessableEntity case engine.KindBIND: return http.StatusBadGateway } return http.StatusInternalServerError } func raw(v any) json.RawMessage { if v == nil { return nil } b, err := json.Marshal(v) if err != nil { return nil } if string(b) == "null" { return nil } return b } // writeErr writes any error as a JSON error response. result, if given, is // included (e.g. the preview of a change that failed validation). func (s *Server) writeErr(w http.ResponseWriter, r *http.Request, err error, result any) { var he *httpError if errors.As(err, &he) { writeError(w, he.status, he.code, he.msg) return } if ee, ok := engine.AsError(err); ok { body := api.ErrorBody{Error: api.ErrorInfo{Code: string(ee.Kind), Message: ee.Error(), Op: ee.OpSeq, Problems: raw(ee.Problems), Check: raw(ee.Check)}} if result != nil { body.Error.Result = raw(result) } writeJSON(w, engineStatus(ee.Kind), body) return } if errors.Is(err, store.ErrNotFound) { writeError(w, http.StatusNotFound, "not_found", "not found") return } if errors.Is(err, store.ErrConflict) { writeError(w, http.StatusConflict, "conflict", "already exists") return } s.log.Error("request failed", "method", r.Method, "path", r.URL.Path, "err", err) writeError(w, http.StatusInternalServerError, "internal", "internal server error: "+err.Error()) } // handler is an authenticated API handler. type handler func(w http.ResponseWriter, r *http.Request, p *auth.Principal) error // decodeJSON reads a JSON request body into v, rejecting unknown fields. func decodeJSON(r *http.Request, v any) error { ct := r.Header.Get("Content-Type") if ct != "" && !strings.HasPrefix(ct, "application/json") { return badRequest("expected a JSON request body (Content-Type: application/json)") } dec := json.NewDecoder(r.Body) dec.DisallowUnknownFields() if err := dec.Decode(v); err != nil { if errors.Is(err, io.EOF) { return badRequest("request body is empty") } var mbe *http.MaxBytesError if errors.As(err, &mbe) { return &httpError{http.StatusRequestEntityTooLarge, "too_large", "request body is too large"} } return badRequest("invalid JSON: %v", err) } return nil } // readBody returns the raw JSON body (may be empty). func readBody(r *http.Request) (json.RawMessage, error) { b, err := io.ReadAll(r.Body) if err != nil { var mbe *http.MaxBytesError if errors.As(err, &mbe) { return nil, &httpError{http.StatusRequestEntityTooLarge, "too_large", "request body is too large"} } return nil, err } if len(strings.TrimSpace(string(b))) == 0 { return nil, nil } if !json.Valid(b) { return nil, badRequest("invalid JSON body") } return b, nil } func pathID(r *http.Request, name string) (int64, error) { v := r.PathValue(name) id, err := strconv.ParseInt(v, 10, 64) if err != nil || id <= 0 { return 0, badRequest("invalid %s %q", name, v) } return id, nil } func queryBool(r *http.Request, name string) bool { switch strings.ToLower(r.URL.Query().Get(name)) { case "1", "true", "yes", "on": return true } return false } func queryInt(r *http.Request, name string, def int) int { if v := r.URL.Query().Get(name); v != "" { if n, err := strconv.Atoi(v); err == nil { return n } } return def }