Documentation
¶
Overview ¶
Package contracts is everything another module, an app or a test may know about content: the entity, the events, the permissions and the Service interface. The implementation is in ../internal.
Content is a page or a post: a slug, a title, a body in Markdown, and a status that decides whether the public site serves it. There are no versions, no categories and no translations. Versions in particular are a module of their own — a history table, a diff, a restore and a retention rule — and the private catalogue has one; what a reference architecture owes is the lifecycle, which is draft, published, archived.
Index ¶
Constants ¶
const ( KindPage = "page" KindPost = "post" )
The two kinds. A page is addressed by its slug and stands on its own; a post is dated and belongs in a list. Nothing else distinguishes them, which is why this is a field and not two entities.
const ( StatusDraft = "draft" StatusPublished = "published" StatusArchived = "archived" )
The lifecycle: a draft is being written, a published one is served to anybody, an archived one is kept and served to nobody.
const ( MaxSlug = 200 MaxTitle = 200 MaxBody = 262144 )
The bounds. MaxSlug and MaxTitle are the columns' widths; MaxBody is a quarter of a megabyte, which is roughly forty thousand words — longer than anything anybody writes in one page and short enough that the public route can render it on every anonymous request without becoming the way to take the site down. The review measured 2.25 seconds for one 8.5 MB body.
const ( EventCreated = "content.content.created" EventUpdated = "content.content.updated" EventDeleted = "content.content.deleted" EventPublished = "content.published" EventUnpublished = "content.unpublished" EventArchived = "content.archived" )
The six events this module emits: kit/rest's three, published by the Spec module.go mounts, and the lifecycle's three, published by the commands. Both sets are in the manifest, and kit/app refuses to start if a route would publish one that is not.
const ( PermissionContentRead = "content:read" PermissionContentManage = "content:manage" )
The two permissions content has. They are named after the resource and not after the module, because a permission outlives the module that first defined it, and publishing is not a permission of its own: whoever may change what a page says may decide that it says it in public.
The list the manifest declares is in ../module.go, which keeps kit/module out of this package's build graph.
Variables ¶
var Events = []string{EventCreated, EventUpdated, EventDeleted, EventPublished, EventUnpublished, EventArchived}
Events is every event this module emits, for the manifest.
Functions ¶
func Slugify ¶
Slugify is the one definition of a slug: lower case, words joined by single dashes, nothing outside a-z, 0-9 and the dash. It exists as a function because the write path and the public read path have to agree — a slug normalised on the way in and looked up verbatim is a page nobody can reach.
Types ¶
type Content ¶
type Content struct {
crud.Base
// Slug is the name this content is reached by, unique within the tenant. It
// is normalised on every write, so two callers cannot disagree about
// whether "About Us" and "about-us" are the same page.
Slug string `` /* 155-byte string literal not displayed */
// Title is what a list and a heading show.
Title string `` /* 130-byte string literal not displayed */
// Body is Markdown. It is stored as it was written and rendered on read, so
// a change to what the renderer allows applies to everything ever written
// rather than to whatever happens to be saved next.
Body string `` /* 146-byte string literal not displayed */
// Kind and Status are closed sets; the enum tag is what a form renders as a
// select and what Validate refuses a value outside.
Kind string `` /* 146-byte string literal not displayed */
Status string `` /* 168-byte string literal not displayed */
// PublishedAt is when it was published, and nil whenever it is not
// published: the two are one fact, and Validate keeps them together.
PublishedAt *time.Time `json:"publishedAt,omitempty" gorm:"type:timestamptz" ui:"widget:datetime" doc:"When this was published" readOnly:"true"`
// AuthorID is whoever created it. Validate stamps it from the caller on the
// context, so it is the actor of the request that wrote the row and not
// something a body can claim; the Spec names it Immutable, so no patch
// rewrites a byline.
AuthorID uuid.UUID `` /* 134-byte string literal not displayed */
}
Content is one page or post in one tenant.
The struct is the whole surface: the json tags are the API, the gorm tags are the table, the enum and validate tags are the schema a generated screen reads, and crud.Base contributes the id, the timestamps, the soft delete and the tenant column row-level security matches on.
func (*Content) Validate ¶
Validate is the entity's own check, run by kit/crud on every write whichever door it came through. It normalises as well as refuses, and it stamps the author: the context is the one thing that knows whose request this is, which is the same reason kit/events reads the actor off it.
type Moved ¶
type Moved struct {
ContentID uuid.UUID `json:"contentId"`
Slug string `json:"slug"`
Kind string `json:"kind"`
Status string `json:"status"`
At time.Time `json:"at"`
}
Moved is the payload of all three lifecycle events: which content, what it is now, and when it moved. One struct rather than three, because the three events differ in what happened and not in what a subscriber needs to know — a cache invalidating a slug reads the same fields whichever it hears.
type Service ¶
type Service interface {
// Publish serves it to anybody, and records when. Publishing what is
// already published changes nothing — the publication time does not move,
// because a page has one — and archived content is refused: it is taken out
// of the archive first, which is what Unpublish does.
Publish(ctx context.Context, tx db.Tx[db.Tenant], id uuid.UUID) (*Content, error)
// Unpublish takes it back to a draft, from published or from archived, and
// clears the publication time with it. A draft again changes nothing.
Unpublish(ctx context.Context, tx db.Tx[db.Tenant], id uuid.UUID) (*Content, error)
// Archive keeps it and serves it to nobody. Archiving twice changes
// nothing.
Archive(ctx context.Context, tx db.Tx[db.Tenant], id uuid.UUID) (*Content, error)
// Public is the published content at this slug, which is what the public
// site serves. Anything else — a draft, an archived page, a slug nobody has
// used — is ErrNotFound, because from outside, content that is not served
// and content that does not exist are the same fact.
Public(ctx context.Context, tx db.Tx[db.Tenant], slug string) (*Content, error)
}
Service is the content lifecycle: the three transitions generic CRUD cannot safely infer, and the one read the public site makes.
Every command takes the caller's transaction rather than opening one, so the state change and its event commit together. The errors are kit/crud's: ErrNotFound for content this tenant does not have, ErrConflict for a state that refuses the command. Each command is idempotent: a browser that retries, or a redelivered event, must not produce a second announcement.
Directories
¶
| Path | Synopsis |
|---|---|
|
Package contenttest is the conformance suite for contracts.Service, and a fake that passes it.
|
Package contenttest is the conformance suite for contracts.Service, and a fake that passes it. |