Adding Endpoints
Create new API endpoints in the Go backend.
Standard CRUD Endpoint
See Adding Models for complete domain module setup with the 6-file pattern.
Custom Action Endpoints
Detail Action (Single Resource)
// handler.go
func (h *Handler) ActivateEntity(w http.ResponseWriter, r *http.Request) {
// POST /api/v1/domain/entities/{entityID}/activate
id, err := strconv.Atoi(r.PathValue("entityID"))
if err != nil {
response.BadRequest(w, "Invalid entity ID")
return
}
entity, err := h.service.Activate(r.Context(), int32(id))
if err != nil {
response.HandleError(w, err)
return
}
response.OK(w, entity)
}
func (h *Handler) GetEntityHistory(w http.ResponseWriter, r *http.Request) {
// GET /api/v1/domain/entities/{entityID}/history
id, err := strconv.Atoi(r.PathValue("entityID"))
if err != nil {
response.BadRequest(w, "Invalid entity ID")
return
}
history, err := h.service.GetHistory(r.Context(), int32(id))
if err != nil {
response.HandleError(w, err)
return
}
response.OK(w, history)
}Register in routes.go:
func RegisterRoutes(reg *authz.Registrar, prefix string, database *sql.DB,
v *validator.Validator) {
// ... create repo, service, handler ...
p := prefix + "/domain"
// Bind a permission node once, then hang the routes off it. The required
// access level is derived from the method: GET is VIEW, everything else EDIT.
entities := reg.Node("DOMAIN.ENTITIES")
// Standard CRUD
entities.Handle("GET "+p+"/entities", h.ListEntities)
entities.Handle("POST "+p+"/entities", h.CreateEntity)
entities.Handle("GET "+p+"/entities/{entityID}", h.GetEntity)
entities.Handle("PUT "+p+"/entities/{entityID}", h.UpdateEntity)
entities.Handle("DELETE "+p+"/entities/{entityID}", h.DeleteEntity)
// Custom detail actions
entities.Handle("POST "+p+"/entities/{entityID}/activate", h.ActivateEntity)
entities.Handle("GET "+p+"/entities/{entityID}/history", h.GetEntityHistory)
}Every route must go through the Registrar
mux.HandleFunc(..., protect(...)) is the old pattern and no longer compiles here — RegisterRoutes receives a *authz.Registrar, not a mux. protect only answered "are you logged in", never "may you do this", so a route left ungated looked exactly like one that was deliberately public. That ambiguity is how the SSO identity-provider CRUD and the whole authorization domain stayed callable by any logged-in session.
internal/router/coverage_test.go fails the build if a route names neither a permission node nor a written reason for not having one, and nodes_test.go fails if it names a node no migration creates. A new node therefore needs adding in two places: authz.KnownNodes and a migration.
List Action (Collection)
func (h *Handler) GetActiveEntities(w http.ResponseWriter, r *http.Request) {
// GET /api/v1/domain/entities/active
entities, err := h.service.GetActive(r.Context())
if err != nil {
response.HandleError(w, err)
return
}
response.OK(w, entities)
}
func (h *Handler) BulkUpdateEntities(w http.ResponseWriter, r *http.Request) {
// POST /api/v1/domain/entities/bulk-update
var req BulkUpdateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.BadRequest(w, "Invalid JSON")
return
}
count, err := h.service.BulkUpdate(r.Context(), req.IDs, req.Data)
if err != nil {
response.HandleError(w, err)
return
}
response.OK(w, map[string]int{"updated": count})
}Register list actions:
// Collection actions (no {entityID} in path)
entities.Handle("GET "+p+"/entities/active", h.GetActiveEntities)
entities.Handle("POST "+p+"/entities/bulk-update", h.BulkUpdateEntities)
entities.Handle("GET "+p+"/entities/search", h.SearchEntities)Standalone Endpoint (Non-Resource)
For endpoints that don't map to CRUD operations:
// handler.go
func (h *Handler) CalculateGPA(w http.ResponseWriter, r *http.Request) {
// GET /api/v1/grading/calculate-gpa?student_id=123
studentID := r.URL.Query().Get("student_id")
if studentID == "" {
response.BadRequest(w, "student_id is required")
return
}
id, err := strconv.Atoi(studentID)
if err != nil {
response.BadRequest(w, "Invalid student_id")
return
}
result, err := h.service.CalculateGPA(r.Context(), int32(id))
if err != nil {
response.HandleError(w, err)
return
}
response.OK(w, result)
}
func (h *Handler) RunDegreeAudit(w http.ResponseWriter, r *http.Request) {
// POST /api/v1/audit/run-audit
var req RunAuditRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.BadRequest(w, "Invalid JSON")
return
}
if err := h.validator.Struct(&req); err != nil {
response.ValidationError(w, err)
return
}
result, err := h.service.RunDegreeAudit(r.Context(), &req)
if err != nil {
response.HandleError(w, err)
return
}
response.OK(w, result)
}Register standalone endpoints:
grading.Handle("GET "+p+"/calculate-gpa", h.CalculateGPA)
audit.Handle("POST "+p+"/run-audit", h.RunDegreeAudit)Service Layer for Custom Endpoints
// service.go
type EntityService interface {
// Standard CRUD
GetByID(ctx context.Context, id int32) (*EntityDetailDTO, error)
List(ctx context.Context, params *EntityQueryParams) (*db.PaginatedResult[EntityListDTO], error)
Create(ctx context.Context, req *CreateEntityRequest) (*EntityDetailDTO, error)
Update(ctx context.Context, id int32, req *UpdateEntityRequest) (*EntityDetailDTO, error)
Delete(ctx context.Context, id int32) error
// Custom actions
Activate(ctx context.Context, id int32) (*EntityDetailDTO, error)
GetHistory(ctx context.Context, id int32) ([]HistoryEntry, error)
GetActive(ctx context.Context) ([]EntityListDTO, error)
BulkUpdate(ctx context.Context, ids []int32, data *UpdateData) (int, error)
}
func (s *entityService) Activate(ctx context.Context, id int32) (*EntityDetailDTO, error) {
// Fetch entity
entity, err := s.repo.FindByID(ctx, id)
if err != nil {
return nil, err
}
// Update status
activeStatusID, err := s.lookupRepo.FindStatusID(ctx, "ACTIVE")
if err != nil {
return nil, err
}
entity.StatusID = activeStatusID
entity.UpdatedAt = time.Now()
if err := s.repo.Update(ctx, entity); err != nil {
return nil, err
}
return s.GetByID(ctx, id)
}Frontend API Service
Add to sis_frontend/src/api/services/{domain}.api.ts:
export const entityApi = {
// Standard CRUD
getAll: (params?: Record<string, any>) =>
api.get<PaginatedResponse<Entity>>('/domain/entities', { params }),
getById: (id: number) =>
api.get<Entity>(`/domain/entities/${id}`),
create: (data: EntityCreate) =>
api.post<Entity>('/domain/entities', data),
update: (id: number, data: Partial<EntityCreate>) =>
api.put<Entity>(`/domain/entities/${id}`, data),
delete: (id: number) =>
api.delete(`/domain/entities/${id}`),
// Custom actions
activate: (id: number) =>
api.post(`/domain/entities/${id}/activate`),
getHistory: (id: number) =>
api.get<HistoryEntry[]>(`/domain/entities/${id}/history`),
getActive: () =>
api.get<Entity[]>('/domain/entities/active'),
search: (query: string) =>
api.get<Entity[]>('/domain/entities/search', { params: { q: query } }),
bulkUpdate: (ids: number[], data: Partial<EntityCreate>) =>
api.post('/domain/entities/bulk-update', { ids, data })
}INFO
Note that API paths do not have trailing slashes. The Go backend strips trailing slashes in the router middleware, so both /entities and /entities/ will work, but the convention is to use paths without trailing slashes.
Query Parameter Parsing
For endpoints with query parameters:
func (h *Handler) SearchEntities(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
params := &SearchParams{
Query: query.Get("q"),
Page: parseIntParam(r, "page", 1),
PageSize: parseIntParam(r, "page_size", 20),
Status: query.Get("status"),
OrderBy: query.Get("ordering"),
}
if params.Query == "" || len(params.Query) < 2 {
response.BadRequest(w, "Search query must be at least 2 characters")
return
}
results, err := h.service.Search(r.Context(), params)
if err != nil {
response.HandleError(w, err)
return
}
response.OK(w, results)
}
func parseIntParam(r *http.Request, key string, defaultVal int) int {
val := r.URL.Query().Get(key)
if val == "" {
return defaultVal
}
i, err := strconv.Atoi(val)
if err != nil {
return defaultVal
}
return i
}Choosing how a route is gated
The Registrar offers five ways to register a route, and the choice is recorded so a reviewer can see it. Three of them keep the session wrapper; two do not.
| Constructor | Session required? | Use when |
|---|---|---|
Node(...).Handle / Secure | yes, plus a permission check | The default. Almost everything. |
SelfService(pattern, reason, h) | yes, no node check | The route only ever touches the caller's own record. Gate my_roles or switch_role and a user who switches to a low-privilege role can never switch back. |
Ungated(pattern, gap, h) | yes, no node check | Should be gated but the grant data cannot support it yet. A backlog marker — TestNoKnownGapsRemain fails while any exist. |
Custom(pattern, reason, wrap, h) | no | Pre-session flows needing their own wrapper, e.g. login with the rate limiter and AUTH system actor. |
Public(pattern, reason, h) | no | Genuinely open: health, SAML metadata. |
entities := reg.Node("DOMAIN.ENTITIES")
// Gated on a permission node — the normal case
entities.Handle("GET "+p+"/entities", h.ListEntities)
entities.Handle("POST "+p+"/entities", h.CreateEntity)
// Authenticated, but acts only on the caller's own record
reg.SelfService("GET "+p+"/my-entity", "returns only the caller's row", h.GetMyEntity)
// No session at all — reason is mandatory and is surfaced by the coverage test
reg.Public("GET "+p+"/public-info", "rendered before login", h.GetPublicInfo)Prefer SelfService over Public. An applicant or proxy on a magic-link session is authenticated, just not role-bearing; Public means anyone on the internet.
SecureLevel and HandleLevel override the method-derived access level. Several user-facing action routes are deliberately gated at VIEW rather than EDIT because the real per-record check lives in the service — whether this caller may approve this workflow step is not something a node grant can express.
Underneath, the session wrapper applies three middleware layers:
- Session authentication — validates the
sis_sessioncookie against Valkey - CSRF validation — checks the
X-CSRF-Tokenheader on mutating requests - Impersonation audit — logs actions taken while impersonating another user
Authentication runs outermost, so an anonymous request is rejected with 401 before the permission check or the handler ever runs. internal/router/unauth_test.go fires every registered route at the real handler with no cookie and asserts exactly that; see the unauthenticated surface.