336 lines
10 KiB
Go
336 lines
10 KiB
Go
// Package service sits between the HTTP layer and the repositories.
|
|
//
|
|
// It owns request validation, organization scoping, and the three behaviours
|
|
// the contract is most specific about: what a missing record does on read
|
|
// (§5.1), what a missing record does on delete (§12.7), and what a PATCH is
|
|
// allowed to touch (§3.2).
|
|
//
|
|
// No business rule lives here that the frontend does not already impose. The
|
|
// scoring, funnel and matching logic all stay client-side in Phase 2C.
|
|
package service
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/url"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"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/repo"
|
|
)
|
|
|
|
// MaxLimit caps how much a single request can ask for. Nothing in the frontend
|
|
// asks for more than 500; this exists so a hand-written query cannot ask for
|
|
// everything. See api-contract.md §8.
|
|
const MaxLimit = 1000
|
|
|
|
// Service serves one resource.
|
|
type Service struct {
|
|
res *domain.Resource
|
|
db repo.Querier
|
|
}
|
|
|
|
// New builds a service for a resource.
|
|
func New(res *domain.Resource, db repo.Querier) *Service {
|
|
return &Service{res: res, db: db}
|
|
}
|
|
|
|
// Resource is the descriptor this service serves.
|
|
func (s *Service) Resource() *domain.Resource { return s.res }
|
|
|
|
func (s *Service) repo() *repo.Repo { return repo.New(s.res, s.db) }
|
|
|
|
/* ── Query parsing ──────────────────────────────────────────────────────── */
|
|
|
|
// Reserved query parameters. Every other parameter is a field filter.
|
|
// No column in any resource collides with these. See api-contract.md §1.
|
|
var reserved = map[string]bool{"sort": true, "limit": true, "offset": true}
|
|
|
|
// ParseList turns a query string into validated list parameters, applying this
|
|
// resource's own defaults. The defaults are not generic: each one is the
|
|
// literal argument at the frontend call site (api-contract.md §8.1).
|
|
func (s *Service) ParseList(q url.Values) (domain.ListParams, error) {
|
|
p := domain.ListParams{Limit: s.res.DefaultLimit}
|
|
|
|
sortSpec := s.res.DefaultSort
|
|
if raw, ok := q["sort"]; ok && len(raw) > 0 {
|
|
sortSpec = raw[0] // an explicitly empty ?sort= means "no ordering"
|
|
}
|
|
if sortSpec != "" {
|
|
field := sortSpec
|
|
if strings.HasPrefix(field, "-") {
|
|
p.Desc, field = true, field[1:]
|
|
}
|
|
if !s.res.Sortable(field) {
|
|
return p, domain.Invalid(fmt.Sprintf("cannot sort by %q on %s", field, s.res.Name))
|
|
}
|
|
p.Sort = field
|
|
}
|
|
|
|
if raw := q.Get("limit"); raw != "" {
|
|
n, err := strconv.Atoi(raw)
|
|
if err != nil || n < 0 {
|
|
return p, domain.Invalid("limit must be a non-negative integer")
|
|
}
|
|
if n > MaxLimit {
|
|
n = MaxLimit
|
|
}
|
|
p.Limit = n
|
|
}
|
|
if raw := q.Get("offset"); raw != "" {
|
|
n, err := strconv.Atoi(raw)
|
|
if err != nil || n < 0 {
|
|
return p, domain.Invalid("offset must be a non-negative integer")
|
|
}
|
|
p.Offset = n
|
|
}
|
|
|
|
// Deterministic filter order keeps generated SQL stable and cacheable.
|
|
names := make([]string, 0, len(q))
|
|
for name := range q {
|
|
if !reserved[name] {
|
|
names = append(names, name)
|
|
}
|
|
}
|
|
sort.Strings(names)
|
|
|
|
for _, name := range names {
|
|
col, ok := s.res.Column(name)
|
|
if !ok {
|
|
return p, domain.Invalid(fmt.Sprintf("unknown filter field %q on %s", name, s.res.Name))
|
|
}
|
|
if !s.res.Filterable(name) {
|
|
return p, domain.Invalid(fmt.Sprintf(
|
|
"%s is not filterable: array and JSON columns cannot be compared for equality", name))
|
|
}
|
|
values := q[name]
|
|
if len(values) == 0 {
|
|
continue
|
|
}
|
|
p.Filters = append(p.Filters, domain.Filter{Column: col, Values: values})
|
|
}
|
|
return p, nil
|
|
}
|
|
|
|
/* ── Reads ──────────────────────────────────────────────────────────────── */
|
|
|
|
// List returns a page. An empty result is a page with no records, never an error.
|
|
func (s *Service) List(ctx context.Context, ident authctx.Identity, p domain.ListParams) (*domain.Page, error) {
|
|
page, err := s.repo().List(ctx, ident, p)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if page.Records == nil {
|
|
page.Records = []domain.Record{}
|
|
}
|
|
return page, nil
|
|
}
|
|
|
|
// Get returns one record, or a not_found error carrying store.js's message.
|
|
func (s *Service) Get(ctx context.Context, ident authctx.Identity, id string) (domain.Record, error) {
|
|
if !isUUID(id) {
|
|
// store.js throws "<Entity> <id> not found" for any id it cannot find,
|
|
// and a malformed id is simply an id it cannot find.
|
|
return nil, domain.NotFound(s.res.Name, id)
|
|
}
|
|
rec, err := s.repo().Get(ctx, ident, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if rec == nil {
|
|
return nil, domain.NotFound(s.res.Name, id)
|
|
}
|
|
return rec, nil
|
|
}
|
|
|
|
/* ── Writes ─────────────────────────────────────────────────────────────── */
|
|
|
|
// Create validates and inserts, returning the complete stored record.
|
|
func (s *Service) Create(ctx context.Context, ident authctx.Identity, body domain.Record) (domain.Record, error) {
|
|
clean, err := s.validate(body, true)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s.repo().Insert(ctx, ident, clean)
|
|
}
|
|
|
|
// Update shallow-merges the supplied fields. Absent keys are left untouched.
|
|
func (s *Service) Update(ctx context.Context, ident authctx.Identity, id string, patch domain.Record) (domain.Record, error) {
|
|
if !isUUID(id) {
|
|
return nil, domain.NotFound(s.res.Name, id)
|
|
}
|
|
clean, err := s.validate(patch, false)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rec, err := s.repo().Update(ctx, ident, id, clean)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if rec == nil {
|
|
return nil, domain.NotFound(s.res.Name, id)
|
|
}
|
|
return rec, nil
|
|
}
|
|
|
|
// Delete removes a record and always reports success.
|
|
//
|
|
// store.js filters its array and returns { id } whether or not anything
|
|
// matched, and both live callers delete inside loops without checking. A 404
|
|
// here would surface an error toast where none appears today.
|
|
// See api-contract.md §12.7.
|
|
func (s *Service) Delete(ctx context.Context, ident authctx.Identity, id string) (domain.Record, error) {
|
|
if !isUUID(id) {
|
|
return domain.Record{"id": id}, nil
|
|
}
|
|
if _, err := s.repo().Delete(ctx, ident, id); err != nil {
|
|
return nil, err
|
|
}
|
|
return domain.Record{"id": id}, nil
|
|
}
|
|
|
|
/* ── Validation ─────────────────────────────────────────────────────────── */
|
|
|
|
// validate checks a request body against the resource's columns and returns a
|
|
// copy with server-owned fields removed.
|
|
//
|
|
// Unknown fields are rejected rather than ignored. Silently dropping them is
|
|
// exactly how `interview_id`, `training_outline` and `score_breakdown` would
|
|
// have been lost: the frontend would have written them, the API would have
|
|
// accepted the request, and the data would never have arrived.
|
|
func (s *Service) validate(in domain.Record, isCreate bool) (domain.Record, error) {
|
|
details := map[string]string{}
|
|
out := make(domain.Record, len(in))
|
|
|
|
for name, value := range in {
|
|
col, ok := s.res.Column(name)
|
|
if !ok {
|
|
details[name] = "unknown field"
|
|
continue
|
|
}
|
|
if col.ReadOnly {
|
|
continue // server-owned: ignored, not rejected (api-contract.md §3.1)
|
|
}
|
|
if value == nil {
|
|
if col.NotNull {
|
|
details[name] = "must not be null"
|
|
continue
|
|
}
|
|
out[name] = nil
|
|
continue
|
|
}
|
|
if col.Kind == domain.KindEnum {
|
|
str, isStr := value.(string)
|
|
if !isStr || !contains(col.Enum, str) {
|
|
details[name] = fmt.Sprintf("must be one of: %s", strings.Join(col.Enum, ", "))
|
|
continue
|
|
}
|
|
}
|
|
out[name] = value
|
|
}
|
|
|
|
if isCreate {
|
|
for _, col := range s.res.Columns {
|
|
if !col.Required {
|
|
continue
|
|
}
|
|
if s.serverSupplies(col.Name) {
|
|
// The repository fills this from the session, so demanding it
|
|
// from the caller would reject a request the server is about to
|
|
// complete correctly. evidence.worker_email is the live case.
|
|
continue
|
|
}
|
|
v, ok := out[col.Name]
|
|
if !ok {
|
|
details[col.Name] = "required"
|
|
continue
|
|
}
|
|
if str, isStr := v.(string); isStr && strings.TrimSpace(str) == "" {
|
|
details[col.Name] = "must not be blank"
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(details) > 0 {
|
|
return nil, domain.Validation(
|
|
fmt.Sprintf("%s payload is not valid", s.res.Name), details)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// serverSupplies reports whether a column is filled in from the authenticated
|
|
// session rather than from the request body.
|
|
func (s *Service) serverSupplies(name string) bool {
|
|
if s.res.Policy == nil {
|
|
return false
|
|
}
|
|
for _, d := range s.res.Policy.Derived {
|
|
if d.Column == name {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func contains(set []string, v string) bool {
|
|
for _, s := range set {
|
|
if s == v {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// isUUID reports whether a string is shaped like a canonical UUID. Cheap enough
|
|
// to run per request and it keeps a malformed id out of the SQL entirely.
|
|
func isUUID(s string) bool {
|
|
if len(s) != 36 {
|
|
return false
|
|
}
|
|
for i, c := range s {
|
|
switch i {
|
|
case 8, 13, 18, 23:
|
|
if c != '-' {
|
|
return false
|
|
}
|
|
default:
|
|
isHex := (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')
|
|
if !isHex {
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
/* ── Registry ───────────────────────────────────────────────────────────── */
|
|
|
|
// Registry holds one service per resource that has an endpoint.
|
|
type Registry struct {
|
|
byPath map[string]*Service
|
|
order []*Service
|
|
}
|
|
|
|
// NewRegistry builds services for every resource in domain.AllResources.
|
|
func NewRegistry(db repo.Querier) *Registry {
|
|
reg := &Registry{byPath: make(map[string]*Service, len(domain.AllResources))}
|
|
for _, res := range domain.AllResources {
|
|
svc := New(res, db)
|
|
reg.byPath[res.Path] = svc
|
|
reg.order = append(reg.order, svc)
|
|
}
|
|
return reg
|
|
}
|
|
|
|
// Get returns the service for a URL path segment.
|
|
func (r *Registry) Get(path string) (*Service, bool) {
|
|
s, ok := r.byPath[path]
|
|
return s, ok
|
|
}
|
|
|
|
// All returns every service, in declaration order.
|
|
func (r *Registry) All() []*Service { return r.order }
|