docs(modules): rewrite cabana, wire, towel, festival, compass, backpack READMEs
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 `<prefix>/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 `<prefix>/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 <code>`, `--superuser` | Creates an activated backend administrator. |
|
||||
| `admin:reset-password` | `<identifier>` (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 '<secret>' --superuser
|
||||
./bin/acme admin:reset-password admin@example.com --password '<secret>'
|
||||
```
|
||||
|
||||
## 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/`.
|
||||
|
||||
@@ -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 `<plugin id>.<key>`, any other `<name>.yaml` becomes `<plugin id>.<name>.<key>`.
|
||||
2. `<dir>/*.yaml` (and `*.yml`): each file is a section named after the file, so `config/app.yaml` provides `app.*`. Files load in sorted order.
|
||||
3. `<dir>/env/<environment>/*.yaml`: per-environment sections with the same naming.
|
||||
4. `SUMMER_` environment variables, including values from a `.env` file (see Configuration).
|
||||
5. `<dir>/env/<environment>/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/<name>/`. An explicit `compass.Options.Env` wins over it. |
|
||||
| `SUMMER_<SECTION>__<KEY>` | 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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user