feat: add the student API under /_bm/api/v1
Dashboard, courses with counters, schedule, meetings, deadlines, readings with per-user progress (mark and unmark) and paginated published materials with tags and files, behind golem15.user's jwt.auth. Responses are built field by field with Warsaw RFC 3339 timestamps; bad query parameters answer 422 in Laravel's shape.
This commit is contained in:
122
controllers/api/api.go
Normal file
122
controllers/api/api.go
Normal file
@@ -0,0 +1,122 @@
|
||||
// Package api holds the student-facing JSON handlers of the jz.bm plugin,
|
||||
// served under /_bm/api/v1 behind the golem15.user jwt.auth guard.
|
||||
//
|
||||
// Every response is built field by field and wrapped in {"data": ...}; a
|
||||
// paginated list adds {"meta": ...}. Timestamps are RFC 3339 in
|
||||
// Europe/Warsaw, dates are YYYY-MM-DD.
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"git.golem15.com/golem15/summercms/modules/phrasebook"
|
||||
"git.golem15.com/golem15/summercms/modules/wire"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Handlers serves the API. The plugin builds it in Routes; tests build it
|
||||
// with their own database and clock.
|
||||
type Handlers struct {
|
||||
// DB returns the application database, or nil while none is published.
|
||||
// It is resolved per request: the binary boots plugins before serve
|
||||
// publishes the database.
|
||||
DB func() *gorm.DB
|
||||
// Translator returns the application translator for validation
|
||||
// messages; nil gives English.
|
||||
Translator func() *phrasebook.Translator
|
||||
// Now is the clock; nil means time.Now.
|
||||
Now func() time.Time
|
||||
// PerPage is the default page size of /materials.
|
||||
PerPage int
|
||||
// DashboardLimit is the number of sessions and deadlines on /dashboard.
|
||||
DashboardLimit int
|
||||
}
|
||||
|
||||
func (h *Handlers) now() time.Time {
|
||||
if h.Now != nil {
|
||||
return h.Now()
|
||||
}
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
func (h *Handlers) perPage() int {
|
||||
if h.PerPage > 0 {
|
||||
return min(h.PerPage, maxPerPage)
|
||||
}
|
||||
return defaultPerPage
|
||||
}
|
||||
|
||||
func (h *Handlers) dashboardLimit() int {
|
||||
if h.DashboardLimit > 0 {
|
||||
return h.DashboardLimit
|
||||
}
|
||||
return 5
|
||||
}
|
||||
|
||||
func (h *Handlers) translator() *phrasebook.Translator {
|
||||
if h.Translator == nil {
|
||||
return nil
|
||||
}
|
||||
return h.Translator()
|
||||
}
|
||||
|
||||
const (
|
||||
defaultPerPage = 20
|
||||
maxPerPage = 100
|
||||
)
|
||||
|
||||
// caller returns the signed-in user's ID. It answers 401 itself and returns
|
||||
// false when there is none (the jwt.auth guard normally answers first).
|
||||
func (h *Handlers) caller(w http.ResponseWriter, r *http.Request) (uint, bool) {
|
||||
user, ok := bouncer.User(r.Context())
|
||||
if !ok || user == nil || user.ID == 0 {
|
||||
wire.WriteJSON(w, http.StatusUnauthorized, map[string]any{"error": true, "message": "Unauthenticated."})
|
||||
return 0, false
|
||||
}
|
||||
return user.ID, true
|
||||
}
|
||||
|
||||
// db returns the database bound to the request context. It answers 503
|
||||
// itself and returns false while no database is published.
|
||||
func (h *Handlers) db(w http.ResponseWriter, r *http.Request) (*gorm.DB, bool) {
|
||||
var db *gorm.DB
|
||||
if h.DB != nil {
|
||||
db = h.DB()
|
||||
}
|
||||
if db == nil {
|
||||
wire.WriteJSON(w, http.StatusServiceUnavailable, map[string]any{"error": true, "message": "Database unavailable."})
|
||||
return nil, false
|
||||
}
|
||||
return db.WithContext(r.Context()), true
|
||||
}
|
||||
|
||||
func writeData(w http.ResponseWriter, v any) {
|
||||
wire.WriteJSON(w, http.StatusOK, map[string]any{"data": v})
|
||||
}
|
||||
|
||||
func writeNotFound(w http.ResponseWriter) {
|
||||
wire.WriteJSON(w, http.StatusNotFound, map[string]any{"error": true, "message": "Not found."})
|
||||
}
|
||||
|
||||
// writeValidation answers 422 with Laravel's validation body: the first
|
||||
// message and the errors object from lagoon.ValidateRequest.
|
||||
func writeValidation(w http.ResponseWriter, errs map[string][]string) {
|
||||
keys := make([]string, 0, len(errs))
|
||||
for k := range errs {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
message := "The given data was invalid."
|
||||
if len(keys) > 0 && len(errs[keys[0]]) > 0 {
|
||||
message = errs[keys[0]][0]
|
||||
}
|
||||
wire.WriteJSON(w, http.StatusUnprocessableEntity, map[string]any{"message": message, "errors": errs})
|
||||
}
|
||||
|
||||
func writeOpaque500(w http.ResponseWriter) { wire.WriteOpaque500(w) }
|
||||
|
||||
// writeJSONPage writes a lagoon.Page as it is: {data, meta}.
|
||||
func writeJSONPage(w http.ResponseWriter, page any) { wire.WriteJSON(w, http.StatusOK, page) }
|
||||
Reference in New Issue
Block a user