diff --git a/modules/backpack/README.md b/modules/backpack/README.md index 1fbf3d2..28033a8 100644 --- a/modules/backpack/README.md +++ b/modules/backpack/README.md @@ -1,3 +1,88 @@ # backpack -`backpack` provides the application container that holds configuration, services, events, and activated plugin IDs. Framework boot code and Fonoteka plugin assembly import it; start with `backpack.App` and `backpack.New` in `app.go`. +Per-instance application container that holds the configuration, a typed service registry, the event bus and the set of activated plugins. + +`import "git.golem15.com/golem15/summercms/modules/backpack"` + +## Overview + +`backpack` plays the role of the Laravel service container that WinterCMS plugins reach through `App::make` and singleton bindings, without any process-global state: every `backpack.App` is independent, so tests and multiple instances in one process do not interfere. The generated `main` of an application loads configuration with [compass](../compass/README.md), creates the container with `backpack.New`, and hands it to [party](../party/README.md), which passes it to every plugin's Register and Boot. `backpack` deliberately does not import `party`, which keeps the dependency graph acyclic. + +## Features + +- `backpack.New` wires a container around a loaded `*compass.Config`: `backpack.App.Config`, a fresh service registry in `backpack.App.Services` and a new [festival](../festival/README.md) bus in `backpack.App.Events`. +- Typed services keyed by the type argument: `backpack.App.Publish` stores a value under its type T and `backpack.App.Lookup` returns it. Publishing the same T twice or publishing nil is an error, so two plugins cannot silently replace each other's service. Framework modules share their infrastructure this way (for example the database handles published by [lagoon](../lagoon/README.md), or the translator from [phrasebook](../phrasebook/README.md)). +- Plugin presence checks: `backpack.App.SetPlugins` records the complete activated set before any plugin boots, and `backpack.App.HasPlugin` answers whether an optional integration partner is part of this build (the equivalent of WinterCMS's `PluginManager::exists`). +- `backpack.Registry` can be used on its own through `backpack.NewRegistry`; it is safe for concurrent use. +- Nil-safe methods: calls on a nil container or registry return an error or a zero value instead of panicking. + +## Usage + +```go +package blog + +import ( + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/compass" +) + +// Feed is a service the blog plugin offers to other plugins. +type Feed interface { + Latest(n int) []string +} + +type staticFeed struct{} + +func (staticFeed) Latest(n int) []string { return []string{"hello-world"} } + +func setup() error { + cfg, err := compass.Load("config") + if err != nil { + return err + } + app := backpack.New(cfg) + app.SetPlugins([]string{"acme.blog", "acme.search"}) + + // Provider side, usually in the plugin's Register step. + var feed Feed = staticFeed{} + if err := app.Publish(feed); err != nil { // published under Feed, not staticFeed + return err + } + + // Consumer side, usually in another plugin's Boot step. + if app.HasPlugin("acme.blog") { + if found, ok := app.Lookup[Feed](); ok { + _ = found.Latest(5) + } + } + return nil +} +``` + +Publish under an interface type when consumers should not depend on the concrete implementation; the lookup must use exactly the same type argument. + +## API reference + +| Identifier | Description | +|------------|-------------| +| `backpack.App` | The application container: configuration, service registry, event bus and the activated plugin set. | +| `backpack.New` | Creates a `backpack.App` around a loaded configuration, with an empty registry and a new event bus. | +| `backpack.App.Publish` / `backpack.App.Lookup` | Store and retrieve an app-scoped service by type. | +| `backpack.App.SetPlugins` / `backpack.App.HasPlugin` | Record the activated plugin IDs and check whether one is present. | +| `backpack.Registry` | Concurrency-safe typed service catalog behind `backpack.App.Services`. | +| `backpack.NewRegistry` | Returns an empty registry. | +| `backpack.Registry.Publish` / `backpack.Registry.Lookup` | Registry-level publish and lookup behind the `backpack.App` methods. | + +## Dependencies + +- SummerCMS modules: [compass](../compass/README.md), [festival](../festival/README.md). +- Third-party: none. +- Standard library: `fmt`, `reflect`, `sync`. + +## Testing + +```sh +go test ./modules/backpack/... +``` + +The tests build containers in memory and need no external services. diff --git a/modules/cabana/README.md b/modules/cabana/README.md index ccac4f9..5ff39c1 100644 --- a/modules/cabana/README.md +++ b/modules/cabana/README.md @@ -1,3 +1,158 @@ # cabana -`cabana` implements the schema-driven admin API: authentication, controller schemas, CRUD, relations, settings, and OpenAPI DTOs. Fonoteka's admin controllers and framework routing import it; start the assembled surface with `cabana.Activate` in `http.go`. +Schema-driven admin backend that compiles WinterCMS-style YAML list, form, filter and relation definitions at boot and serves them as a JSON admin API next to the embedded admin SPA. + +`import "git.golem15.com/golem15/summercms/modules/cabana"` + +## Overview + +`cabana` is the SummerCMS counterpart of WinterCMS's backend: controllers with the List, Form and Relation behaviors, their `config_list.yaml`, `config_form.yaml`, `config_filter.yaml` and `config_relation.yaml` files, the model `columns.yaml` and `fields.yaml`, backend users, roles and permissions, settings models and backend navigation. Plugins declare admin controllers through the [pact](../pact/README.md) capability interfaces and embed their YAML; `cabana.Activate` compiles all of it once at boot, fails fast on any schema error, and returns the admin routes that [surf](../surf/README.md) mounts under the admin prefix (`backend.uri`, default `/backend`). The JSON API lives under `/api/v1`, and every other path under the prefix serves the admin SPA from [boardwalk](../boardwalk/README.md). + +## Features + +- Boot-time schema compilation: `cabana.CompileList` and `cabana.CompileForm` read a controller's YAML from the plugin's embedded tree, check that `modelClass` matches the controller's model name, and cache a locale-neutral schema. Each request gets a translated copy (`cabana.ListSchema.Localize`, `cabana.FormSchema.Localize`, `cabana.RelationSchema.Localize`) through [phrasebook](../phrasebook/README.md), with CLDR plural forms for the SPA's messages. +- Generic CRUD with `cabana.CRUDService`: list, show, create, update, delete and bulk delete. Writes run in transactions, and reads and writes are scoped by the controller's `pact.ListExtendQuery` and `pact.FormExtendQuery` hooks. `cabana.ExecuteList` applies search, sort, filters and pagination only on columns declared in the schema, so request parameters never reach SQL directly. +- Mass-assignment protection: writable form fields are bound to model columns at activation (`cabana.BindWritableFields`), and `cabana.ProjectWritableFields` drops unknown keys, case variants, nested objects and protected columns from request bodies. Values are filled and validated through [lagoon](../lagoon/README.md), and the form lifecycle hooks declared in `pact` (before and after create, update and delete) run around each write. +- Relations: `type: relation` form fields for belongsTo and belongsToMany (`cabana.FieldRelationProvider`, `cabana.FieldRelationContract`) with a paginated options endpoint and display labels in every record response; relation managers (`cabana.AdminRelationContractProvider`, `cabana.RelationContract`) served by `cabana.RelationService` for listing linked records and candidates and for linking and unlinking. Framework code never guesses table, pivot or foreign-key names: the controller supplies them. +- Singleton settings screens declared with `pact.HasSettings`, read and saved by `cabana.SettingsService`. +- Backend navigation (`pact.HasNavigation`) and permissions (`pact.HasPermissions`), filtered per user by `cabana.Registry.Metadata`. `cabana.Allows` implements the permission check: superusers pass, and grants ending in `.*` match by prefix. +- Admin authentication against WinterCMS's `backend_users` and `backend_user_roles` tables (`cabana.BackendUser`, `cabana.BackendUserRole`, `cabana.BackendUsers`): a JWT guard registered in [bouncer](../bouncer/README.md) as `backend`, login throttling, token refresh and revocation, and two transports. API clients use a Bearer token; the SPA sends `X-Requested-With: XMLHttpRequest` and receives the token in the HttpOnly, SameSite=Strict cookie named by `cabana.AdminCookieName`. Cookie-authenticated requests that change state must carry that header, which blocks cross-site request forgery. +- A consistent JSON envelope for every response: `cabana.WriteData`, `cabana.WriteError` and `cabana.WriteErrorDetails`, typed for documentation as `cabana.Envelope`, `cabana.ListEnvelope`, `cabana.RecordEnvelope` and `cabana.ErrorEnvelope`. +- OpenAPI documentation: `cabana.AdminList`, `cabana.AdminCreate` and the other `Admin*` functions have empty bodies and exist only to carry the swag annotations of each admin route. +- Operator commands for creating administrators and resetting their passwords (see CLI commands). + +### Admin API routes + +All paths are relative to `/api/v1`. A controller ID `vendor.plugin.controller` maps to the path `/{vendor}/{plugin}/{controller}`. + +| Method and path | Purpose | +|-----------------|---------| +| POST `/auth/login`, POST `/auth/refresh` | Sign in (throttled) and refresh a token. Public. | +| GET `/lang` | The `backend::lang` string bundle for the request locale. Public, so the login screen can load it. | +| POST `/auth/logout`, GET `/auth/me` | Revoke the current token; return the signed-in administrator. | +| GET `/navigation`, GET `/settings` | Navigation and settings entries the administrator may open. | +| GET `/settings/{code}/schema`, GET and PUT `/settings/{code}` | Settings form schema, values and update. | +| GET `/{vendor}/{plugin}/{controller}/schema/list`, `.../schema/form`, `.../schema/relation/{name}` | Localized list, form and relation schemas. | +| GET and POST `/{vendor}/{plugin}/{controller}` | List records; create a record. | +| GET, PUT and DELETE `/{vendor}/{plugin}/{controller}/{id}` | Show, update and delete a record. | +| POST `/{vendor}/{plugin}/{controller}/bulk-delete` | Delete a set of records in one transaction. | +| GET `.../fields/{field}/options`, GET `.../filters/{scope}/options` | Choices for a relation field and for a model-backed list filter. | +| GET `.../{id}/relations/{name}`, GET `.../{id}/relations/{name}/candidates` | Linked records and link candidates of a relation manager. | +| POST `.../{id}/relations/{name}/link`, POST `.../{id}/relations/{name}/unlink` | Link and unlink related records. | + +Every path under the prefix that no API route matches is served by the admin SPA; unmatched API paths return the `not_found` error envelope instead. + +## Usage + +A plugin exposes an admin controller and embeds its YAML. `summer make:admin-controller` scaffolds the controller type and its four YAML files: + +```go +package blog + +import ( + "embed" + "io/fs" + + "git.golem15.com/golem15/summercms/modules/pact" +) + +// adminFS holds controllers/post/config_list.yaml, controllers/post/config_form.yaml, +// models/post/columns.yaml and models/post/fields.yaml. +// +//go:embed controllers models +var adminFS embed.FS + +type Post struct { + ID uint `gorm:"primaryKey"` + Title string `gorm:"column:title"` +} + +func (Post) TableName() string { return "acme_blog_posts" } + +type postAdmin struct{} + +func (postAdmin) ID() string { return "acme.blog.post" } +func (postAdmin) ModelName() string { return "Post" } // must equal modelClass in the YAML +func (postAdmin) ConfigDir() string { return "controllers/post" } +func (postAdmin) NewRecord() any { return &Post{} } // pact.AdminRecordSource + +// Plugin also implements party.Plugin (ID, Requires, Register, Boot). +type Plugin struct{} + +func (p *Plugin) AdminControllers() []pact.AdminController { + return []pact.AdminController{postAdmin{}} +} + +func (p *Plugin) AdminFS() fs.FS { return adminFS } +``` + +`config_list.yaml` and `config_form.yaml` reference the model files with WinterCMS paths such as `~/plugins/acme/blog/models/post/columns.yaml`. At boot, `surf.BuildRouter` calls `cabana.Activate` with the activated plugins and mounts the returned `cabana.Routes`; when no plugin registers an admin controller, `cabana.Activate` returns nil and no admin routes exist. Record and user lookups use the `*gorm.DB` that [lagoon](../lagoon/README.md) publishes on the `backpack.App`. + +## API reference + +| Identifier | Description | +|------------|-------------| +| `cabana.Activate` | Compiles every plugin's admin controllers, settings, navigation and permissions and returns the admin `cabana.Routes`, or nil when there are no controllers. | +| `cabana.Routes` | Guard middleware, mount function and normalized prefix of the admin API and SPA. | +| `cabana.AdminPrefix` | Reads and validates `backend.uri`; `cabana.DefaultAdminPrefix` is the fallback. | +| `cabana.RuntimeCommands` | Returns the `admin:create` and `admin:reset-password` commands. | +| `cabana.CompileList` / `cabana.CompileForm` | Compile a controller's list and form YAML into cached schemas. | +| `cabana.ListSchema` / `cabana.FormSchema` / `cabana.RelationSchema` | Locale-neutral compiled schemas; each request works on a localized copy. | +| `cabana.CompiledController` | One controller after compilation: list, form, relations and writable fields. | +| `cabana.Registry` | Immutable map of compiled controllers and settings, with permission-filtered metadata. | +| `cabana.CRUDService` | Schema-projected show, create, update, delete, bulk delete and relation options. | +| `cabana.ExecuteList` | Runs an allowlisted, paginated list query for a controller. | +| `cabana.RelationService` | Linked, candidate, link and unlink operations of relation managers. | +| `cabana.SettingsService` | Reads and transactionally updates singleton settings rows. | +| `cabana.FieldRelationProvider` / `cabana.FieldRelationContract` | Controller-supplied bindings for `type: relation` form fields. | +| `cabana.AdminRelationContractProvider` / `cabana.RelationContract` | Controller-supplied bindings for relation managers. | +| `cabana.BackendUser` / `cabana.BackendUserRole` / `cabana.BackendUsers` | GORM models of the backend user tables and the principal loader used by the guard. | +| `cabana.Allows` | Checks a principal against required permission codes. | +| `cabana.WriteData` / `cabana.WriteError` / `cabana.WriteErrorDetails` | Write the admin success and error envelopes. | +| `cabana.ValidationError` / `cabana.ListValidationError` | Field-level `validation_failed` errors. | + +## Configuration + +| Key | Default | Effect | +|-----|---------|--------| +| `admin.jwt.secret` | none | HMAC secret for admin tokens. Required as soon as any plugin registers an admin controller; boot fails without it. Set it through `SUMMER_ADMIN__JWT__SECRET` rather than a committed file. | +| `admin.jwt.ttl` | `60` | Access token lifetime in minutes. | +| `admin.jwt.refresh_ttl` | `20160` | Refresh window in minutes (14 days); also the lifetime of the admin cookie. | +| `admin.jwt.blacklist_grace` | `0` | Seconds a token stays valid after it has been refreshed, for requests already in flight. | +| `admin.password.bcrypt_cost` | `10` | Bcrypt cost for administrator passwords; values outside 4 to 31 fall back to 10. | +| `admin.login.max_attempts` | `5` | Login attempts allowed per throttle window. | +| `admin.login.decay_minutes` | `1` | Length of the login throttle window in minutes. | +| `backend.uri` | `/backend` | Admin mount path. One or more lowercase path segments; boot fails on an invalid value. | +| `backend.cookie_secure` | `true` | Set `false` to drop the cookie's Secure attribute for plain-HTTP development. Refused in the `production` environment. | +| `app.url` | empty | Base URL used for the token issuer. | + +The backend user, role and token blacklist tables (`backend_users`, `backend_user_roles`, `backend_jwt_blacklist`) are created by `lagoon.BackendAdminMigrations`, which the `migrate` command runs. + +## CLI commands + +Both commands are added to every application binary by the generated `main` and open the database themselves when the application has not. + +| Command | Arguments and flags | Effect | +|---------|---------------------|--------| +| `admin:create` | `--email`, `--password` (both required), `--login` (defaults to the lower-cased email), `--role `, `--superuser` | Creates an activated backend administrator. | +| `admin:reset-password` | `` (login or email), `--password` | Sets a new password and revokes every token issued before the reset. | + +```sh +./bin/acme admin:create --email admin@example.com --password '' --superuser +./bin/acme admin:reset-password admin@example.com --password '' +``` + +## Dependencies + +- SummerCMS modules: [backpack](../backpack/README.md), [boardwalk](../boardwalk/README.md), [bonfire](../bonfire/README.md), [bouncer](../bouncer/README.md), [lagoon](../lagoon/README.md), [pact](../pact/README.md), [party](../party/README.md), [phrasebook](../phrasebook/README.md), [towel](../towel/README.md). +- Third-party: `gorm.io/gorm` (with `gorm.io/gorm/clause`), `github.com/goccy/go-yaml` (with its `ast` package). +- Standard library: `bytes`, `context`, `database/sql`, `encoding/json`, `errors`, `fmt`, `io`, `io/fs`, `log/slog`, `math`, `net`, `net/http`, `path`, `reflect`, `regexp`, `sort`, `strconv`, `strings`, `time`. +- Tests additionally use `github.com/testcontainers/testcontainers-go` and its `modules/postgres` package. + +## Testing + +```sh +go test ./modules/cabana/... +``` + +The database-backed tests start a PostgreSQL container through testcontainers-go and need Docker. Run `go test -short ./modules/cabana/...` to skip them. YAML fixtures for the schema compiler live in `testdata/`. diff --git a/modules/compass/README.md b/modules/compass/README.md index a0e9432..50ea836 100644 --- a/modules/compass/README.md +++ b/modules/compass/README.md @@ -1,3 +1,112 @@ # compass -`compass` loads layered YAML and environment configuration, including plugin configuration files and runtime values. The framework runtime and Fonoteka application boot import it; create a configuration tree with `compass.Open` in `config.go`. +Layered YAML configuration with per-environment directories, `SUMMER_` environment overrides, embedded plugin defaults and dot-path access. + +`import "git.golem15.com/golem15/summercms/modules/compass"` + +## Overview + +`compass` is the SummerCMS counterpart of WinterCMS's `config/*.php` files, per-environment config directories, `.env` support and `Config::get('app.name')`. It merges several layers into one tree built on koanf, and every key is read with a dot path such as `app.name`. Plugin defaults live under the bare plugin ID (`acme.blog.posts_per_page`) instead of WinterCMS's `acme.blog::posts_per_page`. The generated `main` of an application calls `compass.Load("config")` and hands the result to [backpack](../backpack/README.md); [party](../party/README.md) merges plugin defaults into it during activation. + +## Features + +- A fixed layer order, lowest to highest precedence: + 1. Plugin defaults added with `compass.Config.MergePlugin`: a plugin's `config.yaml` becomes `.`, any other `.yaml` becomes `..`. + 2. `/*.yaml` (and `*.yml`): each file is a section named after the file, so `config/app.yaml` provides `app.*`. Files load in sorted order. + 3. `/env//*.yaml`: per-environment sections with the same naming. + 4. `SUMMER_` environment variables, including values from a `.env` file (see Configuration). + 5. `/env//overrides.yaml`, the file written by `compass.Config.Persist`. + 6. In-memory values stored with `compass.Config.Set`. +- Typed getters with zero-value defaults: `compass.Config.String`, `compass.Config.Int`, `compass.Config.Bool`, plus `compass.Config.Lookup` and `compass.Config.Has` to tell a missing key from a zero value. +- `compass.Config.LoadSection` unmarshals a whole subtree into a struct using `koanf` struct tags. +- Runtime overrides: `compass.Config.Set` changes a value in memory, `compass.Config.Persist` saves all runtime values atomically to the environment's `overrides.yaml` (directory mode 0700, file mode 0600, refusing any path outside the config directory), and `compass.Config.Reload` rereads every source and discards unsaved runtime values. +- `compass.Config.Environment` reports the active environment name, which must consist of letters, digits, `-` and `_`. +- Safe for concurrent reads and writes. + +## Usage + +```go +package blog + +import ( + "git.golem15.com/golem15/summercms/modules/compass" +) + +type mailSettings struct { + Host string `koanf:"host"` + Port int `koanf:"port"` +} + +func loadConfig() (*compass.Config, error) { + cfg, err := compass.Open(compass.Options{Dir: "config", Env: "development"}) + if err != nil { + return nil, err + } + + name := cfg.String("app.name") + perPage := cfg.Int("acme.blog.posts_per_page") + _, _ = name, perPage + + var mail mailSettings + if err := cfg.LoadSection("mail", &mail); err != nil { + return nil, err + } + + // Store a runtime override and write it to config/env/development/overrides.yaml. + if err := cfg.Set("acme.blog.posts_per_page", 25); err != nil { + return nil, err + } + return cfg, cfg.Persist() +} +``` + +A matching configuration directory: + +```yaml +# config/app.yaml +name: Acme +url: http://localhost:8080 + +# config/env/development/app.yaml +debug: true +``` + +## API reference + +| Identifier | Description | +|------------|-------------| +| `compass.Config` | The merged configuration tree with dot-path access. | +| `compass.Load` | Opens a config directory, taking the environment from `SUMMER_ENV`. | +| `compass.Open` | Opens configuration with explicit `compass.Options`. | +| `compass.Options` | Config directory, environment name and the environment variable list to read (defaults to the process environment). | +| `compass.Config.String` / `compass.Config.Int` / `compass.Config.Bool` | Typed getters that return the zero value for a missing key. | +| `compass.Config.Lookup` / `compass.Config.Has` | Raw value lookup and existence check. | +| `compass.Config.LoadSection` | Unmarshals a subtree into a struct with `koanf` tags. | +| `compass.Config.MergePlugin` | Adds a plugin's embedded default configuration under its plugin ID. | +| `compass.Config.Set` / `compass.Config.Persist` / `compass.Config.Reload` | Runtime overrides, saving them to disk, and rebuilding from disk. | +| `compass.Config.Environment` | Returns the active environment name. | + +## Configuration + +`compass` reads the process environment (or `compass.Options.Environ` when it is set): + +| Variable | Default | Effect | +|----------|---------|--------| +| `SUMMER_ENV` | `production` | Selects the environment directory `config/env//`. An explicit `compass.Options.Env` wins over it. | +| `SUMMER_
__` | none | Overrides a config key: the prefix is removed, `__` separates path segments and the name is lower-cased, so `SUMMER_DATABASE__DSN` sets `database.dsn` and `SUMMER_ADMIN__JWT__SECRET` sets `admin.jwt.secret`. | + +A `.env` file in the parent directory of the config directory (next to `config/`) supplies `KEY=VALUE` lines, with optional `export` prefixes and quotes, for variables that are not already set in the real environment. It never modifies the process environment. + +## Dependencies + +- SummerCMS modules: none. +- Third-party: `github.com/knadh/koanf/v2` with its `providers/file`, `providers/env/v2`, `providers/confmap` and `parsers/yaml` packages. +- Standard library: `fmt`, `io/fs`, `os`, `path/filepath`, `sort`, `strings`, `sync`, `unicode`. + +## Testing + +```sh +go test ./modules/compass/... +``` + +The tests use temporary directories and explicit environment lists and need no external services. diff --git a/modules/festival/README.md b/modules/festival/README.md index 13ba19c..a1ed8d7 100644 --- a/modules/festival/README.md +++ b/modules/festival/README.md @@ -1,3 +1,84 @@ # festival -`festival` is the framework event bus, supporting collection and handler registration for application events. `backpack` and example plugins import it during boot; create a bus with `festival.New` in `bus.go`. +Typed, synchronous event bus with listener priorities, payload collection and stop-when-handled dispatch. + +`import "git.golem15.com/golem15/summercms/modules/festival"` + +## Overview + +`festival` is the SummerCMS counterpart of WinterCMS's `Event::listen` and `Event::fire`, the mechanism plugins use to extend each other without direct calls. Events are routed by their Go type rather than by a string name, so a listener registered for `*PostPublished` receives exactly that type, and a payload mismatch is a compile error. Each application owns one bus: [backpack](../backpack/README.md) creates it in `backpack.New` and exposes it as `backpack.App.Events`, and plugins register listeners from their Boot step. + +## Features + +- Listener registration with `festival.Bus.Listen` (priority 0) and `festival.Bus.ListenPriority`. Higher priorities run first; listeners with equal priority run in registration order. Every listener carries the ID of the plugin that owns it. +- Three dispatch modes, all synchronous on the caller's goroutine: + - `festival.Bus.Fire` runs every listener and returns the joined errors of all that failed (`errors.Join`). + - `festival.Bus.Collect` runs every listener and, after each one, merges the event's `festival.Collectable.Collected` map into a single payload (later keys win). It returns the payload gathered so far together with the joined errors. + - `festival.Bus.UntilHandled` stops at the first error or as soon as the event's `festival.Handleable.IsHandled` reports true, and returns whether the event was handled (WinterCMS's halting fire). +- Panic isolation: a panicking listener is recovered and reported as an error that names its owner plugin, so one faulty plugin cannot take down the dispatch. +- Safe for concurrent use: registration is locked and dispatch works on a snapshot of the listener list. +- Value and pointer types are distinct event types; events that listeners modify (for `Collect` and `UntilHandled`) are usually pointers. + +## Usage + +```go +package blog + +import ( + "context" + + "git.golem15.com/golem15/summercms/modules/festival" +) + +// PostPublished is fired after a post goes live. Listeners add payload +// entries and may mark the event handled. +type PostPublished struct { + PostID uint + payload map[string]any + handled bool +} + +func (e *PostPublished) Collected() map[string]any { return e.payload } +func (e *PostPublished) IsHandled() bool { return e.handled } + +func publish(ctx context.Context, bus *festival.Bus) (map[string]any, error) { + bus.ListenPriority("acme.search", 10, func(ctx context.Context, e *PostPublished) error { + if e.payload == nil { + e.payload = map[string]any{} + } + e.payload["indexed"] = true + return nil + }) + return bus.Collect(ctx, &PostPublished{PostID: 42}) +} +``` + +The event type is inferred from the listener's parameter, so this listener only receives `*PostPublished` events. In a plugin, the bus is `app.Events` on the `backpack.App` passed to Boot; `festival.New` is for tests and standalone use. + +## API reference + +| Identifier | Description | +|------------|-------------| +| `festival.Bus` | Application-owned, type-keyed event dispatcher. | +| `festival.New` | Returns an empty bus. | +| `festival.Bus.Listen` | Registers a listener for event type T at priority 0. | +| `festival.Bus.ListenPriority` | Registers a listener for event type T at an explicit priority. | +| `festival.Bus.Fire` | Runs every listener and joins their errors. | +| `festival.Bus.Collect` | Runs every listener and merges the event's collected payload. | +| `festival.Bus.UntilHandled` | Runs listeners until one handles the event or fails. | +| `festival.Collectable` | Implemented by events that expose a mergeable payload for `Collect`. | +| `festival.Handleable` | Implemented by events that can stop `UntilHandled`. | + +## Dependencies + +- SummerCMS modules: none. +- Third-party: none. +- Standard library: `context`, `errors`, `fmt`, `reflect`, `sort`, `sync`. + +## Testing + +```sh +go test ./modules/festival/... +``` + +The tests use in-process listeners and need no external services. diff --git a/modules/towel/README.md b/modules/towel/README.md index c831b13..f6589a7 100644 --- a/modules/towel/README.md +++ b/modules/towel/README.md @@ -1,3 +1,70 @@ # towel -`towel` carries request-scoped actor, organization, collection, and locale values through `context.Context`. Admin schema and controller code import it while processing scoped requests; set an actor with `towel.WithActor` in `context.go`. +Request-scoped actor, organization, collection and locale values carried through `context.Context`. + +`import "git.golem15.com/golem15/summercms/modules/towel"` + +## Overview + +`towel` replaces the request-global state that WinterCMS reads through facades (the current locale, the acting user) with explicit values on the request context. Middleware stores a value once, and any code further down the call chain that receives the context reads it back without a global lookup. [surf](../surf/README.md) sets the locale from `Accept-Language` (or the signed-in user's preferred locale) and the organization for every request, [phrasebook](../phrasebook/README.md) reads the locale when it translates, and [cabana](../cabana/README.md) sets it while localizing admin schemas. + +## Features + +- Four independent string values, each with a setter and a getter: actor (`towel.WithActor`, `towel.Actor`), organization (`towel.WithOrganization`, `towel.Organization`), collection (`towel.WithCollection`, `towel.Collection`) and locale (`towel.WithLocale`, `towel.Locale`). +- Unexported context key types, so no other package can read or overwrite the values by accident. +- Getters return `(value, ok)`: a value that was set to an empty string is distinguishable from one that was never set. +- Nil-safe: a setter given a nil context starts from `context.Background()`, and a getter given a nil context reports `false`. + +## Usage + +```go +package blog + +import ( + "fmt" + "net/http" + + "git.golem15.com/golem15/summercms/modules/towel" +) + +// withAcmeScope tags every request with the organization and collection it serves. +func withAcmeScope(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := towel.WithOrganization(r.Context(), "acme") + ctx = towel.WithCollection(ctx, "blog") + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +func listPosts(w http.ResponseWriter, r *http.Request) { + org, _ := towel.Organization(r.Context()) + locale, ok := towel.Locale(r.Context()) + if !ok { + locale = "en" + } + fmt.Fprintf(w, "posts for %s in %s\n", org, locale) +} +``` + +## API reference + +| Identifier | Description | +|------------|-------------| +| `towel.WithActor` / `towel.Actor` | Store and read the acting user or client identifier. | +| `towel.WithOrganization` / `towel.Organization` | Store and read the organization (tenant) the request belongs to. | +| `towel.WithCollection` / `towel.Collection` | Store and read the collection the request operates on. | +| `towel.WithLocale` / `towel.Locale` | Store and read the locale used for translations and localized responses. | + +## Dependencies + +- SummerCMS modules: none. +- Third-party: none. +- Standard library: `context`. + +## Testing + +```sh +go test ./modules/towel/... +``` + +The tests exercise plain contexts and need no external services. diff --git a/modules/wire/README.md b/modules/wire/README.md index bd319e8..93af17d 100644 --- a/modules/wire/README.md +++ b/modules/wire/README.md @@ -1,3 +1,74 @@ # wire -`wire` writes JSON response envelopes and normalizes optional slices for stable API serialization. `surf` and Fonoteka API controllers import it for HTTP responses; use `wire.WriteJSON` from `response.go`. +JSON response helpers and value types that keep API bodies byte-compatible with a PHP (WinterCMS/Laravel) backend. + +`import "git.golem15.com/golem15/summercms/modules/wire"` + +## Overview + +`wire` is the lowest layer of the HTTP stack: it decides how a Go value becomes response bytes. Its helpers reproduce what `json_encode` and Carbon produce in a WinterCMS or Laravel app (unescaped HTML characters, no trailing newline, `+00:00` timestamps, nullable booleans, `[]` rather than `null` for empty lists), so an endpoint ported from PHP returns the same body its existing clients already parse. [surf](../surf/README.md) uses it for the opaque 500 response of its panic recovery, and application handlers use it directly for their JSON responses. + +## Features + +- `wire.WriteJSON` encodes a value with HTML escaping disabled and without the trailing newline `encoding/json` adds, then sets `Content-Type: application/json` and the status code. If encoding fails, it writes the opaque 500 body instead of a partial response. +- `wire.WriteOpaque500` writes a 500 response with the fixed body `{"error":true,"message":"Internal server error"}`, which reveals nothing about the failure. +- `wire.Time` wraps `time.Time` and always marshals in UTC as `2006-01-02T15:04:05+00:00` (Carbon's form, never Go's `Z`). It unmarshals a timestamp with a numeric offset or an RFC 3339 `Z` timestamp, and turns `null` into the zero time. +- `wire.TriBool` models a nullable boolean: when `wire.TriBool.Valid` is false it marshals as `null`, otherwise as `wire.TriBool.Value`. +- `wire.Slice` returns a non-nil empty slice for a nil input, so optional lists marshal as `[]` instead of `null`. + +## Usage + +```go +package blog + +import ( + "net/http" + "time" + + "git.golem15.com/golem15/summercms/modules/wire" +) + +type postJSON struct { + ID uint `json:"id"` + Title string `json:"title"` + Tags []string `json:"tags"` + Featured wire.TriBool `json:"featured"` + PublishedAt wire.Time `json:"published_at"` +} + +func showPost(w http.ResponseWriter, r *http.Request) { + var tags []string // nil when the post has no tags + body := postJSON{ + ID: 1, + Title: "Hello & welcome", // "&" stays unescaped + Tags: wire.Slice(tags), // marshals as [] + Featured: wire.TriBool{}, // marshals as null + PublishedAt: wire.Time{Time: time.Now()}, // "...+00:00" + } + wire.WriteJSON(w, http.StatusOK, map[string]any{"data": body}) +} +``` + +## API reference + +| Identifier | Description | +|------------|-------------| +| `wire.WriteJSON` | Writes a JSON body with HTML escaping off and no trailing newline; falls back to the opaque 500 on an encoding error. | +| `wire.WriteOpaque500` | Writes the fixed `{"error":true,"message":"Internal server error"}` 500 response. | +| `wire.Time` | `time.Time` wrapper that marshals as UTC `+00:00` and reads both `+00:00` and `Z` forms. | +| `wire.TriBool` | Nullable boolean: `wire.TriBool.Valid` false marshals `null`, otherwise `wire.TriBool.Value`. | +| `wire.Slice` | Generic helper that turns a nil slice into an empty one so it marshals as `[]`. | + +## Dependencies + +- SummerCMS modules: none. +- Third-party: none. +- Standard library: `bytes`, `encoding/json`, `net/http`, `time`. + +## Testing + +```sh +go test ./modules/wire/... +``` + +The tests use `net/http/httptest` and need no external services.