Skip to content

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) ​

go
// 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:

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) ​

go
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:

go
// 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:

go
// 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:

go
grading.Handle("GET "+p+"/calculate-gpa", h.CalculateGPA)
audit.Handle("POST "+p+"/run-audit", h.RunDegreeAudit)

Service Layer for Custom Endpoints ​

go
// 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:

typescript
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:

go
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.

ConstructorSession required?Use when
Node(...).Handle / Secureyes, plus a permission checkThe default. Almost everything.
SelfService(pattern, reason, h)yes, no node checkThe 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 checkShould be gated but the grant data cannot support it yet. A backlog marker — TestNoKnownGapsRemain fails while any exist.
Custom(pattern, reason, wrap, h)noPre-session flows needing their own wrapper, e.g. login with the rate limiter and AUTH system actor.
Public(pattern, reason, h)noGenuinely open: health, SAML metadata.
go
    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:

  1. Session authentication — validates the sis_session cookie against Valkey
  2. CSRF validation — checks the X-CSRF-Token header on mutating requests
  3. 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.

Student Information System Documentation