Files
krow_backend/go-api/internal/domain/resource.go
2026-08-24 13:06:29 +05:30

165 lines
4.8 KiB
Go

// Package domain describes the API's resources: their columns, types and the
// operations each one supports.
//
// The descriptors here are the single place the contract in
// `docs/api-contract.md` is encoded. The repository, service and HTTP layers
// are all driven from them, so a semantic is implemented once and applies
// identically to every resource — which is the point. Fourteen hand-written
// repositories would be fourteen chances to get NULLS LAST wrong.
package domain
import "fmt"
// Kind is a column's value type, as the API presents it.
type Kind int
const (
KindString Kind = iota
KindInt
KindFloat
KindBool
KindTimestamp
KindDate
KindTextArray
KindJSON
KindUUID
KindEnum
)
// Op is a supported operation, as a bit set.
type Op uint8
const (
OpList Op = 1 << iota
OpGet
OpCreate
OpUpdate
OpDelete
)
// Column is one database column and how the API treats it.
type Column struct {
Name string
Kind Kind
PGType string // the type every parameter is explicitly cast to
NotNull bool // database-level NOT NULL
ReadOnly bool // server-owned: ignored if present in a request body
Required bool // must be supplied, non-blank, on create
Enum []string // permitted values when Kind == KindEnum
}
// Resource is one API resource and its backing table.
type Resource struct {
Name string // frontend entity name, used verbatim in error messages
Path string // URL segment
Table string
Columns []Column
DefaultSort string
DefaultLimit int
Ops Op
// OrgNullable marks a table where a NULL org_id means "shared across every
// organization" — the platform course library. Reads match org OR NULL.
OrgNullable bool
// Policy is who may do what, and which rows they see. Attached from
// policy.go, which is hand-written; nil means the resource permits nothing.
Policy *Policy
byName map[string]*Column
}
// Supports reports whether the resource exposes an operation.
func (r *Resource) Supports(op Op) bool { return r.Ops&op != 0 }
// Column looks a column up by name.
func (r *Resource) Column(name string) (*Column, bool) {
if r.byName == nil {
r.byName = make(map[string]*Column, len(r.Columns))
for i := range r.Columns {
r.byName[r.Columns[i].Name] = &r.Columns[i]
}
}
c, ok := r.byName[name]
return c, ok
}
// Filterable reports whether a column may appear as a query filter.
//
// Arrays and JSON are excluded deliberately. `store.js` compares with `===`,
// so a filter against an array column matches nothing today; supporting
// containment here would be a silent behaviour change, not a fix.
// See api-contract.md §6.
func (r *Resource) Filterable(name string) bool {
c, ok := r.Column(name)
if !ok {
return false
}
switch c.Kind {
case KindTextArray, KindJSON:
return false
}
return true
}
// Sortable reports whether a column may be sorted on. Any real column may be.
func (r *Resource) Sortable(name string) bool {
_, ok := r.Column(name)
return ok
}
// SelectExpr is the SQL that reads one column back in its API representation.
//
// The casts are not cosmetic. pgx hands back a [16]byte for uuid and a
// pgtype.Numeric for numeric, neither of which JSON-encodes as the frontend
// expects, and both are cheaper to fix in the projection than in Go.
func (c Column) SelectExpr() string {
switch c.Kind {
case KindUUID:
return fmt.Sprintf("%s::text AS %s", c.Name, c.Name)
case KindFloat:
return fmt.Sprintf("%s::float8 AS %s", c.Name, c.Name)
case KindDate:
return fmt.Sprintf("to_char(%s, 'YYYY-MM-DD') AS %s", c.Name, c.Name)
case KindTimestamp:
// Reproduces the millisecond ISO-8601 form the seed data uses, so a
// record read back over HTTP is byte-identical to what the frontend
// has always seen from localStorage.
return fmt.Sprintf(
`to_char(%s AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') AS %s`, c.Name, c.Name)
case KindString:
if c.PGType == "citext" {
// pgx has no codec registered for citext, so without this the value
// comes back as an unmapped type rather than a string.
return fmt.Sprintf("%s::text AS %s", c.Name, c.Name)
}
return c.Name
case KindInt:
if c.PGType == "bigint" {
// user_activity.id is an identity bigint. Every id the frontend
// handles is an opaque string, so it stays one here too.
return fmt.Sprintf("%s::text AS %s", c.Name, c.Name)
}
return c.Name
default:
return c.Name
}
}
// ResourceByPath indexes AllResources by URL segment.
var ResourceByPath = func() map[string]*Resource {
m := make(map[string]*Resource, len(AllResources))
for _, r := range AllResources {
m[r.Path] = r
}
return m
}()
// ResourceByTable indexes AllResources by table name.
var ResourceByTable = func() map[string]*Resource {
m := make(map[string]*Resource, len(AllResources))
for _, r := range AllResources {
m[r.Table] = r
}
return m
}()