165 lines
4.8 KiB
Go
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
|
|
}()
|