first commit
This commit is contained in:
451
go-api/internal/service/definitions.go
Normal file
451
go-api/internal/service/definitions.go
Normal file
@@ -0,0 +1,451 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/krow/krow-backend/go-api/internal/authctx"
|
||||
"github.com/krow/krow-backend/go-api/internal/definition"
|
||||
"github.com/krow/krow-backend/go-api/internal/domain"
|
||||
"github.com/krow/krow-backend/go-api/internal/repo"
|
||||
)
|
||||
|
||||
// allowedDefinitionFilters names the accepted query parameters for definition collections.
|
||||
var allowedDefinitionFilters = map[string]bool{
|
||||
"visibility": true,
|
||||
"status": true,
|
||||
"definition_id": true,
|
||||
"sort": true,
|
||||
"limit": true,
|
||||
"offset": true,
|
||||
}
|
||||
|
||||
// DefinitionsService manages authored Agent and Skill definitions.
|
||||
type DefinitionsService struct {
|
||||
repo *repo.DefinitionsRepo
|
||||
}
|
||||
|
||||
// NewDefinitions builds a definitions service over a repository.
|
||||
func NewDefinitions(db repo.Querier) *DefinitionsService {
|
||||
return &DefinitionsService{repo: repo.NewDefinitionsRepo(db)}
|
||||
}
|
||||
|
||||
// ParseListParams validates query parameters for listing definitions.
|
||||
func (s *DefinitionsService) ParseListParams(q url.Values) (repo.DefinitionListParams, error) {
|
||||
p := repo.DefinitionListParams{
|
||||
Limit: 100,
|
||||
Sort: "created_date",
|
||||
Desc: true,
|
||||
}
|
||||
|
||||
for name := range q {
|
||||
if !allowedDefinitionFilters[name] {
|
||||
return p, domain.Invalid(fmt.Sprintf("unknown filter field %q", name))
|
||||
}
|
||||
}
|
||||
|
||||
if raw := q.Get("visibility"); raw != "" {
|
||||
if raw != "personal" && raw != "organization" {
|
||||
return p, domain.Invalid("visibility must be one of: personal, organization")
|
||||
}
|
||||
p.Visibility = raw
|
||||
}
|
||||
|
||||
if raw := q.Get("status"); raw != "" {
|
||||
p.Status = raw
|
||||
}
|
||||
|
||||
if raw := q.Get("definition_id"); raw != "" {
|
||||
p.DefinitionID = raw
|
||||
}
|
||||
|
||||
if raw := q.Get("sort"); raw != "" {
|
||||
field := raw
|
||||
desc := false
|
||||
if strings.HasPrefix(field, "-") {
|
||||
desc = true
|
||||
field = field[1:]
|
||||
}
|
||||
switch field {
|
||||
case "created_date", "updated_date", "name", "definition_id", "status", "version":
|
||||
p.Sort = field
|
||||
p.Desc = desc
|
||||
default:
|
||||
return p, domain.Invalid(fmt.Sprintf("cannot sort by %q", 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
|
||||
}
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
/* ── Agents ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
// ListAgents returns a page of authored agent definitions.
|
||||
func (s *DefinitionsService) ListAgents(ctx context.Context, ident authctx.Identity, p repo.DefinitionListParams) (*domain.Page, error) {
|
||||
records, total, err := s.repo.ListAgents(ctx, ident, p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if records == nil {
|
||||
records = []domain.Record{}
|
||||
}
|
||||
return &domain.Page{
|
||||
Records: records,
|
||||
Total: total,
|
||||
Limit: p.Limit,
|
||||
Offset: p.Offset,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetAgent returns one agent definition by id within the caller's tenant and ownership scope.
|
||||
func (s *DefinitionsService) GetAgent(ctx context.Context, ident authctx.Identity, id string) (domain.Record, error) {
|
||||
if !isUUID(id) {
|
||||
return nil, domain.NotFound("AgentDefinition", id)
|
||||
}
|
||||
rec, err := s.repo.GetAgent(ctx, ident, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if rec == nil {
|
||||
return nil, domain.NotFound("AgentDefinition", id)
|
||||
}
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
// CreateAgent validates, parses and persists a new authored agent definition.
|
||||
func (s *DefinitionsService) CreateAgent(ctx context.Context, ident authctx.Identity, body domain.Record) (domain.Record, error) {
|
||||
mdRaw, ok := body["markdown"]
|
||||
if !ok || mdRaw == nil {
|
||||
return nil, domain.Validation("Paste or upload a Markdown definition.", nil)
|
||||
}
|
||||
markdown, isStr := mdRaw.(string)
|
||||
if !isStr {
|
||||
return nil, domain.Validation("markdown must be a string", nil)
|
||||
}
|
||||
|
||||
if err := definition.ValidateAgent(markdown); err != nil {
|
||||
return nil, domain.Validation(err.Error(), nil)
|
||||
}
|
||||
|
||||
visibility := "personal"
|
||||
if visRaw, ok := body["visibility"]; ok && visRaw != nil {
|
||||
v, isStr := visRaw.(string)
|
||||
if !isStr || (v != "personal" && v != "organization") {
|
||||
return nil, domain.Validation("visibility must be one of: personal, organization", map[string]string{"visibility": "invalid"})
|
||||
}
|
||||
visibility = v
|
||||
}
|
||||
|
||||
if visibility == "organization" {
|
||||
role, known := domain.ParseRole(ident.Role)
|
||||
if !known || role == domain.RoleTalent {
|
||||
return nil, domain.Forbidden()
|
||||
}
|
||||
}
|
||||
|
||||
agent, err := definition.ParseAgent(markdown, definition.Options{})
|
||||
if err != nil {
|
||||
return nil, domain.Validation("That definition could not be parsed. "+err.Error(), nil)
|
||||
}
|
||||
|
||||
input := repo.AgentInsertInput{
|
||||
DefinitionID: agent.ID,
|
||||
OrgID: ident.OrgID,
|
||||
Visibility: visibility,
|
||||
CreatedBy: &ident.UserID,
|
||||
Markdown: markdown,
|
||||
Status: agent.Status,
|
||||
Version: agent.Version,
|
||||
Name: agent.Name,
|
||||
Description: agent.Description,
|
||||
Pages: agent.Pages,
|
||||
}
|
||||
|
||||
if visibility == "personal" {
|
||||
input.OwnerUserID = &ident.UserID
|
||||
}
|
||||
|
||||
return s.repo.InsertAgent(ctx, ident, input)
|
||||
}
|
||||
|
||||
// UpdateAgent validates and applies updates to an authored agent definition.
|
||||
func (s *DefinitionsService) UpdateAgent(ctx context.Context, ident authctx.Identity, id string, patch domain.Record) (domain.Record, error) {
|
||||
if !isUUID(id) {
|
||||
return nil, domain.NotFound("AgentDefinition", id)
|
||||
}
|
||||
|
||||
existing, err := s.repo.GetAgent(ctx, ident, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if existing == nil {
|
||||
return nil, domain.NotFound("AgentDefinition", id)
|
||||
}
|
||||
|
||||
if existing["visibility"] == "organization" {
|
||||
role, known := domain.ParseRole(ident.Role)
|
||||
if !known || role == domain.RoleTalent {
|
||||
return nil, domain.Forbidden()
|
||||
}
|
||||
}
|
||||
|
||||
if visRaw, ok := patch["visibility"]; ok && visRaw != nil {
|
||||
if v, isStr := visRaw.(string); isStr && v != existing["visibility"] {
|
||||
return nil, domain.Validation("visibility cannot be modified after creation", map[string]string{"visibility": "immutable"})
|
||||
}
|
||||
}
|
||||
|
||||
var input repo.AgentUpdateInput
|
||||
|
||||
if mdRaw, ok := patch["markdown"]; ok && mdRaw != nil {
|
||||
markdown, isStr := mdRaw.(string)
|
||||
if !isStr {
|
||||
return nil, domain.Validation("markdown must be a string", nil)
|
||||
}
|
||||
if err := definition.ValidateAgent(markdown); err != nil {
|
||||
return nil, domain.Validation(err.Error(), nil)
|
||||
}
|
||||
agent, err := definition.ParseAgent(markdown, definition.Options{})
|
||||
if err != nil {
|
||||
return nil, domain.Validation("That definition could not be parsed. "+err.Error(), nil)
|
||||
}
|
||||
input.Markdown = &markdown
|
||||
input.DefinitionID = &agent.ID
|
||||
input.Name = &agent.Name
|
||||
input.Description = &agent.Description
|
||||
input.Status = &agent.Status
|
||||
input.Version = &agent.Version
|
||||
input.Pages = agent.Pages
|
||||
} else if statusRaw, ok := patch["status"]; ok && statusRaw != nil {
|
||||
status, isStr := statusRaw.(string)
|
||||
if !isStr || (status != "draft" && status != "published" && status != "archived") {
|
||||
return nil, domain.Validation("status must be one of: draft, published, archived", map[string]string{"status": "invalid"})
|
||||
}
|
||||
input.Status = &status
|
||||
}
|
||||
|
||||
return s.repo.UpdateAgent(ctx, ident, id, input)
|
||||
}
|
||||
|
||||
// DeleteAgent removes an agent definition following idempotent delete semantics.
|
||||
func (s *DefinitionsService) DeleteAgent(ctx context.Context, ident authctx.Identity, id string) (domain.Record, error) {
|
||||
if !isUUID(id) {
|
||||
return domain.Record{"id": id}, nil
|
||||
}
|
||||
|
||||
existing, err := s.repo.GetAgent(ctx, ident, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if existing == nil {
|
||||
return domain.Record{"id": id}, nil
|
||||
}
|
||||
|
||||
if existing["visibility"] == "organization" {
|
||||
role, known := domain.ParseRole(ident.Role)
|
||||
if !known || role == domain.RoleTalent {
|
||||
return nil, domain.Forbidden()
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := s.repo.DeleteAgent(ctx, ident, id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return domain.Record{"id": id}, nil
|
||||
}
|
||||
|
||||
/* ── Skills ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
// ListSkills returns a page of authored skill definitions.
|
||||
func (s *DefinitionsService) ListSkills(ctx context.Context, ident authctx.Identity, p repo.DefinitionListParams) (*domain.Page, error) {
|
||||
records, total, err := s.repo.ListSkills(ctx, ident, p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if records == nil {
|
||||
records = []domain.Record{}
|
||||
}
|
||||
return &domain.Page{
|
||||
Records: records,
|
||||
Total: total,
|
||||
Limit: p.Limit,
|
||||
Offset: p.Offset,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetSkill returns one skill definition by id within the caller's tenant and ownership scope.
|
||||
func (s *DefinitionsService) GetSkill(ctx context.Context, ident authctx.Identity, id string) (domain.Record, error) {
|
||||
if !isUUID(id) {
|
||||
return nil, domain.NotFound("SkillDefinition", id)
|
||||
}
|
||||
rec, err := s.repo.GetSkill(ctx, ident, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if rec == nil {
|
||||
return nil, domain.NotFound("SkillDefinition", id)
|
||||
}
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
// CreateSkill validates, parses and persists a new authored skill definition.
|
||||
func (s *DefinitionsService) CreateSkill(ctx context.Context, ident authctx.Identity, body domain.Record) (domain.Record, error) {
|
||||
mdRaw, ok := body["markdown"]
|
||||
if !ok || mdRaw == nil {
|
||||
return nil, domain.Validation("Paste or upload a Markdown definition.", nil)
|
||||
}
|
||||
markdown, isStr := mdRaw.(string)
|
||||
if !isStr {
|
||||
return nil, domain.Validation("markdown must be a string", nil)
|
||||
}
|
||||
|
||||
if err := definition.ValidateSkill(markdown); err != nil {
|
||||
return nil, domain.Validation(err.Error(), nil)
|
||||
}
|
||||
|
||||
visibility := "personal"
|
||||
if visRaw, ok := body["visibility"]; ok && visRaw != nil {
|
||||
v, isStr := visRaw.(string)
|
||||
if !isStr || (v != "personal" && v != "organization") {
|
||||
return nil, domain.Validation("visibility must be one of: personal, organization", map[string]string{"visibility": "invalid"})
|
||||
}
|
||||
visibility = v
|
||||
}
|
||||
|
||||
if visibility == "organization" {
|
||||
role, known := domain.ParseRole(ident.Role)
|
||||
if !known || role == domain.RoleTalent {
|
||||
return nil, domain.Forbidden()
|
||||
}
|
||||
}
|
||||
|
||||
skill, err := definition.ParseSkill(markdown, definition.Options{})
|
||||
if err != nil {
|
||||
return nil, domain.Validation("That definition could not be parsed. "+err.Error(), nil)
|
||||
}
|
||||
|
||||
input := repo.SkillInsertInput{
|
||||
DefinitionID: skill.ID,
|
||||
OrgID: ident.OrgID,
|
||||
Visibility: visibility,
|
||||
CreatedBy: &ident.UserID,
|
||||
Markdown: markdown,
|
||||
Status: skill.Status,
|
||||
Name: skill.Name,
|
||||
Description: skill.Description,
|
||||
Pages: skill.Pages,
|
||||
}
|
||||
|
||||
if visibility == "personal" {
|
||||
input.OwnerUserID = &ident.UserID
|
||||
}
|
||||
|
||||
return s.repo.InsertSkill(ctx, ident, input)
|
||||
}
|
||||
|
||||
// UpdateSkill validates and applies updates to an authored skill definition.
|
||||
func (s *DefinitionsService) UpdateSkill(ctx context.Context, ident authctx.Identity, id string, patch domain.Record) (domain.Record, error) {
|
||||
if !isUUID(id) {
|
||||
return nil, domain.NotFound("SkillDefinition", id)
|
||||
}
|
||||
|
||||
existing, err := s.repo.GetSkill(ctx, ident, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if existing == nil {
|
||||
return nil, domain.NotFound("SkillDefinition", id)
|
||||
}
|
||||
|
||||
if existing["visibility"] == "organization" {
|
||||
role, known := domain.ParseRole(ident.Role)
|
||||
if !known || role == domain.RoleTalent {
|
||||
return nil, domain.Forbidden()
|
||||
}
|
||||
}
|
||||
|
||||
if visRaw, ok := patch["visibility"]; ok && visRaw != nil {
|
||||
if v, isStr := visRaw.(string); isStr && v != existing["visibility"] {
|
||||
return nil, domain.Validation("visibility cannot be modified after creation", map[string]string{"visibility": "immutable"})
|
||||
}
|
||||
}
|
||||
|
||||
var input repo.SkillUpdateInput
|
||||
|
||||
if mdRaw, ok := patch["markdown"]; ok && mdRaw != nil {
|
||||
markdown, isStr := mdRaw.(string)
|
||||
if !isStr {
|
||||
return nil, domain.Validation("markdown must be a string", nil)
|
||||
}
|
||||
if err := definition.ValidateSkill(markdown); err != nil {
|
||||
return nil, domain.Validation(err.Error(), nil)
|
||||
}
|
||||
skill, err := definition.ParseSkill(markdown, definition.Options{})
|
||||
if err != nil {
|
||||
return nil, domain.Validation("That definition could not be parsed. "+err.Error(), nil)
|
||||
}
|
||||
input.Markdown = &markdown
|
||||
input.DefinitionID = &skill.ID
|
||||
input.Name = &skill.Name
|
||||
input.Description = &skill.Description
|
||||
input.Status = &skill.Status
|
||||
input.Pages = skill.Pages
|
||||
} else if statusRaw, ok := patch["status"]; ok && statusRaw != nil {
|
||||
status, isStr := statusRaw.(string)
|
||||
if !isStr || (status != "active" && status != "inactive") {
|
||||
return nil, domain.Validation("status must be one of: active, inactive", map[string]string{"status": "invalid"})
|
||||
}
|
||||
input.Status = &status
|
||||
}
|
||||
|
||||
return s.repo.UpdateSkill(ctx, ident, id, input)
|
||||
}
|
||||
|
||||
// DeleteSkill removes a skill definition following idempotent delete semantics.
|
||||
func (s *DefinitionsService) DeleteSkill(ctx context.Context, ident authctx.Identity, id string) (domain.Record, error) {
|
||||
if !isUUID(id) {
|
||||
return domain.Record{"id": id}, nil
|
||||
}
|
||||
|
||||
existing, err := s.repo.GetSkill(ctx, ident, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if existing == nil {
|
||||
return domain.Record{"id": id}, nil
|
||||
}
|
||||
|
||||
if existing["visibility"] == "organization" {
|
||||
role, known := domain.ParseRole(ident.Role)
|
||||
if !known || role == domain.RoleTalent {
|
||||
return nil, domain.Forbidden()
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := s.repo.DeleteSkill(ctx, ident, id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return domain.Record{"id": id}, nil
|
||||
}
|
||||
335
go-api/internal/service/service.go
Normal file
335
go-api/internal/service/service.go
Normal file
@@ -0,0 +1,335 @@
|
||||
// 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 }
|
||||
217
go-api/internal/service/service_test.go
Normal file
217
go-api/internal/service/service_test.go
Normal file
@@ -0,0 +1,217 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/krow/krow-backend/go-api/internal/domain"
|
||||
)
|
||||
|
||||
// These exercise query parsing and validation without a database, so the
|
||||
// contract's defaults are pinned even when PostgreSQL is not available.
|
||||
|
||||
func resource(t *testing.T, path string) *domain.Resource {
|
||||
t.Helper()
|
||||
res, ok := domain.ResourceByPath[path]
|
||||
if !ok {
|
||||
t.Fatalf("no resource for path %q", path)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func parse(t *testing.T, path, query string) (domain.ListParams, error) {
|
||||
t.Helper()
|
||||
values, err := url.ParseQuery(query)
|
||||
if err != nil {
|
||||
t.Fatalf("bad test query %q: %v", query, err)
|
||||
}
|
||||
return New(resource(t, path), nil).ParseList(values)
|
||||
}
|
||||
|
||||
// Each default is the literal argument at the frontend call site
|
||||
// (api-contract.md §8.1), not a generic value.
|
||||
func TestParseListDefaults(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
path string
|
||||
limit int
|
||||
sort string
|
||||
desc bool
|
||||
}{
|
||||
{"job-postings", 100, "created_date", true},
|
||||
{"job-applications", 200, "ai_score", true},
|
||||
{"worker-profiles", 500, "krow_score", true},
|
||||
{"shift-records", 500, "created_date", true},
|
||||
{"user-activity", 500, "created_date", true},
|
||||
{"courses", 200, "created_date", true},
|
||||
} {
|
||||
p, err := parse(t, tc.path, "")
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", tc.path, err)
|
||||
}
|
||||
if p.Limit != tc.limit {
|
||||
t.Errorf("%s limit = %d, want %d", tc.path, p.Limit, tc.limit)
|
||||
}
|
||||
if p.Sort != tc.sort || p.Desc != tc.desc {
|
||||
t.Errorf("%s sort = %q desc=%v, want %q desc=%v", tc.path, p.Sort, p.Desc, tc.sort, tc.desc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An explicitly empty ?sort= means no ordering, matching `if (!sort) return records`.
|
||||
func TestParseListEmptySortMeansUnordered(t *testing.T) {
|
||||
p, err := parse(t, "job-postings", "sort=")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.Sort != "" {
|
||||
t.Errorf("sort = %q, want empty", p.Sort)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseListLimitClampAndRejection(t *testing.T) {
|
||||
p, err := parse(t, "job-postings", "limit=99999")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.Limit != MaxLimit {
|
||||
t.Errorf("limit = %d, want it clamped to %d", p.Limit, MaxLimit)
|
||||
}
|
||||
// A zero limit is legitimate: store.js's slice(0, 0) returns nothing.
|
||||
if p, err := parse(t, "job-postings", "limit=0"); err != nil || p.Limit != 0 {
|
||||
t.Errorf("limit=0 -> %d, %v", p.Limit, err)
|
||||
}
|
||||
for _, bad := range []string{"limit=-1", "limit=abc", "offset=-3", "offset=x"} {
|
||||
if _, err := parse(t, "job-postings", bad); err == nil {
|
||||
t.Errorf("%s was accepted", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseListRejectsUnknownSortAndFilter(t *testing.T) {
|
||||
if _, err := parse(t, "job-postings", "sort=-nope"); err == nil {
|
||||
t.Error("unknown sort field was accepted")
|
||||
}
|
||||
if _, err := parse(t, "job-postings", "nope=1"); err == nil {
|
||||
t.Error("unknown filter field was accepted")
|
||||
}
|
||||
// Arrays and JSON are not comparable for equality, so they are not filterable.
|
||||
if _, err := parse(t, "job-postings", "responsibilities=x"); err == nil {
|
||||
t.Error("an array column was accepted as a filter")
|
||||
}
|
||||
if _, err := parse(t, "job-postings", "vetting_criteria=x"); err == nil {
|
||||
t.Error("a jsonb column was accepted as a filter")
|
||||
}
|
||||
}
|
||||
|
||||
// A repeated parameter is membership, matching `Array.isArray(want)`.
|
||||
func TestParseListRepeatedParameterIsMembership(t *testing.T) {
|
||||
p, err := parse(t, "job-applications", "status=hired&status=interview")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(p.Filters) != 1 {
|
||||
t.Fatalf("filters = %d, want 1", len(p.Filters))
|
||||
}
|
||||
if len(p.Filters[0].Values) != 2 {
|
||||
t.Errorf("values = %v, want two", p.Filters[0].Values)
|
||||
}
|
||||
}
|
||||
|
||||
// sort, limit and offset are reserved; no column collides with them.
|
||||
func TestReservedParametersAreNotFilters(t *testing.T) {
|
||||
p, err := parse(t, "job-applications", "sort=-ai_score&limit=5&offset=2&status=hired")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(p.Filters) != 1 || p.Filters[0].Column.Name != "status" {
|
||||
t.Errorf("filters = %#v, want only status", p.Filters)
|
||||
}
|
||||
if p.Limit != 5 || p.Offset != 2 {
|
||||
t.Errorf("limit/offset = %d/%d, want 5/2", p.Limit, p.Offset)
|
||||
}
|
||||
for _, r := range []string{"sort", "limit", "offset"} {
|
||||
if _, isColumn := resource(t, "job-applications").Column(r); isColumn {
|
||||
t.Errorf("a column named %q collides with a reserved parameter", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRequiredAndUnknownAndEnum(t *testing.T) {
|
||||
svc := New(resource(t, "job-postings"), nil)
|
||||
|
||||
if _, err := svc.validate(domain.Record{}, true); err == nil {
|
||||
t.Error("a create with no title was accepted")
|
||||
}
|
||||
if _, err := svc.validate(domain.Record{"title": " "}, true); err == nil {
|
||||
t.Error("a blank title was accepted")
|
||||
}
|
||||
if _, err := svc.validate(domain.Record{"title": "X", "bogus": 1}, true); err == nil {
|
||||
t.Error("an unknown field was accepted")
|
||||
}
|
||||
if _, err := svc.validate(domain.Record{"title": "X", "status": "archived"}, true); err == nil {
|
||||
t.Error("an invalid enum value was accepted")
|
||||
}
|
||||
if _, err := svc.validate(domain.Record{"title": "X", "status": "active"}, true); err != nil {
|
||||
t.Errorf("a valid payload was rejected: %v", err)
|
||||
}
|
||||
|
||||
// Server-owned fields are stripped, not rejected.
|
||||
out, err := svc.validate(domain.Record{"title": "X", "id": "abc", "org_id": "def"}, true)
|
||||
if err != nil {
|
||||
t.Fatalf("server-owned fields caused a rejection: %v", err)
|
||||
}
|
||||
if _, present := out["id"]; present {
|
||||
t.Error("id survived validation")
|
||||
}
|
||||
if _, present := out["org_id"]; present {
|
||||
t.Error("org_id survived validation")
|
||||
}
|
||||
|
||||
// An update needs no required fields — it is a partial by definition.
|
||||
if _, err := svc.validate(domain.Record{"location": "Here"}, false); err != nil {
|
||||
t.Errorf("a partial update was rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsUUID(t *testing.T) {
|
||||
valid := []string{
|
||||
"00000000-0000-0000-0000-000000000000",
|
||||
"9A88DEBC-76E5-572C-A7E7-6EB5F43A6705",
|
||||
}
|
||||
for _, v := range valid {
|
||||
if !isUUID(v) {
|
||||
t.Errorf("%q rejected", v)
|
||||
}
|
||||
}
|
||||
invalid := []string{"", "not-a-uuid", "00000000000000000000000000000000",
|
||||
"00000000-0000-0000-0000-00000000000g", "00000000-0000-0000-0000-0000000000000"}
|
||||
for _, v := range invalid {
|
||||
if isUUID(v) {
|
||||
t.Errorf("%q accepted", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every resource must declare a sort column that actually exists, and a limit
|
||||
// in range — a typo in the generated metadata would otherwise only surface as a
|
||||
// 400 at runtime.
|
||||
func TestEveryResourceIsCoherent(t *testing.T) {
|
||||
for _, res := range domain.AllResources {
|
||||
field := res.DefaultSort
|
||||
if len(field) > 0 && field[0] == '-' {
|
||||
field = field[1:]
|
||||
}
|
||||
if !res.Sortable(field) {
|
||||
t.Errorf("%s: default sort %q is not a column", res.Name, res.DefaultSort)
|
||||
}
|
||||
if res.DefaultLimit < 1 || res.DefaultLimit > MaxLimit {
|
||||
t.Errorf("%s: default limit %d is out of range", res.Name, res.DefaultLimit)
|
||||
}
|
||||
if _, ok := res.Column("id"); !ok {
|
||||
t.Errorf("%s: no id column, so the sort tiebreaker cannot apply", res.Name)
|
||||
}
|
||||
if _, ok := res.Column("org_id"); !ok {
|
||||
t.Errorf("%s: no org_id column, so it cannot be scoped", res.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user