feat: serve the quiz API under /_quizzes/api/v1 behind jwt.auth

This commit is contained in:
Jakub Zych
2026-10-02 14:13:30 +02:00
parent 4d4da5dc14
commit effe640549
16 changed files with 2232 additions and 11 deletions

459
controllers/api/handlers.go Normal file
View File

@@ -0,0 +1,459 @@
// 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
}

View File

@@ -0,0 +1,193 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"git.golem15.com/golem15/sm-quizzes-plugin/classes"
"git.golem15.com/golem15/sm-quizzes-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bouncer"
)
func call(t *testing.T, h http.HandlerFunc, method, target, body string, user uint, pathValues map[string]string) (int, map[string]any) {
t.Helper()
var req *http.Request
if body != "" {
req = httptest.NewRequest(method, target, strings.NewReader(body))
} else {
req = httptest.NewRequest(method, target, nil)
}
for k, v := range pathValues {
req.SetPathValue(k, v)
}
if user != 0 {
req = req.WithContext(bouncer.WithUser(req.Context(), &bouncer.Principal{ID: user}))
}
rec := httptest.NewRecorder()
h(rec, req)
var out map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("%s %s: %v\n%s", method, target, err, rec.Body.String())
}
return rec.Code, out
}
func TestHandlersRequireAUser(t *testing.T) {
h := New(backpack.New(nil))
for name, fn := range map[string]http.HandlerFunc{
"list": h.ListQuizzes, "show": h.ShowQuiz, "start": h.StartAttempt, "answer": h.Answer,
"finish": h.Finish, "attempt": h.ShowAttempt, "history": h.ListAttempts,
} {
code, out := call(t, fn, "POST", "/", "{}", 0, map[string]string{"id": "1", "slug": "x"})
if code != http.StatusUnauthorized || out["error"] != true {
t.Errorf("%s without a user = %d %v, want 401", name, code, out)
}
}
}
func TestHandlersWithoutDatabase(t *testing.T) {
h := New(backpack.New(nil))
for name, fn := range map[string]http.HandlerFunc{
"list": h.ListQuizzes, "show": h.ShowQuiz, "start": h.StartAttempt, "finish": h.Finish,
"attempt": h.ShowAttempt, "history": h.ListAttempts,
} {
code, _ := call(t, fn, "POST", "/", "", 7, map[string]string{"id": "1", "slug": "x"})
if code != http.StatusServiceUnavailable {
t.Errorf("%s without a database = %d, want 503", name, code)
}
}
code, _ := call(t, h.Answer, "POST", "/", `{"question_id":1,"known":true}`, 7, map[string]string{"id": "1"})
if code != http.StatusServiceUnavailable {
t.Errorf("valid answer without a database = %d, want 503", code)
}
}
func TestAnswerValidation(t *testing.T) {
h := New(backpack.New(nil))
cases := []struct {
body string
fields []string
}{
{``, []string{"question_id"}},
{`[1,2]`, []string{"question_id"}},
{`{"question_id":0}`, []string{"question_id"}},
{`{"question_id":1.5}`, []string{"question_id"}},
{`{"question_id":"abc","answer_ids":7,"known":"perhaps"}`, []string{"question_id", "answer_ids", "known"}},
{`{"question_id":1,"answer_ids":[1,-2]}`, []string{"answer_ids.1"}},
}
for _, tc := range cases {
code, out := call(t, h.Answer, "POST", "/", tc.body, 7, map[string]string{"id": "1"})
errs, _ := out["errors"].(map[string]any)
if code != http.StatusUnprocessableEntity || len(errs) != len(tc.fields) {
t.Errorf("%s = %d %v, want 422 on %v", tc.body, code, out, tc.fields)
continue
}
for _, f := range tc.fields {
if errs[f] == nil {
t.Errorf("%s: no error on %s: %v", tc.body, f, errs)
}
}
if msg, _ := out["error"].(string); msg == "" {
t.Errorf("%s: no top-level error message: %v", tc.body, out)
}
}
code, out := call(t, h.StartAttempt, "POST", "/", `{"category":5}`, 7, map[string]string{"slug": "x"})
if code != http.StatusUnprocessableEntity || out["errors"].(map[string]any)["category"] == nil {
t.Errorf("start with a numeric category = %d %v", code, out)
}
}
func TestErrorKeysInRuleOrder(t *testing.T) {
rec := httptest.NewRecorder()
writeValidation(rec, map[string][]string{"known": {"k"}, "question_id": {"q"}, "zzz": {"z"}}, []string{"question_id", "known"})
if got := rec.Body.String(); got != `{"error":"q","errors":{"question_id":["q"],"known":["k"],"zzz":["z"]}}` {
t.Errorf("body = %s", got)
}
}
func TestWriteErrorMapping(t *testing.T) {
h := New(nil)
cases := []struct {
err error
slug string
code int
msg string
}{
{classes.ErrNotFound, "quiz", 404, "Quiz not found"},
{classes.ErrNotFound, "", 404, "Attempt not found"},
{classes.ErrCompleted, "", 409, apiMessages["attempt_completed"]},
{classes.ErrTimeLimit, "", 409, apiMessages["time_limit_exceeded"]},
{&classes.FieldError{Field: "question_id", Key: "question_not_in_quiz"}, "", 422, apiMessages["question_not_in_quiz"]},
}
for _, tc := range cases {
req := httptest.NewRequest("GET", "/", nil)
if tc.slug != "" {
req.SetPathValue("slug", tc.slug)
}
rec := httptest.NewRecorder()
h.writeError(rec, req, tc.err)
var out map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &out)
if rec.Code != tc.code || out["error"] != tc.msg {
t.Errorf("%v = %d %v, want %d %q", tc.err, rec.Code, out, tc.code, tc.msg)
}
}
rec := httptest.NewRecorder()
h.writeError(rec, httptest.NewRequest("GET", "/", nil), http.ErrBodyNotAllowed)
if rec.Code != 500 {
t.Errorf("unknown error = %d, want 500", rec.Code)
}
}
func TestResultHidesSolutionsOfUnansweredQuestions(t *testing.T) {
expl := "why"
q := models.Question{ID: 1, Type: models.TypeSingle, Points: 1, Explanation: &expl,
Answers: []models.Answer{{ID: 10, Text: "a", IsCorrect: true}, {ID: 11, Text: "b"}}}
open := &classes.AttemptResult{
AttemptSummary: classes.AttemptSummary{Attempt: models.Attempt{ID: 5, StartedAt: time.Now(), Quiz: &models.Quiz{Slug: "s", PassingScore: 50}}},
Questions: []classes.QuestionResult{{Question: q}},
}
out := result(open)
row := out.Questions[0]
if row.Answered || row.CorrectAnswerIDs != nil || row.Explanation != nil || row.Answers[0].IsCorrect != nil {
t.Errorf("unanswered question in an open attempt reveals the solution: %+v", row)
}
if out.Passed != nil || out.CompletedAt != nil {
t.Errorf("open attempt has a result: %+v", out.attemptJSON)
}
done := time.Now()
pct := 40.0
open.Attempt.CompletedAt = &done
open.Attempt.ScorePercent = &pct
out = result(open)
row = out.Questions[0]
if row.CorrectAnswerIDs == nil || row.Explanation == nil || row.Answers[0].IsCorrect == nil || !*row.Answers[0].IsCorrect {
t.Errorf("completed attempt hides the solution: %+v", row)
}
if out.Passed == nil || *out.Passed {
t.Errorf("40%% of a 50%% quiz passed = %v", out.Passed)
}
b, _ := json.Marshal(out)
if !strings.Contains(string(b), `"answer_ids":[]`) || !strings.Contains(string(b), `"quiz_slug":"s"`) {
t.Errorf("json = %s", b)
}
}
func TestToBoolAndToInt(t *testing.T) {
for v, want := range map[any]bool{true: true, false: false, "1": true, "0": false, "true": true, json.Number("1"): true, json.Number("0"): false, 1.0: true} {
if toBool(v) != want {
t.Errorf("toBool(%v) != %v", v, want)
}
}
for v, want := range map[any]int64{json.Number("12"): 12, 3.0: 3, 4: 4, int64(5): 5, "6": 6, nil: 0} {
if toInt(v) != want {
t.Errorf("toInt(%v) != %v", v, want)
}
}
}

270
controllers/api/json.go Normal file
View File

@@ -0,0 +1,270 @@
package api
import (
"time"
"git.golem15.com/golem15/sm-quizzes-plugin/classes"
"git.golem15.com/golem15/sm-quizzes-plugin/models"
"git.golem15.com/golem15/summercms/modules/wire"
)
// The response shapes are built field by field, so a column added to a
// model never leaks into the API.
// quizSummaryJSON is one row of GET /quizzes.
type quizSummaryJSON struct {
ID uint `json:"id"`
Slug string `json:"slug"`
Title string `json:"title"`
Description *string `json:"description"`
Context *string `json:"context"`
QuestionCount int `json:"question_count"`
TimeLimitSeconds *int `json:"time_limit_seconds"`
PassingScore int `json:"passing_score"`
RoundSize *int `json:"round_size"`
ShuffleQuestions bool `json:"shuffle_questions"`
BestScorePercent *float64 `json:"best_score_percent"`
AttemptsCount int `json:"attempts_count"`
}
func quizSummary(s classes.QuizSummary) quizSummaryJSON {
q := s.Quiz
return quizSummaryJSON{
ID: q.ID,
Slug: q.Slug,
Title: q.Title,
Description: q.Description,
Context: q.Context,
QuestionCount: s.QuestionCount,
TimeLimitSeconds: q.TimeLimitSeconds,
PassingScore: q.PassingScore,
RoundSize: q.RoundSize,
ShuffleQuestions: q.ShuffleQuestions,
BestScorePercent: s.BestScorePercent,
AttemptsCount: s.AttemptsCount,
}
}
// quizJSON is GET /quizzes/{slug}: the quiz without any solution.
type quizJSON struct {
ID uint `json:"id"`
Slug string `json:"slug"`
Title string `json:"title"`
Description *string `json:"description"`
Context *string `json:"context"`
TimeLimitSeconds *int `json:"time_limit_seconds"`
PassingScore int `json:"passing_score"`
RoundSize *int `json:"round_size"`
ShuffleQuestions bool `json:"shuffle_questions"`
QuestionCount int `json:"question_count"`
Categories []string `json:"categories"`
Questions []questionJSON `json:"questions"`
}
// questionJSON is a question as the player sees it before answering: no
// is_correct, explanation or answer_text.
type questionJSON struct {
ID uint `json:"id"`
Text string `json:"text"`
Type string `json:"type"`
Category *string `json:"category"`
Points int `json:"points"`
SortOrder int `json:"sort_order"`
Answers []answerJSON `json:"answers"`
}
type answerJSON struct {
ID uint `json:"id"`
Text string `json:"text"`
}
func quizDetail(d *classes.QuizDetail) quizJSON {
q := d.Quiz
out := quizJSON{
ID: q.ID,
Slug: q.Slug,
Title: q.Title,
Description: q.Description,
Context: q.Context,
TimeLimitSeconds: q.TimeLimitSeconds,
PassingScore: q.PassingScore,
RoundSize: q.RoundSize,
ShuffleQuestions: q.ShuffleQuestions,
QuestionCount: len(d.Questions),
Categories: wire.Slice(d.Categories),
Questions: make([]questionJSON, 0, len(d.Questions)),
}
for _, qu := range d.Questions {
out.Questions = append(out.Questions, question(qu))
}
return out
}
func question(q models.Question) questionJSON {
out := questionJSON{
ID: q.ID,
Text: q.Text,
Type: q.Type,
Category: q.Category,
Points: q.Points,
SortOrder: q.SortOrder,
Answers: make([]answerJSON, 0, len(q.Answers)),
}
for _, a := range q.Answers {
out.Answers = append(out.Answers, answerJSON{ID: a.ID, Text: a.Text})
}
return out
}
// attemptJSON is an attempt without its questions: POST
// /quizzes/{slug}/attempts and each row of GET /attempts.
type attemptJSON struct {
ID uint `json:"id"`
QuizID uint `json:"quiz_id"`
QuizSlug string `json:"quiz_slug"`
QuizTitle string `json:"quiz_title"`
Category *string `json:"category"`
StartedAt wire.Time `json:"started_at"`
ExpiresAt *wire.Time `json:"expires_at"`
CompletedAt *wire.Time `json:"completed_at"`
AnsweredCount int `json:"answered_count"`
ScorePoints float64 `json:"score_points"`
MaxPoints int `json:"max_points"`
ScorePercent *float64 `json:"score_percent"`
PassingScore int `json:"passing_score"`
Passed *bool `json:"passed"`
}
func attempt(s classes.AttemptSummary) attemptJSON {
a := s.Attempt
out := attemptJSON{
ID: a.ID,
QuizID: a.QuizID,
Category: a.Category,
StartedAt: wireTime(a.StartedAt),
AnsweredCount: s.AnsweredCount,
ScorePoints: a.ScorePoints,
MaxPoints: a.MaxPoints,
ScorePercent: a.ScorePercent,
}
if a.Quiz != nil {
out.QuizSlug, out.QuizTitle, out.PassingScore = a.Quiz.Slug, a.Quiz.Title, a.Quiz.PassingScore
}
if d := classes.Deadline(&a); d != nil {
t := wireTime(*d)
out.ExpiresAt = &t
}
if a.CompletedAt != nil {
t := wireTime(*a.CompletedAt)
out.CompletedAt = &t
passed := a.ScorePercent != nil && *a.ScorePercent >= float64(out.PassingScore)
out.Passed = &passed
}
return out
}
// resultJSON is GET /attempts/{id} and POST /attempts/{id}/finish.
type resultJSON struct {
attemptJSON
Questions []questionResultJSON `json:"questions"`
}
// questionResultJSON is one question of a result. The solution fields
// (is_correct of the answers, correct_answer_ids, explanation, answer_text)
// are revealed for an answered question and for every question once the
// attempt is completed; otherwise they are null.
type questionResultJSON struct {
ID uint `json:"id"`
Text string `json:"text"`
Type string `json:"type"`
Category *string `json:"category"`
Points int `json:"points"`
Answered bool `json:"answered"`
AnswerIDs []uint `json:"answer_ids"`
Known *bool `json:"known"`
IsCorrect bool `json:"is_correct"`
PointsEarned float64 `json:"points_earned"`
CorrectAnswerIDs []uint `json:"correct_answer_ids"`
Explanation *string `json:"explanation"`
AnswerText *string `json:"answer_text"`
Answers []resultAnswerJSON `json:"answers"`
}
type resultAnswerJSON struct {
ID uint `json:"id"`
Text string `json:"text"`
IsCorrect *bool `json:"is_correct"`
Explanation *string `json:"explanation"`
}
func result(r *classes.AttemptResult) resultJSON {
out := resultJSON{attemptJSON: attempt(r.AttemptSummary), Questions: make([]questionResultJSON, 0, len(r.Questions))}
completed := r.Attempt.CompletedAt != nil
for _, qr := range r.Questions {
q := qr.Question
row := questionResultJSON{
ID: q.ID,
Text: q.Text,
Type: q.Type,
Category: q.Category,
Points: q.Points,
AnswerIDs: []uint{},
Answers: make([]resultAnswerJSON, 0, len(q.Answers)),
}
if a := qr.Answer; a != nil {
row.Answered = true
row.AnswerIDs = wire.Slice(a.AnswerIDs.Data)
row.Known = a.SelfCheckKnown
row.IsCorrect = a.IsCorrect
row.PointsEarned = a.Points
}
reveal := completed || row.Answered
if reveal {
row.CorrectAnswerIDs = classes.CorrectAnswerIDs(q)
row.Explanation = q.Explanation
row.AnswerText = q.AnswerText
}
for _, ans := range q.Answers {
ra := resultAnswerJSON{ID: ans.ID, Text: ans.Text}
if reveal {
correct := ans.IsCorrect
ra.IsCorrect = &correct
ra.Explanation = ans.Explanation
}
row.Answers = append(row.Answers, ra)
}
out.Questions = append(out.Questions, row)
}
return out
}
// feedbackJSON is POST /attempts/{id}/answers.
type feedbackJSON struct {
QuestionID uint `json:"question_id"`
IsCorrect bool `json:"is_correct"`
Points float64 `json:"points"`
CorrectAnswerIDs []uint `json:"correct_answer_ids"`
Explanation *string `json:"explanation"`
AnswerText *string `json:"answer_text"`
Answers []resultAnswerJSON `json:"answers"`
}
func feedback(fb *classes.Feedback) feedbackJSON {
q := fb.Question
out := feedbackJSON{
QuestionID: q.ID,
IsCorrect: fb.Result.IsCorrect,
Points: fb.Result.Points,
CorrectAnswerIDs: wire.Slice(fb.CorrectAnswerIDs),
Explanation: q.Explanation,
AnswerText: q.AnswerText,
Answers: make([]resultAnswerJSON, 0, len(q.Answers)),
}
for _, a := range q.Answers {
correct := a.IsCorrect
out.Answers = append(out.Answers, resultAnswerJSON{ID: a.ID, Text: a.Text, IsCorrect: &correct, Explanation: a.Explanation})
}
return out
}
func wireTime(t time.Time) wire.Time { return wire.Time{Time: t.UTC().Truncate(time.Second)} }