Files
sm-quizzes-plugin/controllers/api/handlers.go

460 lines
12 KiB
Go

// 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.<key>, 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": <first message>, "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
}