rest

package
v1.0.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 3, 2026 License: Apache-2.0 Imports: 20 Imported by: 0

Documentation

Overview

Package rest is an entity's projection onto HTTP: five routes, the commands beside them, and the one mapping from kit/crud's errors to statuses.

A module declares one Spec per entity and gets list, create, read, update and delete as declared routes, each with its permission, each emitting the events the manifest promises. What a module writes is the struct, in its contracts/ package, and the Spec, in its manifest; what it does not write is a repository, a service, a handler, a DTO or a mapper.

It is a package of its own, and not the other half of kit/crud, because a contracts/ package imports the entity half and every consumer of a module compiles against its contracts/. Keeping the routes here is what keeps huma, chi and NATS out of the build graph of a module that only wanted to name a Task. See ARCHITECTURE.md, idea 3.

Index

Constants

View Source
const (
	Created = "created"
	Updated = "updated"
	Deleted = "deleted"
)

The three events every Spec publishes.

Variables

This section is empty.

Functions

func Command

func Command[I any, T crud.Entity](api *httpx.API, spec Spec[T], verb, summary, description string, events []string,
	run func(ctx context.Context, tx db.Tx[db.Tenant], id uuid.UUID, in I) (T, error), opts CommandOptions,
)

Command registers one lifecycle route on a Spec: POST {Path}/{id}/{verb}, or POST {Path}/{verb} when opts says Collection. It is guarded by the Spec's Write permission unless opts says otherwise, takes the request's transaction, declares the events it publishes, and answers failures with the same mapping the five routes above use.

It is here rather than in each module because a command is the one thing a Spec cannot express — each is a rule about the state the entity is in, and each publishes an event of its own — while everything around it is what every module would otherwise write again and disagree with: a module with its own error mapping is a second opinion about what a 409 means, and one that forgot the events extension is a route the boot gate cannot see.

I is the request body and it is optional, so a command that takes no arguments is a POST with no body at all; run is handed the zero I in that case. A command whose argument is missing is refused by run, with ErrInvalid, rather than by the decoder — which is what keeps "no assignee" and "an assignee that is not a user" the same 422.

func Display

func Display(f crud.Field, v any) string

Display is a field's value as a screen shows it, and it is the only one: a cell, a description list and a select's option label all come through here, so a status is "In progress" in all three rather than "in_progress" in two of them. An instant is in the form a person reads, a boolean is Yes or No, and nothing at all is a dash, because a blank cell reads as a bug rather than as an empty field.

func Fault

func Fault(err error) error

Fault turns the three errors a caller can act on into the response that says so: 404 for a row this tenant does not have, 422 for something the caller sent, 409 for a state the write contradicts. Anything else is an outage and reaches huma as a 500 with its cause in the log and nothing in the body.

It is exported because a module's own handlers answer with the same three errors as the five routes here, and one mapping is the point: a module that wrote its own would be a second opinion about what a 404 means.

func FieldErrors

func FieldErrors(err error, fields []crud.Field) (map[string]string, string)

FieldErrors reads a problem back into the fields it is about, so a form marks the control rather than only shouting above it, and returns the whole message as well. kit/problem's Errors carry "field: message"; a Detail that names a field is matched too, because kit/crud's own messages are prose that names it.

The Detail match is on the field's name as a word of its own. It used to be a substring, so a message about "subtitle" marked "title" — the wrong control, with the right message, which is the confusing half of both.

func FieldHelp

func FieldHelp(f crud.Field) string

FieldHelp is the note under a control: the field's own Doc, when it has one.

func FieldLabel

func FieldLabel(f crud.Field) string

FieldLabel is what a control, a column header and a description term call a field. It is Humanize today, in one place, so that the entity gaining a way to name its own fields is one line here rather than three at three call sites. Field.Doc is not that name: the entities in this repository write a sentence there — "Short summary of the task" — which is a description and belongs under the control, not on it. See Field.Doc.

func Humanize

func Humanize(name string) string

Humanize turns a JSON name or an enum value into something a person reads: "slaDeadline" becomes "Sla deadline" and "in_progress" becomes "In progress". It is not a dictionary and does not try to be one; a field that wants a better word is a field that should say so, which is what Field.Doc is for.

func Text

func Text(v any) string

Text is a value as a form control and a link read it: the raw one. Everything arrives as encoding/json made it, so a number is a float64 and a list is a []any.

It is not Display. A select's value attribute has to be the enum's own spelling and a checkbox's has to be "true"; what a person reads is Display's business, and confusing the two is how a form posts back "Yes".

func Values

func Values(body []byte, fields []crud.Field, refuse []string) (map[string]any, error)

Values reads a submitted form into the shape a Resource's write takes, typed by the schema rather than by guesswork: a number field arrives as a number, a checkbox that was not ticked arrives as false rather than as missing, and a blank optional field is left out so a nullable column stays null instead of becoming the zero time.

refuse names the fields that must not appear at all — the Immutable ones, on a create, which the form does not render. A create is the one door where a command's field is otherwise writable, so a value arriving for one did not come from the form this function serves, and it is refused with a field error rather than dropped: dropping it would store something other than what was sent and say nothing. The JSON create keeps its documented behaviour, which is that Immutable is about a patch; this is the form's own promise.

func Writable

func Writable(values map[string]any, immutable []string) map[string]any

Writable drops the fields a route of its own owns. The update route refuses them with a 422 naming the field, which is right for an API and wrong for a form that rendered them read-only and then posted them back — a browser posts a read-only control's value, and the person changed nothing.

Types

type CommandOptions

type CommandOptions struct {
	// Auth is who may run this command. The zero value is the Spec's own write
	// permission, which is right for a command that moves a row an
	// administrator owns and wrong for one somebody runs on their own thing:
	// adding an item to a basket is httpx.SignedIn(), and the alternative was a
	// second permission every tenant would have to grant to every shopper.
	Auth httpx.Auth

	// Collection mounts the command on the collection rather than on a row —
	// POST {Path}/{verb} — and run is handed uuid.Nil.
	//
	// The id is the whole difference, and it was a second exported function
	// until the review counted the lines: twenty-eight of its thirty-two were
	// this one's. A command about a row somebody names — resolve this task —
	// carries the id; a command about the collection, where the row is what the
	// command finds or produces, cannot. Redeeming a code is the example: the
	// caller knows the code and not the row it belongs to, so {id} in the path
	// would be asking them for the answer.
	Collection bool
}

CommandOptions is what a command may differ from its Spec in. It is a struct and not two more parameters because a command that differs in nothing has to be able to say so in one word: rest.CommandOptions{}.

type Item

type Item[T any] struct {
	Body T
}

Item is one entity as a response body, and Page is a page of them.

They are exported because a module that writes its own handlers answers in the same shape as the five routes here — modules/audit and modules/notification both do, an append-only trail and a per-recipient list being things a Spec is not — and three separately declared response shapes are three spellings of the same JSON for a client to discover the hard way.

type Page

type Page[T any] struct {
	Body struct {
		Items  []T   `json:"items"`
		Total  int64 `json:"total"`
		Limit  int   `json:"limit"`
		Offset int   `json:"offset"`
	}
}

Page is a page of rows and the total it came from. The limit and the offset are echoed, so a caller that sent neither knows what it got.

type Singleton

type Singleton[T crud.Entity] struct {
	// Module and Entity name it, as they do on a Spec: the operation ids, the
	// tag and the generated screen's path come from them.
	Module, Entity string
	// Path is the resource: "/api/v1/site/settings". There is no item path.
	Path string
	// Read guards the read; Write guards the PUT. An empty Write mounts no PUT
	// at all, which is the shape of a singleton whose changes are commands —
	// billing's subscription is moved by subscribe and cancel, and a PUT would
	// be a caller writing its own period.
	Read, Write string
	// Public mounts GET {Path}/public, unauthenticated. Face is what it
	// answers with, and it is required when Public is set: what a visitor may
	// see is a smaller thing than what an administrator configured, and a
	// public route that served the whole row would be an admin screen anybody
	// could read.
	Public bool
	Face   func(T) any
	// Event is what the PUT declares it publishes. Save is what publishes it;
	// this is the declaration kit/app's boot gate reads.
	Event string

	// Load is the tenant's row. Save writes it and returns what was stored.
	Load func(ctx context.Context, tx db.Tx[db.Tenant]) (T, error)
	Save func(ctx context.Context, tx db.Tx[db.Tenant], in T) (T, error)
}

Singleton is one row per tenant projected onto HTTP: a read, an optional write, and an optional public face.

It is not Spec with the list taken out. A singleton has no id in its path, so there is no create — a tenant has one whether or not anybody has saved it — and no delete: what a tenant would delete is a row that comes back the moment anything reads it. What is left is GET and PUT, and the two are enough for a settings screen.

Load and Save are the module's, because the one thing a singleton cannot be generic about is what "there is none yet" means. A site that has been configured with nothing still has settings, and answers with the defaults; a tenant that has never subscribed has no subscription, and answers 404. Both are right, and no flag would say which.

func (Singleton[T]) Mount

func (s Singleton[T]) Mount(api *httpx.API)

Mount registers the two routes, the public one when there is one, and the resource the admin generator reads.

type Spec

type Spec[T crud.Entity] struct {
	// Module is the manifest's name. It prefixes the events, so the events a
	// Spec publishes are namespaced by the module that mounts it.
	Module string
	// Entity is this resource's name, lower-case: "task". It is the middle of
	// the event name and the noun in the operation ids.
	Entity string
	// Path is the collection's path: "/api/tasks". The item is Path + "/{id}".
	Path string
	// Read guards the list and the read; Write guards create, update and
	// delete. Both are permissions some module has to define, or the app
	// refuses to start.
	Read, Write string
	// OperatorWrite declares Write with httpx.OperatorPermission rather than
	// httpx.Permission: the rows are the installation's and every tenant reads
	// them, but only the operator's own tenant writes them.
	//
	// It exists because the alternative is a module hand-writing five routes to
	// change one declaration, and one of them did. A price list is the case: it
	// is read by every tenant, so it is not a control-plane resource in the way
	// the tenant registry is, and it is written by the operator alone, because
	// a customer that could add a plan could price itself. The manifest has to
	// declare the same permission Operator: true, or kit/app refuses to start.
	OperatorWrite bool
	// SoftDelete keeps deleted rows, hidden, instead of removing them.
	SoftDelete bool
	// Immutable names, by json field name, the fields a PATCH refuses because
	// a route of their own owns them: a task's assigneeId belongs to Assign,
	// which also moves the status and publishes task.assigned, so a caller who
	// could set it through the generic update would move the field alone and
	// tell nobody, and a content author belongs to the create that stamped it.
	// The refusal is a 422 naming the field, so the caller is told which door
	// to use rather than left wondering why nothing happened.
	//
	// This is not ReadOnly. ReadOnly is the four fields Base contributes — the
	// server owns those at every door, and the create route discards whatever
	// a caller sent for them. An immutable field is writable, by exactly one
	// route. Every name here is checked against the entity's schema at mount,
	// so a misspelled one panics where it is written instead of silently
	// guarding nothing.
	Immutable []string

	// HookEvents names the events the hooks below publish. They are appended
	// to the create, update and delete operations' x-platformkit-events, so
	// the OpenAPI document says what a write can emit and kit/app's boot gate
	// sees it.
	//
	// It closes one direction and it is worth being plain about which. The
	// gate compares what the routes declare against what the manifests
	// declare; nothing reads what a handler actually publishes. So an event
	// named here and missing from a manifest fails startup, and an event a
	// hook publishes that nobody named here is still invisible to everything
	// but the outbox.
	HookEvents []string

	// The hooks run inside the request's transaction, after the write and
	// after the event, so a hook can publish more events or write more rows and
	// all of it commits together.
	//
	// There is no AfterUpdate, and the absence is a decision rather than an
	// omission: nothing in three repositories ever set one, and a hook nobody
	// writes is a parameter every reader of this struct has to rule out. A
	// module that needs one adds it back in the commit that uses it.
	AfterCreate func(ctx context.Context, tx db.Tx[db.Tenant], e T) error
	AfterDelete func(ctx context.Context, tx db.Tx[db.Tenant], e T) error
}

Spec is one entity's presence in the application: five routes, two permissions, three events and a schema. A module writes one of these and mounts it; everything below is the same for every entity, which is why it is written once.

func (Spec[T]) Event

func (s Spec[T]) Event(verb string) string

Event is the full name of one of this Spec's events, "<module>.<entity>.<verb>". A module lists these in its manifest's Events, and kit/app refuses to start when a Spec would publish one that nothing declared.

func (Spec[T]) Events

func (s Spec[T]) Events() []string

Events are the three names this Spec publishes, so a manifest can name them without spelling them.

func (Spec[T]) Mount

func (s Spec[T]) Mount(api *httpx.API)

Mount registers the five routes. Each one declares its permission, obtains the request's transaction from kit/httpx, and answers with kit/problem: 404 for a row this tenant does not have, 422 for an entity that fails its own Validate or a query naming a field that does not exist, 409 for a unique constraint.

func (Spec[T]) Schema

func (s Spec[T]) Schema() crud.Schema

Schema describes the entity to anything that did not compile against it: the generated screens of stage E4, and the list operation's own documentation.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL