241 lines
7.3 KiB
Go
241 lines
7.3 KiB
Go
package httpserver
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
|
|
"github.com/krow/krow-backend/go-api/internal/authctx"
|
|
"github.com/krow/krow-backend/go-api/internal/domain"
|
|
"github.com/krow/krow-backend/go-api/internal/service"
|
|
)
|
|
|
|
// maxBodyBytes bounds a request body. The largest thing the frontend sends is
|
|
// an AI interview transcript; 4 MB is far above it and far below trouble.
|
|
const maxBodyBytes = 4 << 20
|
|
|
|
// routeResources registers exactly the endpoints each resource supports.
|
|
//
|
|
// Only the declared operations are registered, so an unsupported one — DELETE
|
|
// on a job posting, say — is answered by the mux with 405 rather than by a
|
|
// handler that has to know it should refuse. The database having a table is
|
|
// never a reason for an endpoint to exist. See api-contract.md §2.
|
|
func (s *Server) routeResources(mux *http.ServeMux) int {
|
|
count := 0
|
|
for _, svc := range s.api.All() {
|
|
res := svc.Resource()
|
|
base := "/api/v1/" + res.Path
|
|
item := base + "/{id}"
|
|
|
|
if res.Supports(domain.OpList) {
|
|
mux.HandleFunc("GET "+base, s.handleList(svc))
|
|
count++
|
|
}
|
|
if res.Supports(domain.OpCreate) {
|
|
mux.HandleFunc("POST "+base, s.handleCreate(svc))
|
|
count++
|
|
}
|
|
if res.Supports(domain.OpGet) {
|
|
mux.HandleFunc("GET "+item, s.handleGet(svc))
|
|
count++
|
|
}
|
|
if res.Supports(domain.OpUpdate) {
|
|
mux.HandleFunc("PATCH "+item, s.handleUpdate(svc))
|
|
count++
|
|
}
|
|
if res.Supports(domain.OpDelete) {
|
|
mux.HandleFunc("DELETE "+item, s.handleDelete(svc))
|
|
count++
|
|
}
|
|
}
|
|
return count
|
|
}
|
|
|
|
// authorize is the role gate. It runs before any query.
|
|
//
|
|
// It answers 403 and nothing else — never 404, and never a message naming the
|
|
// role required. Which rows the caller may then see is a separate question,
|
|
// answered in SQL by the repository, and its refusal is a 404 so that existence
|
|
// does not leak. Keeping the two apart is what makes "403 means your role, 404
|
|
// means not yours or not there" a rule a client can rely on.
|
|
//
|
|
// The role comes from the session-resolved identity. A role the API does not
|
|
// recognise authorizes nothing.
|
|
func (s *Server) authorize(w http.ResponseWriter, r *http.Request,
|
|
svc *service.Service, op domain.Op) (authctx.Identity, bool) {
|
|
|
|
ident, err := authctx.MustFrom(r.Context())
|
|
if err != nil {
|
|
// Unreachable: the middleware refuses an unauthenticated request before
|
|
// the router sees it. A missing identity here is a wiring bug, not a
|
|
// client error.
|
|
writeError(w, s.log, domain.Internal(err))
|
|
return authctx.Identity{}, false
|
|
}
|
|
|
|
role, known := domain.ParseRole(ident.Role)
|
|
if !known || !svc.Resource().Policy.Allows(op, role) {
|
|
s.log.Warn("authorization refused",
|
|
"user_id", ident.UserID, "role", ident.Role,
|
|
"resource", svc.Resource().Path, "method", r.Method, "path", r.URL.Path)
|
|
writeError(w, s.log, domain.Forbidden())
|
|
return authctx.Identity{}, false
|
|
}
|
|
return ident, true
|
|
}
|
|
|
|
func (s *Server) handleList(svc *service.Service) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
ident, ok := s.authorize(w, r, svc, domain.OpList)
|
|
if !ok {
|
|
return
|
|
}
|
|
params, err := svc.ParseList(r.URL.Query())
|
|
if err != nil {
|
|
writeError(w, s.log, err)
|
|
return
|
|
}
|
|
page, err := svc.List(r.Context(), ident, params)
|
|
if err != nil {
|
|
writeError(w, s.log, err)
|
|
return
|
|
}
|
|
writePage(w, page)
|
|
}
|
|
}
|
|
|
|
func (s *Server) handleGet(svc *service.Service) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
ident, ok := s.authorize(w, r, svc, domain.OpGet)
|
|
if !ok {
|
|
return
|
|
}
|
|
rec, err := svc.Get(r.Context(), ident, r.PathValue("id"))
|
|
if err != nil {
|
|
writeError(w, s.log, err)
|
|
return
|
|
}
|
|
writeRecord(w, http.StatusOK, rec)
|
|
}
|
|
}
|
|
|
|
func (s *Server) handleCreate(svc *service.Service) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
ident, ok := s.authorize(w, r, svc, domain.OpCreate)
|
|
if !ok {
|
|
return
|
|
}
|
|
body, err := decodeBody(r)
|
|
if err != nil {
|
|
writeError(w, s.log, err)
|
|
return
|
|
}
|
|
rec, err := s.create(r.Context(), svc, ident, body)
|
|
if err != nil {
|
|
writeError(w, s.log, err)
|
|
return
|
|
}
|
|
writeRecord(w, http.StatusCreated, rec)
|
|
}
|
|
}
|
|
|
|
// create inserts through the resource's own service, except where creating a
|
|
// record has a consequence in another table.
|
|
//
|
|
// One resource has one: an AI interview is only half of completing an
|
|
// interview, and the application it names has to be linked in the same
|
|
// transaction — see internal/service/interviews.go for why the server performs
|
|
// that write and the caller may not. Routing it here rather than registering a
|
|
// second endpoint keeps POST /api/v1/ai-interviews the only way to write one,
|
|
// which is what the client already calls and what the ownership guard already
|
|
// covers.
|
|
func (s *Server) create(ctx context.Context, svc *service.Service,
|
|
ident authctx.Identity, body domain.Record) (domain.Record, error) {
|
|
|
|
if svc.Resource().Path == service.InterviewsPath {
|
|
return s.workflows.CreateInterview(ctx, ident, body)
|
|
}
|
|
return svc.Create(ctx, ident, body)
|
|
}
|
|
|
|
func (s *Server) handleUpdate(svc *service.Service) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
ident, ok := s.authorize(w, r, svc, domain.OpUpdate)
|
|
if !ok {
|
|
return
|
|
}
|
|
body, err := decodeBody(r)
|
|
if err != nil {
|
|
writeError(w, s.log, err)
|
|
return
|
|
}
|
|
rec, err := svc.Update(r.Context(), ident, r.PathValue("id"), body)
|
|
if err != nil {
|
|
writeError(w, s.log, err)
|
|
return
|
|
}
|
|
writeRecord(w, http.StatusOK, rec)
|
|
}
|
|
}
|
|
|
|
func (s *Server) handleDelete(svc *service.Service) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
ident, ok := s.authorize(w, r, svc, domain.OpDelete)
|
|
if !ok {
|
|
return
|
|
}
|
|
rec, err := svc.Delete(r.Context(), ident, r.PathValue("id"))
|
|
if err != nil {
|
|
writeError(w, s.log, err)
|
|
return
|
|
}
|
|
writeRecord(w, http.StatusOK, rec)
|
|
}
|
|
}
|
|
|
|
// decodeInto reads a JSON body into a typed struct.
|
|
//
|
|
// Beside decodeBody rather than replacing it: the resource handlers genuinely
|
|
// want the open map, because a PATCH body is "whichever fields the caller sent"
|
|
// and a struct cannot distinguish an absent field from a zero one. The auth
|
|
// endpoints have a fixed, closed shape, and a struct says so.
|
|
func decodeInto(r *http.Request, dst any) error {
|
|
defer func() { _ = r.Body.Close() }()
|
|
raw, err := io.ReadAll(http.MaxBytesReader(nil, r.Body, maxBodyBytes))
|
|
if err != nil {
|
|
return domain.Invalid("request body could not be read")
|
|
}
|
|
if len(raw) == 0 {
|
|
return domain.Invalid("request body must be a JSON object")
|
|
}
|
|
if err := json.Unmarshal(raw, dst); err != nil {
|
|
return domain.Invalid("request body must be a JSON object")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// decodeBody reads a JSON object body.
|
|
//
|
|
// DisallowUnknownFields is not used — the target is a map, so every field is
|
|
// "known" here. Unknown *columns* are rejected in the service, where the
|
|
// resource's schema is available to say which those are.
|
|
func decodeBody(r *http.Request) (domain.Record, error) {
|
|
defer func() { _ = r.Body.Close() }()
|
|
raw, err := io.ReadAll(http.MaxBytesReader(nil, r.Body, maxBodyBytes))
|
|
if err != nil {
|
|
return nil, domain.Invalid("request body could not be read")
|
|
}
|
|
if len(raw) == 0 {
|
|
return domain.Record{}, nil
|
|
}
|
|
var body domain.Record
|
|
if err := json.Unmarshal(raw, &body); err != nil {
|
|
return nil, domain.Invalid("request body must be a JSON object")
|
|
}
|
|
if body == nil {
|
|
return domain.Record{}, nil
|
|
}
|
|
return body, nil
|
|
}
|