first commit

This commit is contained in:
2026-08-24 13:06:29 +05:30
commit 7d12ebef3d
86 changed files with 39996 additions and 0 deletions

View 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
}