package server import ( "encoding/json" "errors" "fmt" "io" "net/http" "strconv" "strings" "steward/internal/api" "steward/internal/auth" "steward/internal/engine" "steward/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(kind string) int { switch kind { case engine.KindBadRequest: return http.StatusBadRequest case engine.KindNotFound: return http.StatusNotFound case engine.KindConflict: return http.StatusConflict case engine.KindValidation: return http.StatusUnprocessableEntity } return http.StatusInternalServerError } // writeErr writes an error response. result, if given, is the preview of // a refused change. 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: ee.Kind, Message: ee.Message, Problems: ee.Problems}} if result != nil { body.Error.Result, _ = json.Marshal(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 body into v, refusing 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 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("the request body is empty") } var mbe *http.MaxBytesError if errors.As(err, &mbe) { return &httpError{http.StatusRequestEntityTooLarge, "too_large", "the request body is too large"} } return badRequest("invalid JSON: %v", err) } return nil } // decodeOptional is decodeJSON, but an empty body leaves v alone. func decodeOptional(r *http.Request, v any) error { b, err := io.ReadAll(r.Body) if err != nil { return err } if strings.TrimSpace(string(b)) == "" { return nil } dec := json.NewDecoder(strings.NewReader(string(b))) dec.DisallowUnknownFields() if err := dec.Decode(v); err != nil { return badRequest("invalid JSON: %v", err) } return 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 } func require(p *auth.Principal, resource string, write bool) error { if p.Can(resource, write) { return nil } access := "read" if write { access = "write" } return forbidden("permission denied: this needs %s:%s", resource, access) } func requireAdmin(p *auth.Principal) error { if !p.IsAdmin() { return forbidden("this needs an administrator (and not a restricted token)") } return nil } // via names where a request came from, for the history. func via(r *http.Request, p *auth.Principal) string { switch { case strings.Contains(r.UserAgent(), "steward-cli"): return "cli" case p.Method == "session": return "web" } return "api" }