// 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 }()