first commit
This commit is contained in:
164
go-api/internal/domain/resource.go
Normal file
164
go-api/internal/domain/resource.go
Normal file
@@ -0,0 +1,164 @@
|
||||
// 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
|
||||
}()
|
||||
Reference in New Issue
Block a user