first commit
This commit is contained in:
220
go-api/internal/httpserver/api.go
Normal file
220
go-api/internal/httpserver/api.go
Normal file
@@ -0,0 +1,220 @@
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"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 := svc.Create(r.Context(), ident, body)
|
||||
if err != nil {
|
||||
writeError(w, s.log, err)
|
||||
return
|
||||
}
|
||||
writeRecord(w, http.StatusCreated, rec)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user