// Package api holds the golem15.quizzes JSON API handlers, mounted under // /_quizzes/api/v1 behind the jwt.auth middleware. package api import ( "context" "encoding/json" "errors" "io" "net/http" "strconv" "strings" "git.golem15.com/golem15/sm-quizzes-plugin/classes" "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/bouncer" "git.golem15.com/golem15/summercms/modules/lagoon" "git.golem15.com/golem15/summercms/modules/phrasebook" "git.golem15.com/golem15/summercms/modules/surf" "git.golem15.com/golem15/summercms/modules/wire" "gorm.io/gorm" ) // Handlers serves the quiz API. It resolves the database and the scoring // config from the application on every request. type Handlers struct { App *backpack.App // Service, when set, replaces the per-request service (tests). Service func(*gorm.DB) *classes.Service } // New returns the handlers for app. func New(app *backpack.App) *Handlers { return &Handlers{App: app} } var ( startAttemptRules = []lagoon.RequestRule{ {Field: "category", Rules: lagoon.ParseRules("nullable|string|max:255")}, } answerRules = []lagoon.RequestRule{ {Field: "question_id", Rules: lagoon.ParseRules("required|integer|min:1")}, {Field: "answer_ids", Rules: lagoon.ParseRules("nullable|array")}, {Field: "answer_ids.*", Rules: lagoon.ParseRules("integer|min:1")}, {Field: "known", Rules: lagoon.ParseRules("nullable|boolean")}, } ) // ListQuizzes answers GET /quizzes?context=. func (h *Handlers) ListQuizzes(w http.ResponseWriter, r *http.Request) { userID, svc, ok := h.begin(w, r) if !ok { return } rows, err := svc.ListQuizzes(r.Context(), userID, strings.TrimSpace(r.URL.Query().Get("context"))) if err != nil { wire.WriteOpaque500(w) return } out := make([]quizSummaryJSON, 0, len(rows)) for _, row := range rows { out = append(out, quizSummary(row)) } wire.WriteJSON(w, http.StatusOK, map[string]any{"data": out}) } // ShowQuiz answers GET /quizzes/{slug}?category=. func (h *Handlers) ShowQuiz(w http.ResponseWriter, r *http.Request) { _, svc, ok := h.begin(w, r) if !ok { return } detail, err := svc.GetQuiz(r.Context(), r.PathValue("slug"), strings.TrimSpace(r.URL.Query().Get("category"))) if err != nil { h.writeError(w, r, err) return } wire.WriteJSON(w, http.StatusOK, map[string]any{"data": quizDetail(detail)}) } // StartAttempt answers POST /quizzes/{slug}/attempts with an optional // {"category": "..."} body. func (h *Handlers) StartAttempt(w http.ResponseWriter, r *http.Request) { userID, ok := h.user(w, r) if !ok { return } input, ok := h.input(w, r, startAttemptRules) if !ok { return } svc, ok := h.service(w) if !ok { return } category, _ := input["category"].(string) a, quiz, err := svc.StartAttempt(r.Context(), userID, r.PathValue("slug"), strings.TrimSpace(category)) if err != nil { h.writeError(w, r, err) return } a.Quiz = quiz wire.WriteJSON(w, http.StatusCreated, map[string]any{"data": attempt(classes.AttemptSummary{Attempt: *a})}) } // Answer answers POST /attempts/{id}/answers with // {"question_id": 1, "answer_ids": [2], "known": true}. func (h *Handlers) Answer(w http.ResponseWriter, r *http.Request) { userID, ok := h.user(w, r) if !ok { return } attemptID, ok := h.pathID(w, r) if !ok { return } input, ok := h.input(w, r, answerRules) if !ok { return } svc, ok := h.service(w) if !ok { return } in := classes.AnswerInput{QuestionID: uint(toInt(input["question_id"]))} if raw, present := input["answer_ids"]; present && raw != nil { ids := []uint{} list, _ := raw.([]any) // the array rule already refused anything else for _, v := range list { ids = append(ids, uint(toInt(v))) } in.AnswerIDs = ids } if raw, present := input["known"]; present && raw != nil { known := toBool(raw) in.Known = &known } fb, err := svc.Answer(r.Context(), userID, attemptID, in) if err != nil { h.writeError(w, r, err) return } wire.WriteJSON(w, http.StatusOK, map[string]any{"data": feedback(fb)}) } // Finish answers POST /attempts/{id}/finish. func (h *Handlers) Finish(w http.ResponseWriter, r *http.Request) { userID, ok := h.user(w, r) if !ok { return } attemptID, ok := h.pathID(w, r) if !ok { return } svc, ok := h.service(w) if !ok { return } res, err := svc.Finish(r.Context(), userID, attemptID) if err != nil { h.writeError(w, r, err) return } wire.WriteJSON(w, http.StatusOK, map[string]any{"data": result(res)}) } // ShowAttempt answers GET /attempts/{id}. func (h *Handlers) ShowAttempt(w http.ResponseWriter, r *http.Request) { userID, ok := h.user(w, r) if !ok { return } attemptID, ok := h.pathID(w, r) if !ok { return } svc, ok := h.service(w) if !ok { return } res, err := svc.Result(r.Context(), userID, attemptID) if err != nil { h.writeError(w, r, err) return } wire.WriteJSON(w, http.StatusOK, map[string]any{"data": result(res)}) } // ListAttempts answers GET /attempts?quiz={slug}&page=&per_page=. func (h *Handlers) ListAttempts(w http.ResponseWriter, r *http.Request) { userID, svc, ok := h.begin(w, r) if !ok { return } page := queryInt(r, "page", 1, 1, 100000) perPage := queryInt(r, "per_page", 20, 1, 100) rows, total, err := svc.History(r.Context(), userID, strings.TrimSpace(r.URL.Query().Get("quiz")), page, perPage) if err != nil { wire.WriteOpaque500(w) return } out := make([]attemptJSON, 0, len(rows)) for _, row := range rows { out = append(out, attempt(row)) } wire.WriteJSON(w, http.StatusOK, lagoon.Paginate(out, page, perPage, total)) } // begin is user then service, for handlers without a body. func (h *Handlers) begin(w http.ResponseWriter, r *http.Request) (uint, *classes.Service, bool) { userID, ok := h.user(w, r) if !ok { return 0, nil, false } svc, ok := h.service(w) return userID, svc, ok } // user reads the signed-in user that jwt.auth stored on the request. The // guard already answers 401 without a token; this covers a route mounted // without it. func (h *Handlers) user(w http.ResponseWriter, r *http.Request) (uint, bool) { p, ok := bouncer.User(r.Context()) if !ok || p == nil || p.ID == 0 { wire.WriteJSON(w, http.StatusUnauthorized, map[string]any{"error": true, "message": "Unauthenticated"}) return 0, false } return p.ID, true } func (h *Handlers) service(w http.ResponseWriter) (*classes.Service, bool) { var gdb *gorm.DB if h.App != nil { gdb, _ = h.App.Lookup[*gorm.DB]() } if gdb == nil { wire.WriteJSON(w, http.StatusServiceUnavailable, map[string]any{"error": "Database unavailable"}) return nil, false } if h.Service != nil { return h.Service(gdb), true } var scoring classes.ScoringConfig if h.App != nil { scoring = classes.ScoringConfigFrom(h.App.Config) } return &classes.Service{DB: gdb, Scoring: scoring}, true } // input decodes a JSON body (an empty body is {}) and validates it. It // writes 413 for a body over the route limit and 422 for invalid input. func (h *Handlers) input(w http.ResponseWriter, r *http.Request, rules []lagoon.RequestRule) (map[string]any, bool) { input := map[string]any{} if r.Body != nil { dec := json.NewDecoder(r.Body) dec.UseNumber() var decoded any err := dec.Decode(&decoded) var tooLarge *http.MaxBytesError switch { case errors.As(err, &tooLarge): wire.WriteJSON(w, http.StatusRequestEntityTooLarge, map[string]any{"error": "Payload too large"}) return nil, false case err == nil: if m, ok := decoded.(map[string]any); ok { input = m } case errors.Is(err, io.EOF): default: // A malformed body validates as an empty one. } } errs, err := lagoon.ValidateRequest(r.Context(), nil, input, rules, h.translator()) if err != nil { wire.WriteOpaque500(w) return nil, false } if len(errs) > 0 { writeValidation(w, errs, lagoon.ErrorKeys(errs, rules)) return nil, false } return input, true } func (h *Handlers) translator() *phrasebook.Translator { if h.App == nil { return nil } tr, _ := h.App.Lookup[*phrasebook.Translator]() return tr } // message translates golem15.quizzes::lang.api., falling back to the // English text when no translator or line is available. func (h *Handlers) message(ctx context.Context, key string) string { fallback := apiMessages[key] tr := h.translator() if tr == nil { return fallback } full := "golem15.quizzes::lang.api." + key s := tr.Get(ctx, full, nil) if s == "" || s == full { return fallback } return s } var apiMessages = map[string]string{ "quiz_not_found": "Quiz not found", "attempt_not_found": "Attempt not found", "attempt_completed": "The attempt is already completed.", "time_limit_exceeded": "The time limit of this attempt has been exceeded.", "question_not_in_quiz": "The question does not belong to this attempt.", "answer_ids_required": "The answer ids field is required for this question.", "answer_not_in_question": "The chosen answers do not belong to this question.", "single_one_answer": "Choose exactly one answer for this question.", "known_required": "The known field is required for this question.", "unknown_category": "The quiz has no questions in this category.", "validation_failed": "The given data was invalid.", } // writeError maps a service error onto the API's status codes. func (h *Handlers) writeError(w http.ResponseWriter, r *http.Request, err error) { var fe *classes.FieldError switch { case errors.As(err, &fe): msg := h.message(r.Context(), fe.Key) writeValidation(w, map[string][]string{fe.Field: {msg}}, []string{fe.Field}) case errors.Is(err, classes.ErrNotFound): key := "attempt_not_found" if r.PathValue("slug") != "" { key = "quiz_not_found" } wire.WriteJSON(w, http.StatusNotFound, map[string]any{"error": h.message(r.Context(), key)}) case errors.Is(err, classes.ErrCompleted): wire.WriteJSON(w, http.StatusConflict, map[string]any{"error": h.message(r.Context(), "attempt_completed")}) case errors.Is(err, classes.ErrTimeLimit): wire.WriteJSON(w, http.StatusConflict, map[string]any{"error": h.message(r.Context(), "time_limit_exceeded")}) default: wire.WriteOpaque500(w) } } // writeValidation writes the 422 body of the golem15.user API and Laravel: // {"error": , "errors": {field: [messages]}}, with the // fields in rule order. func writeValidation(w http.ResponseWriter, errs map[string][]string, order []string) { first := "" for _, k := range order { if msgs := errs[k]; len(msgs) > 0 { first = msgs[0] break } } wire.WriteJSON(w, http.StatusUnprocessableEntity, map[string]any{ "error": first, "errors": orderedErrors{errs: errs, order: order}, }) } // orderedErrors marshals the errors object with its keys in rule order. type orderedErrors struct { errs map[string][]string order []string } func (o orderedErrors) MarshalJSON() ([]byte, error) { var b strings.Builder b.WriteByte('{') seen := map[string]bool{} write := func(k string) error { msgs, ok := o.errs[k] if !ok || seen[k] { return nil } seen[k] = true if b.Len() > 1 { b.WriteByte(',') } key, err := json.Marshal(k) if err != nil { return err } val, err := json.Marshal(msgs) if err != nil { return err } b.Write(key) b.WriteByte(':') b.Write(val) return nil } for _, k := range o.order { if err := write(k); err != nil { return nil, err } } for k := range o.errs { if err := write(k); err != nil { return nil, err } } b.WriteByte('}') return []byte(b.String()), nil } func (h *Handlers) pathID(w http.ResponseWriter, r *http.Request) (uint, bool) { id, ok := surf.IntParam(r, "id") if !ok || id <= 0 { wire.WriteJSON(w, http.StatusNotFound, map[string]any{"error": h.message(r.Context(), "attempt_not_found")}) return 0, false } return uint(id), true } func queryInt(r *http.Request, name string, def, lo, hi int) int { n, err := strconv.Atoi(r.URL.Query().Get(name)) if err != nil { n = def } return min(max(n, lo), hi) } func toInt(v any) int64 { switch n := v.(type) { case json.Number: i, err := n.Int64() if err == nil { return i } f, _ := n.Float64() return int64(f) case float64: return int64(n) case int: return int64(n) case int64: return n case string: i, _ := strconv.ParseInt(n, 10, 64) return i } return 0 } // toBool follows Laravel's boolean rule: true, false, 1, 0, "1", "0". func toBool(v any) bool { switch b := v.(type) { case bool: return b case json.Number: return b.String() == "1" case string: return b == "1" || b == "true" case float64: return b == 1 } return false }