feat(11.1-04): add the Backend section, the remaining Services pages and the concept map links
- docs/backend: admin controllers, forms, lists and filters, relation manager, users and permissions, settings, partials and widgets, admin SPA - docs/services: storage, outbound HTTP, realtime, Web Push, search, parity testing and the Frontend and AJAX (not provided) page - Examples for cabana (with testdata/docs YAML), fetchguard, lighthouse and its centrifugo driver, flare, beachcomber and typesense, tide; lighthouse and beachcomber TestDocs* regions run on their Postgres harnesses - concept map rows link their guide pages and the not-provided rows the Frontend and AJAX page; index lists Backend, Database and Services - TestDocsRequiredPages asserts the D-08 section order
This commit is contained in:
258
modules/beachcomber/example_test.go
Normal file
258
modules/beachcomber/example_test.go
Normal file
@@ -0,0 +1,258 @@
|
||||
package beachcomber_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/beachcomber"
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Post is the acme.blog post model. It implements beachcomber.Searchable
|
||||
// without importing beachcomber.
|
||||
type Post struct {
|
||||
ID uint `gorm:"column:id;primaryKey"`
|
||||
BlogID uint `gorm:"column:blog_id"`
|
||||
Title string `gorm:"column:title"`
|
||||
Published bool `gorm:"column:published"`
|
||||
}
|
||||
|
||||
func (Post) TableName() string { return "acme_blog_posts" }
|
||||
|
||||
// SearchableAs is the index name, before search.prefix.
|
||||
func (Post) SearchableAs() string { return "acme_blog_posts" }
|
||||
|
||||
// ShouldBeSearchable keeps drafts out of the index.
|
||||
func (p *Post) ShouldBeSearchable() bool { return p.Published }
|
||||
|
||||
// ToSearchableArray builds the document from the committed row.
|
||||
func (p *Post) ToSearchableArray(ctx context.Context, db *gorm.DB) (map[string]any, error) {
|
||||
return map[string]any{
|
||||
"id": strconv.FormatUint(uint64(p.ID), 10),
|
||||
"blog_id": int64(p.BlogID),
|
||||
"title": p.Title,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// memoryEngine is a toy engine: it matches Q against the title and ignores
|
||||
// FilterBy, so its answers are loose candidates, as a stale index's are.
|
||||
type memoryEngine struct {
|
||||
mu sync.Mutex
|
||||
docs map[string]map[string]map[string]any // index -> id -> document
|
||||
}
|
||||
|
||||
func (e *memoryEngine) Name() string { return "acme-memory" }
|
||||
func (e *memoryEngine) Configured() bool { return true }
|
||||
|
||||
func (e *memoryEngine) Upsert(ctx context.Context, index string, schema map[string]any, docs []map[string]any) error {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
if e.docs[index] == nil {
|
||||
e.docs[index] = map[string]map[string]any{}
|
||||
}
|
||||
for _, d := range docs {
|
||||
e.docs[index][fmt.Sprint(d["id"])] = d
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *memoryEngine) Delete(ctx context.Context, index string, ids []string) error {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
for _, id := range ids {
|
||||
delete(e.docs[index], id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *memoryEngine) Flush(ctx context.Context, index string) error {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
delete(e.docs, index)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *memoryEngine) SearchIDs(ctx context.Context, index string, q beachcomber.Query) ([]string, error) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
ids := []string{}
|
||||
for id, d := range e.docs[index] {
|
||||
if strings.Contains(strings.ToLower(fmt.Sprint(d["title"])), strings.ToLower(q.Q)) {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
slices.Sort(ids)
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
var memory = &memoryEngine{docs: map[string]map[string]map[string]any{}}
|
||||
|
||||
func ExampleFrom() {
|
||||
cfg, err := compass.Open(compass.Options{Dir: "config", Env: "development", Environ: []string{}})
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
_ = cfg.Set("search.prefix", "staging_")
|
||||
svc, err := beachcomber.From(backpack.New(cfg)) // search.driver defaults to null
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
fmt.Println(svc.Engine().Name(), svc.Engine().Configured(), svc.IndexName(&Post{}))
|
||||
ids, err := svc.Engine().SearchIDs(context.Background(), svc.IndexName(&Post{}), beachcomber.Query{Q: "go"})
|
||||
fmt.Println(ids, err)
|
||||
|
||||
_ = cfg.Set("search.driver", "elastic")
|
||||
_, err = beachcomber.From(backpack.New(cfg))
|
||||
fmt.Println(err != nil)
|
||||
// Output:
|
||||
// null false staging_acme_blog_posts
|
||||
// [] <nil>
|
||||
// true
|
||||
}
|
||||
|
||||
// installGate turns search sync on only while the blog's search setting is
|
||||
// on; a failed read counts as off.
|
||||
func installGate(svc *beachcomber.Service) {
|
||||
// docs:start gate
|
||||
svc.SetGate(beachcomber.GateFunc(func(ctx context.Context, db *gorm.DB) bool {
|
||||
var enabled bool
|
||||
err := db.WithContext(ctx).Raw(`SELECT search_enabled FROM acme_blog_settings WHERE id = 1`).Scan(&enabled).Error
|
||||
return err == nil && enabled
|
||||
}))
|
||||
// docs:end gate
|
||||
}
|
||||
|
||||
// searchPosts answers a search in one blog.
|
||||
func searchPosts(ctx context.Context, svc *beachcomber.Service, db *gorm.DB, blogID uint, term string) ([]Post, error) {
|
||||
// docs:start search
|
||||
ids, err := svc.Engine().SearchIDs(ctx, svc.IndexName(&Post{}), beachcomber.Query{
|
||||
Q: term,
|
||||
QueryBy: []string{"title"},
|
||||
FilterBy: "blog_id:=" + strconv.FormatUint(uint64(blogID), 10),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// The ids are candidates from an index that may be stale or loosely
|
||||
// filtered: re-check every one in SQL before exposing a row.
|
||||
var posts []Post
|
||||
err = db.WithContext(ctx).
|
||||
Where("id IN ? AND blog_id = ? AND published", ids, blogID).
|
||||
Order("id").
|
||||
Find(&posts).Error
|
||||
return posts, err
|
||||
// docs:end search
|
||||
}
|
||||
|
||||
// TestDocsSearch runs the gate and search regions of the Search page on the
|
||||
// package's Postgres harness.
|
||||
func TestDocsSearch(t *testing.T) {
|
||||
app, db := beachcomber.DocsApp(t, nil)
|
||||
for _, stmt := range []string{
|
||||
`CREATE TABLE acme_blog_posts (id SERIAL PRIMARY KEY, blog_id INTEGER NOT NULL, title TEXT NOT NULL, published BOOLEAN NOT NULL DEFAULT FALSE)`,
|
||||
`CREATE TABLE acme_blog_settings (id INTEGER PRIMARY KEY, search_enabled BOOLEAN NOT NULL)`,
|
||||
`INSERT INTO acme_blog_settings VALUES (1, TRUE)`,
|
||||
} {
|
||||
if err := db.Exec(stmt).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
svc, err := beachcomber.From(app)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// An application selects a registered engine with search.driver; the
|
||||
// test installs the toy engine directly, so the global engine list the
|
||||
// package's own tests check stays unchanged.
|
||||
beachcomber.DocsUseEngine(svc, memory)
|
||||
installGate(svc)
|
||||
ctx := t.Context()
|
||||
indexed := func() int {
|
||||
memory.mu.Lock()
|
||||
defer memory.mu.Unlock()
|
||||
return len(memory.docs[svc.IndexName(&Post{})])
|
||||
}
|
||||
|
||||
// A committed write is indexed after commit; a rolled-back one never is.
|
||||
create := func(p *Post, fail bool) error {
|
||||
return lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error {
|
||||
if err := tx.Create(p).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if fail {
|
||||
return fmt.Errorf("rolled back")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
hello := &Post{BlogID: 7, Title: "Hello Go", Published: true}
|
||||
if err := create(hello, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := create(&Post{BlogID: 7, Title: "Go rolled back", Published: true}, true); err == nil {
|
||||
t.Fatal("rolled-back create succeeded")
|
||||
}
|
||||
if err := create(&Post{BlogID: 8, Title: "Go elsewhere", Published: true}, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := indexed(); n != 2 {
|
||||
t.Fatalf("indexed %d documents, want 2", n)
|
||||
}
|
||||
|
||||
// The engine answers both blogs; SQL keeps blog 7's post only.
|
||||
posts, err := searchPosts(ctx, svc, db, 7, "go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(posts) != 1 || posts[0].ID != hello.ID {
|
||||
t.Fatalf("search = %+v", posts)
|
||||
}
|
||||
|
||||
// A write in a plain GORM transaction is not synced, so the index keeps
|
||||
// the stale document; the SQL re-check still hides the draft.
|
||||
if err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
return tx.Model(hello).Update("published", false).Error
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := indexed(); n != 2 {
|
||||
t.Fatalf("indexed %d documents after an unsynced write, want 2", n)
|
||||
}
|
||||
if posts, err = searchPosts(ctx, svc, db, 7, "go"); err != nil || len(posts) != 0 {
|
||||
t.Fatalf("search after unpublish = %+v, %v", posts, err)
|
||||
}
|
||||
|
||||
// With the gate off nothing is sent.
|
||||
if err := db.Exec(`UPDATE acme_blog_settings SET search_enabled = FALSE`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := create(&Post{BlogID: 7, Title: "Go quietly", Published: true}, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := indexed(); n != 2 {
|
||||
t.Fatalf("indexed %d documents with the gate off, want 2", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDocsDeclarations checks the Searchable methods the Search page shows.
|
||||
func TestDocsDeclarations(t *testing.T) {
|
||||
var _ beachcomber.Searchable = &Post{}
|
||||
p := &Post{ID: 5, BlogID: 7, Title: "Hello", Published: true}
|
||||
if p.SearchableAs() != "acme_blog_posts" || !p.ShouldBeSearchable() {
|
||||
t.Fatalf("SearchableAs %q, ShouldBeSearchable %v", p.SearchableAs(), p.ShouldBeSearchable())
|
||||
}
|
||||
doc, err := p.ToSearchableArray(t.Context(), nil)
|
||||
if err != nil || doc["id"] != "5" || doc["blog_id"] != int64(7) {
|
||||
t.Fatalf("ToSearchableArray = %v, %v", doc, err)
|
||||
}
|
||||
}
|
||||
47
modules/beachcomber/export_docs_test.go
Normal file
47
modules/beachcomber/export_docs_test.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package beachcomber
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// DocsApp returns an app with the given config settings on a fresh migrated
|
||||
// database of this package's Postgres harness, with the database published,
|
||||
// for the database-backed docs examples in example_test.go. It skips under
|
||||
// -short and fails when the harness has no database, like the package's
|
||||
// other database tests.
|
||||
func DocsApp(t *testing.T, settings map[string]any) (*backpack.App, *gorm.DB) {
|
||||
t.Helper()
|
||||
db, dsn := migratedDB(t)
|
||||
cfg, err := compass.Open(compass.Options{Dir: t.TempDir(), Env: "testing", Environ: []string{}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cfg.Set("database.dsn", dsn); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for k, v := range settings {
|
||||
if err := cfg.Set(k, v); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
app := backpack.New(cfg)
|
||||
gdb, err := lagoon.Use(t.Context(), db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := lagoon.Publish(app, db, gdb); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return app, gdb
|
||||
}
|
||||
|
||||
// DocsUseEngine replaces the engine of svc, for docs examples whose toy
|
||||
// engine must not join the global engine registry.
|
||||
func DocsUseEngine(svc *Service, e Engine) {
|
||||
svc.engine = e
|
||||
}
|
||||
49
modules/beachcomber/typesense/example_test.go
Normal file
49
modules/beachcomber/typesense/example_test.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package typesense_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/beachcomber"
|
||||
"git.golem15.com/golem15/summercms/modules/beachcomber/typesense"
|
||||
)
|
||||
|
||||
func ExampleEngine_SearchIDs() {
|
||||
// A stand-in Typesense node.
|
||||
node := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
fmt.Println(r.Method, r.URL.Path, "key sent:", r.Header.Get("X-TYPESENSE-API-KEY") != "")
|
||||
fmt.Println("q", q.Get("q"), "query_by", q.Get("query_by"), "filter_by", q.Get("filter_by"))
|
||||
fmt.Fprint(w, `{"hits":[{"document":{"id":"3"}},{"document":{"id":"1"}}]}`)
|
||||
}))
|
||||
defer node.Close()
|
||||
u, _ := url.Parse(node.URL)
|
||||
port, _ := strconv.Atoi(u.Port())
|
||||
|
||||
engine := typesense.New(typesense.Config{
|
||||
APIKey: "test-only-key", // SUMMER_SEARCH__TYPESENSE__API_KEY
|
||||
Host: u.Hostname(),
|
||||
Port: port,
|
||||
Protocol: "http",
|
||||
ConnectionTimeout: 2 * time.Second,
|
||||
})
|
||||
ids, err := engine.SearchIDs(context.Background(), "acme_blog_posts", beachcomber.Query{
|
||||
Q: "go",
|
||||
QueryBy: []string{"title"},
|
||||
FilterBy: "blog_id:=7",
|
||||
})
|
||||
fmt.Println(ids, err)
|
||||
|
||||
// Without an API key the engine is not configured and nothing is sent.
|
||||
fmt.Println(typesense.New(typesense.Config{}).Configured())
|
||||
// Output:
|
||||
// GET /collections/acme_blog_posts/documents/search key sent: true
|
||||
// q go query_by title filter_by blog_id:=7
|
||||
// [3 1] <nil>
|
||||
// false
|
||||
}
|
||||
133
modules/cabana/example_controller_test.go
Normal file
133
modules/cabana/example_controller_test.go
Normal file
@@ -0,0 +1,133 @@
|
||||
package cabana_test
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
)
|
||||
|
||||
// Post is the model behind the acme.blog posts controller.
|
||||
type Post struct {
|
||||
ID uint `gorm:"column:id;primaryKey"`
|
||||
Title string `gorm:"column:title"`
|
||||
Slug string `gorm:"column:slug"`
|
||||
Status string `gorm:"column:status"`
|
||||
Published bool `gorm:"column:published"`
|
||||
PublishedAt *time.Time `gorm:"column:published_at"`
|
||||
Content string `gorm:"column:content"`
|
||||
}
|
||||
|
||||
func (Post) TableName() string { return "acme_blog_posts" }
|
||||
|
||||
// Fillable lists the columns the admin form may write.
|
||||
func (Post) Fillable() []string {
|
||||
return []string{"title", "slug", "status", "published", "content"}
|
||||
}
|
||||
|
||||
// PostsController is the admin controller for posts: the Go form of a
|
||||
// WinterCMS controller with the List and Form behaviours.
|
||||
type PostsController struct{}
|
||||
|
||||
var (
|
||||
_ pact.AdminController = PostsController{}
|
||||
_ pact.AdminRecordSource = PostsController{}
|
||||
_ pact.AdminPermissioned = PostsController{}
|
||||
_ pact.HasAdminControllers = (*BlogPlugin)(nil)
|
||||
)
|
||||
|
||||
// ID is the controller ID; its admin API path is /acme/blog/posts.
|
||||
func (PostsController) ID() string { return "acme.blog.posts" }
|
||||
|
||||
// ModelName must equal modelClass in the YAML.
|
||||
func (PostsController) ModelName() string { return "Post" }
|
||||
|
||||
// ConfigDir holds config_list.yaml, config_form.yaml and config_filter.yaml.
|
||||
func (PostsController) ConfigDir() string { return "controllers/posts" }
|
||||
|
||||
// NewRecord returns the model the generic admin handlers query.
|
||||
func (PostsController) NewRecord() any { return &Post{} }
|
||||
|
||||
// RequiredPermissions are checked before any schema or query.
|
||||
func (PostsController) RequiredPermissions() []string {
|
||||
return []string{"acme.blog.access_posts"}
|
||||
}
|
||||
|
||||
// BlogSettings is the singleton row behind the plugin's settings page.
|
||||
type BlogSettings struct {
|
||||
ID uint `gorm:"column:id;primaryKey"`
|
||||
PostsPerPage int `gorm:"column:posts_per_page"`
|
||||
CommentsEnabled bool `gorm:"column:comments_enabled"`
|
||||
}
|
||||
|
||||
func (BlogSettings) TableName() string { return "acme_blog_settings" }
|
||||
|
||||
func (BlogSettings) Fillable() []string { return []string{"posts_per_page", "comments_enabled"} }
|
||||
|
||||
// Rules validates a settings save, as a WinterCMS settings model's $rules.
|
||||
func (BlogSettings) Rules() map[string]string {
|
||||
return map[string]string{"posts_per_page": "required|integer|between:1,100"}
|
||||
}
|
||||
|
||||
// BlogPlugin is the acme.blog plugin; only its backend surface is shown.
|
||||
type BlogPlugin struct{}
|
||||
|
||||
var (
|
||||
_ pact.AdminAssets = (*BlogPlugin)(nil)
|
||||
_ pact.HasPermissions = (*BlogPlugin)(nil)
|
||||
_ pact.HasNavigation = (*BlogPlugin)(nil)
|
||||
_ pact.HasSettings = (*BlogPlugin)(nil)
|
||||
)
|
||||
|
||||
func (p *BlogPlugin) ID() string { return "acme.blog" }
|
||||
func (p *BlogPlugin) Requires() []string { return nil }
|
||||
func (p *BlogPlugin) Register(app *backpack.App) error { return nil }
|
||||
func (p *BlogPlugin) Boot(app *backpack.App) error { return nil }
|
||||
|
||||
// AdminControllers registers the plugin's admin controllers.
|
||||
func (p *BlogPlugin) AdminControllers() []pact.AdminController {
|
||||
return []pact.AdminController{PostsController{}}
|
||||
}
|
||||
|
||||
// AdminFS is the plugin's embedded controllers/ and models/ tree. A real
|
||||
// plugin returns an embed.FS; the example reads the same files from testdata.
|
||||
func (p *BlogPlugin) AdminFS() fs.FS { return os.DirFS("testdata/docs") }
|
||||
|
||||
// Permissions replaces registerPermissions().
|
||||
func (p *BlogPlugin) Permissions() []pact.Permission {
|
||||
return []pact.Permission{
|
||||
{Code: "acme.blog.access_posts", Tab: "acme.blog::lang.plugin.name", Label: "acme.blog::lang.permissions.posts"},
|
||||
{Code: "acme.blog.access_settings", Tab: "acme.blog::lang.plugin.name", Label: "acme.blog::lang.permissions.settings"},
|
||||
}
|
||||
}
|
||||
|
||||
// Navigation replaces registerNavigation().
|
||||
func (p *BlogPlugin) Navigation() []pact.NavigationItem {
|
||||
return []pact.NavigationItem{{
|
||||
Code: "blog",
|
||||
Label: "acme.blog::lang.plugin.name",
|
||||
Icon: "icon-pencil",
|
||||
Permissions: []string{"acme.blog.access_posts"},
|
||||
Controller: "acme.blog.posts",
|
||||
SideMenu: []pact.NavigationItem{
|
||||
{Code: "posts", Label: "acme.blog::lang.posts.title", Controller: "acme.blog.posts"},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
// Settings replaces registerSettings().
|
||||
func (p *BlogPlugin) Settings() []pact.SettingsItem {
|
||||
return []pact.SettingsItem{{
|
||||
Code: "blog",
|
||||
Label: "acme.blog::lang.settings.label",
|
||||
Description: "acme.blog::lang.settings.description",
|
||||
Category: "acme.blog::lang.plugin.name",
|
||||
Icon: "icon-pencil",
|
||||
Model: "BlogSettings",
|
||||
Permissions: []string{"acme.blog.access_settings"},
|
||||
Form: "models/settings/fields.yaml",
|
||||
NewModel: func() any { return &BlogSettings{} },
|
||||
}}
|
||||
}
|
||||
103
modules/cabana/example_test.go
Normal file
103
modules/cabana/example_test.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package cabana_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
)
|
||||
|
||||
func ExampleCompileList() {
|
||||
// The plugin embeds its controllers/ and models/ trees; the example reads
|
||||
// the same files from testdata.
|
||||
fsys := os.DirFS("testdata/docs")
|
||||
list, err := cabana.CompileList("acme.blog", PostsController{}, fsys)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
fmt.Println(list.Title, list.RecordsPerPage, list.PerPageOptions, list.ToolbarButtons)
|
||||
for _, c := range list.Columns {
|
||||
fmt.Printf("column %s type=%q searchable=%v sortable=%v\n", c.Key, c.Type, c.Searchable, c.Sortable)
|
||||
}
|
||||
for _, f := range list.Filters {
|
||||
fmt.Printf("filter %s type=%s column=%s\n", f.Name, f.Type, f.Column)
|
||||
}
|
||||
// Output:
|
||||
// acme.blog::lang.posts.title 20 [20 50 100] [create delete]
|
||||
// column title type="" searchable=true sortable=true
|
||||
// column published type="switch" searchable=false sortable=true
|
||||
// column published_at type="datetime" searchable=false sortable=true
|
||||
// filter published type=switch column=published
|
||||
// filter published_at type=daterange column=published_at
|
||||
}
|
||||
|
||||
func ExampleCompileForm() {
|
||||
fsys := os.DirFS("testdata/docs")
|
||||
form, err := cabana.CompileForm("acme.blog", PostsController{}, fsys)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
for _, f := range form.Fields {
|
||||
fmt.Printf("%s %s span=%q tab=%q required=%v options=%d\n", f.Name, f.Type, f.Span, f.Tab, f.Required, len(f.Options))
|
||||
}
|
||||
// Output:
|
||||
// title text span="left" tab="" required=true options=0
|
||||
// slug text span="right" tab="" required=false options=0
|
||||
// status dropdown span="left" tab="" required=false options=2
|
||||
// published switch span="right" tab="" required=false options=0
|
||||
// content textarea span="" tab="acme.blog::lang.posts.tab_content" required=false options=0
|
||||
}
|
||||
|
||||
func ExampleActivate() {
|
||||
cfg, err := compass.Open(compass.Options{Dir: "config", Env: "development", Environ: []string{}})
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
// Set through SUMMER_ADMIN__JWT__SECRET in a real deployment.
|
||||
_ = cfg.Set("admin.jwt.secret", "test-only-secret-with-at-least-32-bytes")
|
||||
_ = cfg.Set("backend.uri", "/admin")
|
||||
|
||||
routes, err := cabana.Activate(backpack.New(cfg), []party.Plugin{&BlogPlugin{}})
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
fmt.Println(routes.Prefix)
|
||||
|
||||
editor := &bouncer.Principal{ID: 7, Backend: true, PermissionGrants: map[string]bool{"acme.blog.*": true}}
|
||||
fmt.Println(cabana.Allows(editor, PostsController{}.RequiredPermissions()))
|
||||
fmt.Println(cabana.Allows(editor, []string{"acme.shop.access_orders"}))
|
||||
// Output:
|
||||
// /admin
|
||||
// true
|
||||
// false
|
||||
}
|
||||
|
||||
// TestDocsDeclarations checks the plugin declarations the Backend pages show.
|
||||
func TestDocsDeclarations(t *testing.T) {
|
||||
p := &BlogPlugin{}
|
||||
if n := len(p.Permissions()); n != 2 {
|
||||
t.Fatalf("Permissions() = %d entries", n)
|
||||
}
|
||||
if nav := p.Navigation(); len(nav) != 1 || nav[0].Controller != "acme.blog.posts" {
|
||||
t.Fatalf("Navigation() = %+v", nav)
|
||||
}
|
||||
settings := p.Settings()
|
||||
if len(settings) != 1 {
|
||||
t.Fatalf("Settings() = %+v", settings)
|
||||
}
|
||||
if _, ok := settings[0].NewModel().(*BlogSettings); !ok {
|
||||
t.Fatal("settings model is not *BlogSettings")
|
||||
}
|
||||
if rule := (BlogSettings{}).Rules()["posts_per_page"]; rule == "" {
|
||||
t.Fatal("BlogSettings has no posts_per_page rule")
|
||||
}
|
||||
}
|
||||
9
modules/cabana/testdata/docs/controllers/posts/config_filter.yaml
vendored
Normal file
9
modules/cabana/testdata/docs/controllers/posts/config_filter.yaml
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
scopes:
|
||||
published:
|
||||
label: acme.blog::lang.posts.published
|
||||
type: switch
|
||||
column: published
|
||||
published_at:
|
||||
label: acme.blog::lang.posts.published_at
|
||||
type: daterange
|
||||
column: published_at
|
||||
10
modules/cabana/testdata/docs/controllers/posts/config_form.yaml
vendored
Normal file
10
modules/cabana/testdata/docs/controllers/posts/config_form.yaml
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
name: acme.blog::lang.posts.form
|
||||
form: ~/plugins/acme/blog/models/post/fields.yaml
|
||||
modelClass: Post
|
||||
defaultRedirect: acme/blog/posts
|
||||
create:
|
||||
redirect: acme/blog/posts/update/:id
|
||||
redirectClose: acme/blog/posts
|
||||
update:
|
||||
redirect: acme/blog/posts
|
||||
redirectClose: acme/blog/posts
|
||||
15
modules/cabana/testdata/docs/controllers/posts/config_list.yaml
vendored
Normal file
15
modules/cabana/testdata/docs/controllers/posts/config_list.yaml
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
list: ~/plugins/acme/blog/models/post/columns.yaml
|
||||
modelClass: Post
|
||||
title: acme.blog::lang.posts.title
|
||||
recordUrl: acme/blog/posts/update/:id
|
||||
recordsPerPage: 20
|
||||
perPageOptions: [20, 50, 100]
|
||||
showCheckboxes: true
|
||||
defaultSort:
|
||||
column: published_at
|
||||
direction: desc
|
||||
filter: config_filter.yaml
|
||||
toolbar:
|
||||
buttons: [create, delete]
|
||||
search:
|
||||
prompt: backend::lang.list.search_prompt
|
||||
10
modules/cabana/testdata/docs/models/post/columns.yaml
vendored
Normal file
10
modules/cabana/testdata/docs/models/post/columns.yaml
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
columns:
|
||||
title:
|
||||
label: acme.blog::lang.posts.title_column
|
||||
searchable: true
|
||||
published:
|
||||
label: acme.blog::lang.posts.published
|
||||
type: switch
|
||||
published_at:
|
||||
label: acme.blog::lang.posts.published_at
|
||||
type: datetime
|
||||
28
modules/cabana/testdata/docs/models/post/fields.yaml
vendored
Normal file
28
modules/cabana/testdata/docs/models/post/fields.yaml
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
fields:
|
||||
title:
|
||||
label: acme.blog::lang.posts.title_column
|
||||
type: text
|
||||
span: left
|
||||
required: true
|
||||
slug:
|
||||
label: acme.blog::lang.posts.slug
|
||||
type: text
|
||||
span: right
|
||||
context: update
|
||||
comment: acme.blog::lang.posts.slug_comment
|
||||
status:
|
||||
label: acme.blog::lang.posts.status
|
||||
type: dropdown
|
||||
span: left
|
||||
options:
|
||||
draft: acme.blog::lang.posts.draft
|
||||
published: acme.blog::lang.posts.published
|
||||
published:
|
||||
label: acme.blog::lang.posts.published
|
||||
type: switch
|
||||
span: right
|
||||
content:
|
||||
label: acme.blog::lang.posts.content
|
||||
type: textarea
|
||||
size: large
|
||||
tab: acme.blog::lang.posts.tab_content
|
||||
10
modules/cabana/testdata/docs/models/settings/fields.yaml
vendored
Normal file
10
modules/cabana/testdata/docs/models/settings/fields.yaml
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
fields:
|
||||
posts_per_page:
|
||||
label: acme.blog::lang.settings.posts_per_page
|
||||
type: number
|
||||
span: left
|
||||
default: 10
|
||||
comments_enabled:
|
||||
label: acme.blog::lang.settings.comments_enabled
|
||||
type: switch
|
||||
span: right
|
||||
52
modules/fetchguard/example_test.go
Normal file
52
modules/fetchguard/example_test.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package fetchguard_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/fetchguard"
|
||||
)
|
||||
|
||||
func ExampleFetch() {
|
||||
ctx := context.Background()
|
||||
// Only the application's image host, at most 5 MiB within 5 seconds.
|
||||
images := fetchguard.Policy{
|
||||
Mode: fetchguard.AllowHostsMode,
|
||||
AllowHosts: []string{"images.example.com"},
|
||||
MaxBytes: 5 << 20,
|
||||
Timeout: 5 * time.Second,
|
||||
}
|
||||
// Any public host, for a URL a user pasted.
|
||||
public := fetchguard.Policy{Mode: fetchguard.PublicOnlyMode}
|
||||
|
||||
for _, c := range []struct {
|
||||
url string
|
||||
policy fetchguard.Policy
|
||||
}{
|
||||
{"http://images.example.com/cover.jpg", images},
|
||||
{"https://cdn.attacker.example/cover.jpg", images},
|
||||
{"https://127.0.0.1/admin", public},
|
||||
{"https://169.254.169.254/latest/meta-data/", public},
|
||||
{"https://[::ffff:10.0.0.1]/", public},
|
||||
{"https://%zz", public},
|
||||
} {
|
||||
// The last argument is the application's config (app.Config), for
|
||||
// limits the policy leaves at zero; nil uses the framework defaults.
|
||||
_, err := fetchguard.Fetch(ctx, c.url, c.policy, nil)
|
||||
var fe *fetchguard.Error
|
||||
if errors.As(err, &fe) {
|
||||
fmt.Println(fe.Reason, c.url)
|
||||
}
|
||||
}
|
||||
fmt.Println(fetchguard.Defaults())
|
||||
// Output:
|
||||
// scheme http://images.example.com/cover.jpg
|
||||
// invalid_url https://cdn.attacker.example/cover.jpg
|
||||
// private_ip https://127.0.0.1/admin
|
||||
// private_ip https://169.254.169.254/latest/meta-data/
|
||||
// private_ip https://[::ffff:10.0.0.1]/
|
||||
// invalid_url https://%zz
|
||||
// 10485760 10s
|
||||
}
|
||||
112
modules/flare/example_test.go
Normal file
112
modules/flare/example_test.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package flare_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdh"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/flare"
|
||||
)
|
||||
|
||||
// browserSubscription stands in for the PushSubscription a browser posts to
|
||||
// the application: an endpoint and the subscriber's p256dh and auth keys.
|
||||
func browserSubscription(endpoint string) (flare.Subscription, error) {
|
||||
key, err := ecdh.P256().GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return flare.Subscription{}, err
|
||||
}
|
||||
auth := make([]byte, 16)
|
||||
if _, err := rand.Read(auth); err != nil {
|
||||
return flare.Subscription{}, err
|
||||
}
|
||||
enc := base64.RawURLEncoding
|
||||
return flare.Subscription{
|
||||
Endpoint: endpoint,
|
||||
P256dh: enc.EncodeToString(key.PublicKey().Bytes()),
|
||||
Auth: enc.EncodeToString(auth),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func ExampleVAPIDPusher_Send() {
|
||||
// A stand-in push service: 201 for a live subscription, 410 for one the
|
||||
// browser dropped.
|
||||
push := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/gone" {
|
||||
w.WriteHeader(http.StatusGone)
|
||||
return
|
||||
}
|
||||
fmt.Println("push service got", r.Header.Get("Content-Encoding"), r.Header.Get("TTL"), r.Header.Get("Urgency"))
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
}))
|
||||
defer push.Close()
|
||||
|
||||
keys, err := flare.GenerateVAPIDKeys() // websockets:generate-vapid-keys
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
cfg := flare.Config{
|
||||
Enabled: true,
|
||||
PublicKey: keys.PublicKey,
|
||||
PrivateKey: keys.PrivateKey,
|
||||
Subject: "mailto:admin@example.com",
|
||||
TTL: time.Hour,
|
||||
AllowedHosts: []string{"127.0.0.1"}, // production keeps the default push services
|
||||
}
|
||||
// The test server's client trusts its certificate.
|
||||
pusher := flare.NewVAPIDPusher(cfg, push.Client())
|
||||
|
||||
ctx := context.Background()
|
||||
payload := []byte(`{"title":"New comment","body":"Someone replied to your post"}`)
|
||||
for _, endpoint := range []string{
|
||||
push.URL + "/live",
|
||||
push.URL + "/gone",
|
||||
"https://push.attacker.example/steal",
|
||||
"http://127.0.0.1/plain",
|
||||
} {
|
||||
sub, err := browserSubscription(endpoint)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
err = pusher.Send(ctx, sub, payload, flare.SendOptions{Urgency: "normal"})
|
||||
switch {
|
||||
case err == nil:
|
||||
fmt.Println("sent")
|
||||
case errors.Is(err, flare.ErrSubscriptionGone):
|
||||
fmt.Println("gone: delete the subscription")
|
||||
case errors.Is(err, flare.ErrEndpointNotAllowed):
|
||||
fmt.Println("refused before connecting")
|
||||
default:
|
||||
fmt.Println("error:", err)
|
||||
}
|
||||
}
|
||||
// Formatting the keys never prints the private key.
|
||||
fmt.Println(strings.Contains(fmt.Sprintf("%v %#v", keys, keys), keys.PrivateKey))
|
||||
// Output:
|
||||
// push service got aes128gcm 3600 normal
|
||||
// sent
|
||||
// gone: delete the subscription
|
||||
// refused before connecting
|
||||
// refused before connecting
|
||||
// false
|
||||
}
|
||||
|
||||
func ExampleHostAllowed() {
|
||||
allowed := []string{"fcm.googleapis.com", "*.push.apple.com"}
|
||||
for _, host := range []string{"fcm.googleapis.com", "api.push.apple.com", "push.apple.com", "evil.example"} {
|
||||
fmt.Println(host, flare.HostAllowed(host, allowed))
|
||||
}
|
||||
// Output:
|
||||
// fcm.googleapis.com true
|
||||
// api.push.apple.com true
|
||||
// push.apple.com false
|
||||
// evil.example false
|
||||
}
|
||||
102
modules/lighthouse/centrifugo/example_test.go
Normal file
102
modules/lighthouse/centrifugo/example_test.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package centrifugo_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
"git.golem15.com/golem15/summercms/modules/lighthouse"
|
||||
"git.golem15.com/golem15/summercms/modules/lighthouse/centrifugo"
|
||||
"git.golem15.com/golem15/summercms/modules/surf"
|
||||
)
|
||||
|
||||
// newApp returns an app whose config selects a realtime driver.
|
||||
func newApp(settings map[string]any) (*backpack.App, error) {
|
||||
cfg, err := compass.Open(compass.Options{Dir: "config", Env: "development", Environ: []string{}})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for k, v := range settings {
|
||||
if err := cfg.Set(k, v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return backpack.New(cfg), nil
|
||||
}
|
||||
|
||||
func ExampleProxyHandler() {
|
||||
svc, err := lighthouse.From(backpack.New(nil))
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
// Members of blog 7 may subscribe to its channels.
|
||||
err = svc.Registry().Register("blog", lighthouse.AuthorizerFunc(
|
||||
func(ctx context.Context, userID uint, channel string) lighthouse.Result {
|
||||
if userID == 42 && lighthouse.ChannelID(channel) == 7 {
|
||||
return lighthouse.Allowed(nil)
|
||||
}
|
||||
return lighthouse.Denied("not a member of the blog")
|
||||
}))
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
// realtime.centrifugo.proxy_secret; set it through the environment.
|
||||
proxy := centrifugo.ProxyHandler(svc, centrifugo.Config{ProxySecret: "test-only-proxy-secret"})
|
||||
|
||||
// What Centrifugo posts to the subscribe proxy.
|
||||
subscribe := func(secret, user, channel string) {
|
||||
body := fmt.Sprintf(`{"client":"c1","user":%q,"channel":%q}`, user, channel)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/realtime/subscribe", strings.NewReader(body))
|
||||
req.Header.Set("X-Centrifugo-Secret", secret)
|
||||
rec := httptest.NewRecorder()
|
||||
proxy.ServeHTTP(rec, req)
|
||||
fmt.Println(rec.Code, strings.TrimSpace(rec.Body.String()))
|
||||
}
|
||||
subscribe("test-only-proxy-secret", "42", "blog:7")
|
||||
subscribe("test-only-proxy-secret", "5", "blog:7")
|
||||
subscribe("wrong-secret", "42", "blog:7")
|
||||
// Output:
|
||||
// 200 {"result":{"info":[]}}
|
||||
// 200 {"error":{"code":403,"message":"Access denied"}}
|
||||
// 200 {"error":{"code":403,"message":"Access denied"}}
|
||||
}
|
||||
|
||||
func ExampleDriver_Routes() {
|
||||
app, err := newApp(map[string]any{"realtime.driver": "centrifugo"})
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
svc, err := lighthouse.From(app)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
// In a plugin's Routes method, r is the router the plugin receives.
|
||||
r := surf.New(nil)
|
||||
err = lighthouse.Mount(r, svc.Driver(), lighthouse.Surfaces{
|
||||
UserAuth: surf.Use("acme.auth"),
|
||||
Middleware: surf.Use("throttle:60,1"),
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
for _, rt := range r.Routes() {
|
||||
fmt.Println(rt.Method, rt.Pattern, rt.Middleware, "raw:", rt.Raw)
|
||||
}
|
||||
|
||||
// A user route without a guard is refused.
|
||||
err = lighthouse.Mount(surf.New(nil), svc.Driver(), lighthouse.Surfaces{})
|
||||
fmt.Println(err != nil)
|
||||
// Output:
|
||||
// GET /api/realtime/token [acme.auth throttle:60,1] raw: false
|
||||
// POST /api/realtime/subscribe [throttle:60,1] raw: true
|
||||
// true
|
||||
}
|
||||
196
modules/lighthouse/example_test.go
Normal file
196
modules/lighthouse/example_test.go
Normal file
@@ -0,0 +1,196 @@
|
||||
package lighthouse_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"git.golem15.com/golem15/summercms/modules/lighthouse"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// newApp returns an app whose config selects a realtime driver.
|
||||
func newApp(settings map[string]any) (*backpack.App, error) {
|
||||
cfg, err := compass.Open(compass.Options{Dir: "config", Env: "development", Environ: []string{}})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for k, v := range settings {
|
||||
if err := cfg.Set(k, v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return backpack.New(cfg), nil
|
||||
}
|
||||
|
||||
// isMember stands in for the application's own membership query.
|
||||
func isMember(ctx context.Context, userID uint, blogID int64) bool {
|
||||
return userID == 42 && blogID == 7
|
||||
}
|
||||
|
||||
func ExampleRegistry_Register() {
|
||||
app, err := newApp(map[string]any{"realtime.driver": "memory"})
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
svc, err := lighthouse.From(app)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
|
||||
// blog:{entity}:{id} channels are open to members of the blog only. The
|
||||
// authorizer runs on every subscribe; nothing is cached.
|
||||
err = svc.Registry().Register("blog", lighthouse.AuthorizerFunc(
|
||||
func(ctx context.Context, userID uint, channel string) lighthouse.Result {
|
||||
if isMember(ctx, userID, lighthouse.ChannelID(channel)) {
|
||||
return lighthouse.Allowed(nil)
|
||||
}
|
||||
return lighthouse.Denied("not a member of the blog")
|
||||
}))
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, sub := range []struct {
|
||||
user uint
|
||||
channel string
|
||||
}{{42, "blog:7"}, {42, "blog:8"}, {42, "presence:blog:7"}, {42, "shop:7"}} {
|
||||
ns, presence := lighthouse.ParseChannel(sub.channel)
|
||||
auth, ok := svc.Registry().Get(ns)
|
||||
if !ok {
|
||||
fmt.Println(sub.channel, "no authorizer")
|
||||
continue
|
||||
}
|
||||
res := auth.Authorize(context.Background(), sub.user, sub.channel)
|
||||
fmt.Printf("%d %s namespace=%s presence=%v allowed=%v reason=%q\n", sub.user, sub.channel, ns, presence, res.Allowed, res.Reason())
|
||||
}
|
||||
fmt.Println(lighthouse.ChannelID("blog:12abc"), lighthouse.FormatChannels("acme", []string{"Blog:7"}))
|
||||
// Output:
|
||||
// 42 blog:7 namespace=blog presence=false allowed=true reason=""
|
||||
// 42 blog:8 namespace=blog presence=false allowed=false reason="not a member of the blog"
|
||||
// 42 presence:blog:7 namespace=blog presence=true allowed=false reason="not a member of the blog"
|
||||
// shop:7 no authorizer
|
||||
// 12 [acme:blog:7]
|
||||
}
|
||||
|
||||
// Post is the acme.blog post model. It knows nothing about realtime.
|
||||
type Post struct {
|
||||
ID uint `gorm:"column:id;primaryKey"`
|
||||
BlogID uint `gorm:"column:blog_id"`
|
||||
Title string `gorm:"column:title"`
|
||||
}
|
||||
|
||||
func (Post) TableName() string { return "acme_blog_posts" }
|
||||
|
||||
// bindPosts registers the broadcast contract of Post from the plugin's Boot.
|
||||
func bindPosts(svc *lighthouse.Service) error {
|
||||
// docs:start bind
|
||||
return lighthouse.Bind[Post](svc, lighthouse.Binding[Post]{
|
||||
Alias: "blog.post",
|
||||
Channels: func(ctx context.Context, tx *gorm.DB, p *Post) ([]string, error) {
|
||||
return []string{"blog:" + strconv.FormatUint(uint64(p.BlogID), 10)}, nil
|
||||
},
|
||||
})
|
||||
// docs:end bind
|
||||
}
|
||||
|
||||
// publishPost creates a post; its broadcast is published after commit.
|
||||
func publishPost(ctx context.Context, db *gorm.DB, fail bool) error {
|
||||
// docs:start write
|
||||
return lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error {
|
||||
if err := tx.Create(&Post{BlogID: 7, Title: "Hello"}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// The broadcast job is now queued in this transaction. It is
|
||||
// published only if the transaction commits.
|
||||
if fail {
|
||||
return fmt.Errorf("rolled back")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
// docs:end write
|
||||
}
|
||||
|
||||
// importPosts writes many posts and publishes one summary event.
|
||||
func importPosts(ctx context.Context, svc *lighthouse.Service, db *gorm.DB, titles []string) error {
|
||||
// docs:start bulk
|
||||
return lighthouse.WithoutBroadcasting[Post](ctx, func(ctx context.Context) error {
|
||||
return lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error {
|
||||
for _, title := range titles {
|
||||
if err := tx.Create(&Post{BlogID: 7, Title: title}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return svc.Emit(ctx, tx, lighthouse.Broadcast{
|
||||
Channels: []string{"blog:7"},
|
||||
Event: "blog.posts_imported",
|
||||
Payload: struct {
|
||||
Count int `json:"count"`
|
||||
}{len(titles)},
|
||||
})
|
||||
})
|
||||
})
|
||||
// docs:end bulk
|
||||
}
|
||||
|
||||
// waitFor waits until the memory driver has recorded n publications.
|
||||
func waitFor(t *testing.T, mem *lighthouse.MemoryDriver, n int) []lighthouse.Publication {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for len(mem.Publications()) < n {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("got %d publications, want %d", len(mem.Publications()), n)
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
time.Sleep(300 * time.Millisecond) // catch extra publications
|
||||
pubs := mem.Publications()
|
||||
if len(pubs) != n {
|
||||
t.Fatalf("got %d publications, want exactly %d: %+v", len(pubs), n, pubs)
|
||||
}
|
||||
return pubs
|
||||
}
|
||||
|
||||
// TestDocsBroadcast runs the bind, write and bulk regions of the Realtime
|
||||
// page on the package's Postgres harness with a job worker.
|
||||
func TestDocsBroadcast(t *testing.T) {
|
||||
_, svc, db := lighthouse.DocsEnv(t)
|
||||
if err := db.Exec(`CREATE TABLE acme_blog_posts (id SERIAL PRIMARY KEY, blog_id INTEGER NOT NULL, title TEXT NOT NULL)`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := bindPosts(svc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mem, ok := svc.Driver().(*lighthouse.MemoryDriver)
|
||||
if !ok {
|
||||
t.Fatalf("driver is %T", svc.Driver())
|
||||
}
|
||||
ctx := t.Context()
|
||||
|
||||
if err := publishPost(ctx, db, true); err == nil {
|
||||
t.Fatal("rolled-back publish succeeded")
|
||||
}
|
||||
if err := publishPost(ctx, db, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pubs := waitFor(t, mem, 1)
|
||||
if pubs[0].Event != "created.blog.post" || len(pubs[0].Channels) != 1 || pubs[0].Channels[0] != "blog:7" {
|
||||
t.Fatalf("publication = %+v", pubs[0])
|
||||
}
|
||||
|
||||
if err := importPosts(ctx, svc, db, []string{"A", "B", "C"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pubs = waitFor(t, mem, 2)
|
||||
if pubs[1].Event != "blog.posts_imported" || string(pubs[1].Payload) != `{"count":3}` {
|
||||
t.Fatalf("summary = %+v %s", pubs[1], pubs[1].Payload)
|
||||
}
|
||||
}
|
||||
19
modules/lighthouse/export_docs_test.go
Normal file
19
modules/lighthouse/export_docs_test.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package lighthouse
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// DocsEnv returns an app with the memory driver on a fresh migrated
|
||||
// database of this package's Postgres harness, with a job worker running,
|
||||
// for the database-backed docs examples in example_test.go. It skips under
|
||||
// -short and fails when the harness has no database, like the package's
|
||||
// other database tests.
|
||||
func DocsEnv(t *testing.T) (*backpack.App, *Service, *gorm.DB) {
|
||||
t.Helper()
|
||||
env := newLHEnv(t, nil, nil)
|
||||
return env.app, env.svc, env.gdb
|
||||
}
|
||||
69
modules/tide/example_test.go
Normal file
69
modules/tide/example_test.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package tide_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/tide"
|
||||
)
|
||||
|
||||
// backend stands in for one implementation of GET /api/blog/posts.
|
||||
func backend(body string) *httptest.Server {
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, body)
|
||||
}))
|
||||
}
|
||||
|
||||
func ExampleReplayFlow() {
|
||||
ctx := context.Background()
|
||||
// The reference (PHP) backend and two Go ports. IDs and *_at timestamps
|
||||
// legitimately differ; the second port changed a title.
|
||||
reference := backend(`{"data":[{"id":12,"title":"Hello","created_at":"2026-09-30T10:00:00+00:00"}]}`)
|
||||
defer reference.Close()
|
||||
port := backend(`{"data":[{"id":3,"title":"Hello","created_at":"2026-10-01T08:30:00+00:00"}]}`)
|
||||
defer port.Close()
|
||||
broken := backend(`{"data":[{"id":3,"title":"hello","created_at":"2026-10-01T08:30:00+00:00"}]}`)
|
||||
defer broken.Close()
|
||||
|
||||
raw, err := os.ReadFile("testdata/docs/posts-spec.yaml")
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
spec, err := tide.ParseFlow(raw)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
// Record the reference once; the flow is what testdata/parity keeps.
|
||||
flow, err := tide.RecordFlow(ctx, spec, tide.RecordConfig{Target: reference.URL})
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
for _, target := range []string{port.URL, broken.URL} {
|
||||
res, err := tide.ReplayFlow(ctx, flow, tide.ReplayConfig{Target: target})
|
||||
var mismatch *tide.MismatchError
|
||||
if errors.As(err, &mismatch) {
|
||||
res = mismatch.Result // a difference is an error carrying the result
|
||||
} else if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
fmt.Println("ok:", res.OK)
|
||||
for _, step := range res.Steps {
|
||||
for _, d := range step.Diffs {
|
||||
fmt.Printf(" %s %s: want %s, got %s\n", step.ID, d.Path, d.Expected, d.Actual)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Output:
|
||||
// ok: true
|
||||
// ok: false
|
||||
// list-posts $.data[0].title: want "Hello", got "hello"
|
||||
}
|
||||
9
modules/tide/testdata/docs/posts-spec.yaml
vendored
Normal file
9
modules/tide/testdata/docs/posts-spec.yaml
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
version: 1
|
||||
name: blog-posts
|
||||
description: List the posts of one blog
|
||||
steps:
|
||||
- id: list-posts
|
||||
route_id: GET /api/blog/posts
|
||||
request:
|
||||
method: GET
|
||||
path: /api/blog/posts?page=1
|
||||
Reference in New Issue
Block a user