// 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 // PublicURL is the absolute base URL of the application // (jz.bm.public_url), used for the calendar feed URL and the links in // it. Empty means the scheme and Host of the request. PublicURL string } 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) }