460 lines
12 KiB
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
|
|
}
|