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:
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
|
||||
}
|
||||
Reference in New Issue
Block a user