feat(11.1-04): add the Database section and the core Services pages
- docs/database: models, migrations, queries and pagination, relations, casts and validation, attachments and transactions (lagoon.Transaction, lagoon.AfterCommit, nested savepoints, lagoon.OnDatabase) - docs/services: configuration, events, routing with auth groups, rate limiting, authentication, the OAuth server, mail and localization - runnable Examples for lagoon, attach, compass, surf, wire, bouncer, wristband, postcard, phrasebook and festival; lagoon TestDocs* regions run on the package's Postgres harness through DocsDB - 15 new required pages
This commit is contained in:
105
modules/bouncer/example_test.go
Normal file
105
modules/bouncer/example_test.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package bouncer_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
)
|
||||
|
||||
// testSecret is a test-only signing secret. A real application reads its
|
||||
// secret from configuration and never commits it.
|
||||
const testSecret = "test-only-secret-with-at-least-32-bytes"
|
||||
|
||||
func ExampleMint() {
|
||||
const issuer = "http://127.0.0.1:8080/api/login"
|
||||
token, _, err := bouncer.Mint(testSecret, "42", issuer, time.Hour)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
sub, iat, exp, _, err := bouncer.VerifyClaims(token, testSecret)
|
||||
fmt.Println(sub, exp.Sub(iat), err)
|
||||
|
||||
// A frontend token never passes a backend check, and a wrong secret fails.
|
||||
_, _, _, _, err = bouncer.VerifyClaimsAudience(token, testSecret, bouncer.AudienceBackend)
|
||||
fmt.Println(err != nil)
|
||||
_, err = bouncer.Verify(token, "another-secret-with-at-least-32-bytes")
|
||||
fmt.Println(err != nil)
|
||||
|
||||
// Refresh reissues the token and blacklists the old jti after the grace.
|
||||
bl := bouncer.NewMemoryBlacklist()
|
||||
fresh, err := bouncer.Refresh(testSecret, token, 14*24*time.Hour, bl, 0, issuer)
|
||||
fmt.Println(fresh != token, err)
|
||||
_, _, _, jti, _ := bouncer.VerifyClaims(token, testSecret)
|
||||
revoked, _ := bl.IsBlacklisted(context.Background(), jti)
|
||||
fmt.Println(revoked)
|
||||
// Output:
|
||||
// 42 1h0m0s <nil>
|
||||
// true
|
||||
// true
|
||||
// true <nil>
|
||||
// true
|
||||
}
|
||||
|
||||
// users loads the principal behind a token subject.
|
||||
type users struct{}
|
||||
|
||||
func (users) FindByID(ctx context.Context, id uint) (*bouncer.Principal, error) {
|
||||
return &bouncer.Principal{ID: id, PreferredLocale: "pl"}, nil
|
||||
}
|
||||
|
||||
func ExampleNewJWTGuard() {
|
||||
guards := bouncer.NewRegistry()
|
||||
guard := bouncer.NewJWTGuard(testSecret, users{}, bouncer.NewMemoryBlacklist(), "token")
|
||||
if err := guards.Register("acme.blog", "acme.auth", guard); err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
auth, err := guards.Middleware("acme.auth")
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
me := auth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := bouncer.User(r.Context())
|
||||
fmt.Fprintf(w, "user %d, locale %s", user.ID, user.PreferredLocale)
|
||||
}))
|
||||
|
||||
token, _, _ := bouncer.Mint(testSecret, "42", "http://127.0.0.1:8080/api/login", time.Hour)
|
||||
for _, set := range []func(*http.Request){
|
||||
func(r *http.Request) {},
|
||||
func(r *http.Request) { r.Header.Set("Authorization", "Bearer "+token) },
|
||||
func(r *http.Request) { r.AddCookie(&http.Cookie{Name: "token", Value: token}) },
|
||||
} {
|
||||
req := httptest.NewRequest("GET", "/api/me", nil)
|
||||
set(req)
|
||||
rec := httptest.NewRecorder()
|
||||
me.ServeHTTP(rec, req)
|
||||
fmt.Println(rec.Code, strings.TrimSpace(rec.Body.String()))
|
||||
}
|
||||
// Output:
|
||||
// 401 {"error":true,"message":"Token not provided"}
|
||||
// 200 user 42, locale pl
|
||||
// 200 user 42, locale pl
|
||||
}
|
||||
|
||||
func ExampleHashPassword() {
|
||||
hash, err := bouncer.HashPassword(10, "correct horse battery staple")
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
fmt.Println(bouncer.CheckPassword(hash, "correct horse battery staple"))
|
||||
fmt.Println(bouncer.CheckPassword(hash, "wrong"))
|
||||
// After raising the configured cost, rehash on the next successful login.
|
||||
fmt.Println(bouncer.NeedsRehash(hash, 12))
|
||||
// Output:
|
||||
// true
|
||||
// false
|
||||
// true
|
||||
}
|
||||
95
modules/compass/example_test.go
Normal file
95
modules/compass/example_test.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package compass_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing/fstest"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
)
|
||||
|
||||
// writeConfig lays out a config directory: base sections and a development
|
||||
// override of one of them.
|
||||
func writeConfig(dir string) error {
|
||||
files := map[string]string{
|
||||
"app.yaml": "name: Acme\ndebug: false\n",
|
||||
"mail.yaml": "driver: log\nfrom: blog@example.com\n",
|
||||
"env/development/app.yaml": "debug: true\n",
|
||||
}
|
||||
for name, body := range files {
|
||||
path := filepath.Join(dir, filepath.FromSlash(name))
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type mailSettings struct {
|
||||
Driver string `koanf:"driver"`
|
||||
From string `koanf:"from"`
|
||||
}
|
||||
|
||||
func ExampleOpen() {
|
||||
dir, err := os.MkdirTemp("", "acme-config")
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
if err := writeConfig(dir); err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
|
||||
// Environ stands in for the process environment (nil reads os.Environ).
|
||||
cfg, err := compass.Open(compass.Options{
|
||||
Dir: dir,
|
||||
Env: "development",
|
||||
Environ: []string{"SUMMER_MAIL__DRIVER=smtp"},
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
|
||||
// A plugin's embedded config/config.yaml becomes its defaults.
|
||||
plugin := fstest.MapFS{"config/config.yaml": {Data: []byte("posts_per_page: 10\n")}}
|
||||
if err := cfg.MergePlugin("acme.blog", plugin); err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println(cfg.String("app.name"), cfg.Bool("app.debug"), cfg.Int("acme.blog.posts_per_page"))
|
||||
var mail mailSettings
|
||||
if err := cfg.LoadSection("mail", &mail); err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
fmt.Println(mail.Driver, mail.From)
|
||||
_, found := cfg.Lookup("app.timezone")
|
||||
fmt.Println(cfg.Environment(), found)
|
||||
|
||||
// A runtime override, saved to env/development/overrides.yaml.
|
||||
if err := cfg.Set("acme.blog.posts_per_page", 25); err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
if err := cfg.Persist(); err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
saved, _ := os.ReadFile(filepath.Join(dir, "env", "development", "overrides.yaml"))
|
||||
fmt.Print(string(saved))
|
||||
// Output:
|
||||
// Acme true 10
|
||||
// smtp blog@example.com
|
||||
// development false
|
||||
// acme:
|
||||
// blog:
|
||||
// posts_per_page: 25
|
||||
}
|
||||
@@ -33,3 +33,60 @@ func ExampleBus_Fire() {
|
||||
// notify subscribers of Hello
|
||||
// index Hello
|
||||
}
|
||||
|
||||
// PostFormExtended gathers extra fields other plugins add to the post form.
|
||||
type PostFormExtended struct {
|
||||
fields map[string]any
|
||||
}
|
||||
|
||||
func (e *PostFormExtended) Collected() map[string]any { return e.fields }
|
||||
|
||||
func ExampleBus_Collect() {
|
||||
bus := festival.New()
|
||||
bus.Listen("acme.seo", func(ctx context.Context, e *PostFormExtended) error {
|
||||
e.fields = map[string]any{"meta_title": "text"}
|
||||
return nil
|
||||
})
|
||||
bus.Listen("acme.gallery", func(ctx context.Context, e *PostFormExtended) error {
|
||||
e.fields = map[string]any{"cover": "fileupload"}
|
||||
return nil
|
||||
})
|
||||
|
||||
fields, err := bus.Collect(context.Background(), &PostFormExtended{})
|
||||
fmt.Println(fields, err)
|
||||
// Output: map[cover:fileupload meta_title:text] <nil>
|
||||
}
|
||||
|
||||
// SlugResolving asks plugins to resolve a URL slug; the first one that
|
||||
// knows it handles the event.
|
||||
type SlugResolving struct {
|
||||
Slug string
|
||||
Found string
|
||||
}
|
||||
|
||||
func (e *SlugResolving) IsHandled() bool { return e.Found != "" }
|
||||
|
||||
func ExampleBus_UntilHandled() {
|
||||
bus := festival.New()
|
||||
bus.ListenPriority("acme.pages", 10, func(ctx context.Context, e *SlugResolving) error {
|
||||
if e.Slug == "about" {
|
||||
e.Found = "page"
|
||||
}
|
||||
return nil
|
||||
})
|
||||
bus.Listen("acme.blog", func(ctx context.Context, e *SlugResolving) error {
|
||||
fmt.Println("acme.blog asked for", e.Slug)
|
||||
e.Found = "post"
|
||||
return nil
|
||||
})
|
||||
|
||||
for _, slug := range []string{"about", "hello-world"} {
|
||||
e := &SlugResolving{Slug: slug}
|
||||
handled, err := bus.UntilHandled(context.Background(), e)
|
||||
fmt.Println(slug, handled, e.Found, err)
|
||||
}
|
||||
// Output:
|
||||
// about true page <nil>
|
||||
// acme.blog asked for hello-world
|
||||
// hello-world true post <nil>
|
||||
}
|
||||
|
||||
81
modules/lagoon/attach/example_test.go
Normal file
81
modules/lagoon/attach/example_test.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package attach_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"os"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
|
||||
)
|
||||
|
||||
// Post owns attachments. MorphName is the attachment_type value its rows
|
||||
// carry: the PHP class name, so rows copied from WinterCMS keep matching.
|
||||
type Post struct {
|
||||
ID uint
|
||||
}
|
||||
|
||||
func (Post) MorphName() string { return `Acme\Blog\Models\Post` }
|
||||
|
||||
func ExampleFile_Thumb() {
|
||||
ctx := context.Background()
|
||||
dir, err := os.MkdirTemp("", "acme-config")
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
// storage.uploads.bucket_url is a file:// URL in production; mem:// keeps
|
||||
// the example in memory.
|
||||
cfg, err := compass.Open(compass.Options{Dir: dir, Env: "development", Environ: []string{}})
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
_ = cfg.Set("storage.uploads.bucket_url", "mem://")
|
||||
bucket, err := attach.OpenBucket(ctx, cfg)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
defer bucket.Close()
|
||||
|
||||
// The system_files row of a post's cover image.
|
||||
var owner attach.Owner = Post{ID: 1}
|
||||
f := attach.File{
|
||||
ID: 7,
|
||||
DiskName: "5f1d0c2e9a7b4c3d8e6f.jpg",
|
||||
FileName: "cover.jpg",
|
||||
ContentType: "image/jpeg",
|
||||
Field: "cover",
|
||||
AttachmentType: owner.MorphName(),
|
||||
AttachmentID: "1",
|
||||
}
|
||||
|
||||
// The original is stored under its partitioned WinterCMS key.
|
||||
var img bytes.Buffer
|
||||
if err := jpeg.Encode(&img, image.NewRGBA(image.Rect(0, 0, 640, 480)), nil); err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
if err := bucket.WriteAll(ctx, attach.BlobKey(f.DiskName), img.Bytes(), nil); err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
fmt.Println(attach.BlobKey(f.DiskName))
|
||||
|
||||
// A thumbnail is generated on first use and reused afterwards.
|
||||
url, err := f.Thumb(ctx, bucket, 200, 200, "crop")
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
fmt.Println(url)
|
||||
// Output:
|
||||
// 5f1/d0c/2e9/5f1d0c2e9a7b4c3d8e6f.jpg
|
||||
// /storage/uploads/5f1/d0c/2e9/thumb_7_200_200_0_0_crop.jpg
|
||||
}
|
||||
626
modules/lagoon/example_test.go
Normal file
626
modules/lagoon/example_test.go
Normal file
@@ -0,0 +1,626 @@
|
||||
package lagoon_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
"github.com/go-gormigrate/gormigrate/v2"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Post is the acme.blog post model: a plain GORM struct with lagoon column
|
||||
// types.
|
||||
type Post struct {
|
||||
ID uint `gorm:"column:id;primaryKey" json:"id"`
|
||||
Title string `gorm:"column:title" json:"title"`
|
||||
Slug string `gorm:"column:slug" json:"slug"`
|
||||
Views int `gorm:"column:views" json:"views"`
|
||||
Tags lagoon.Jsonable[[]string] `gorm:"column:tags" json:"-"`
|
||||
APIToken lagoon.Encrypted `gorm:"column:api_token" json:"-"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at" json:"-"`
|
||||
|
||||
Categories []Category `gorm:"many2many:acme_blog_post_categories" json:"-"`
|
||||
}
|
||||
|
||||
// TableName keeps the WinterCMS table name.
|
||||
func (Post) TableName() string { return "acme_blog_posts" }
|
||||
|
||||
// Fillable is the Go form of $fillable: the keys mass assignment may set.
|
||||
func (Post) Fillable() []string { return []string{"title", "views"} }
|
||||
|
||||
// Hidden is the Go form of $hidden. The json:"-" tags are what keep these
|
||||
// columns out of JSON; the list documents them for tooling.
|
||||
func (Post) Hidden() []string { return []string{"tags", "api_token", "deleted_at"} }
|
||||
|
||||
// BeforeCreate fills the slug from the title. A hook that only touches the
|
||||
// model stays on the model.
|
||||
func (p *Post) BeforeCreate(tx *gorm.DB) error {
|
||||
if p.Slug == "" {
|
||||
p.Slug = strings.ReplaceAll(strings.ToLower(strings.TrimSpace(p.Title)), " ", "-")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BeforeDelete soft-deletes the post's comments in the transaction of the
|
||||
// post's own delete; an error aborts that delete.
|
||||
func (p *Post) BeforeDelete(tx *gorm.DB) error {
|
||||
return lagoon.WithSoftDeleteCascade(tx, func(tx *gorm.DB) error {
|
||||
return tx.Where("post_id = ?", p.ID).Delete(&Comment{}).Error
|
||||
})
|
||||
}
|
||||
|
||||
// Comment belongs to a post and is soft-deleted with it.
|
||||
type Comment struct {
|
||||
ID uint `gorm:"column:id;primaryKey"`
|
||||
PostID uint `gorm:"column:post_id"`
|
||||
Body string `gorm:"column:body"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at"`
|
||||
}
|
||||
|
||||
func (Comment) TableName() string { return "acme_blog_comments" }
|
||||
|
||||
// Category is linked to posts through a pivot with its own sort order.
|
||||
type Category struct {
|
||||
ID uint `gorm:"column:id;primaryKey"`
|
||||
Name string `gorm:"column:name"`
|
||||
}
|
||||
|
||||
func (Category) TableName() string { return "acme_blog_categories" }
|
||||
|
||||
// PostCategory is the pivot model: the join table has a business column.
|
||||
type PostCategory struct {
|
||||
PostID uint `gorm:"column:post_id;primaryKey"`
|
||||
CategoryID uint `gorm:"column:category_id;primaryKey"`
|
||||
SortOrder int `gorm:"column:sort_order"`
|
||||
}
|
||||
|
||||
func (PostCategory) TableName() string { return "acme_blog_post_categories" }
|
||||
|
||||
// BlogPlugin is the acme.blog plugin; only its migrations are shown here.
|
||||
type BlogPlugin struct{}
|
||||
|
||||
var _ pact.HasMigrations = (*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 }
|
||||
|
||||
// Migrations returns the plugin's schema as an ordered gormigrate set. In a
|
||||
// scaffolded plugin each migration is a file in updates/ and this list is
|
||||
// generated in file name order.
|
||||
func (p *BlogPlugin) Migrations() []*gormigrate.Migration {
|
||||
return []*gormigrate.Migration{
|
||||
{
|
||||
ID: "20260101000100_create_posts",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
return tx.Exec(`CREATE TABLE acme_blog_posts (
|
||||
id SERIAL PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
slug TEXT NOT NULL,
|
||||
views INTEGER NOT NULL DEFAULT 0,
|
||||
tags TEXT,
|
||||
api_token TEXT,
|
||||
deleted_at TIMESTAMPTZ
|
||||
)`).Error
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
return tx.Exec(`DROP TABLE IF EXISTS acme_blog_posts`).Error
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "20260101000200_create_comments_and_categories",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
for _, stmt := range []string{
|
||||
`CREATE TABLE acme_blog_comments (id SERIAL PRIMARY KEY, post_id INTEGER NOT NULL, body TEXT NOT NULL, deleted_at TIMESTAMPTZ)`,
|
||||
`CREATE TABLE acme_blog_categories (id SERIAL PRIMARY KEY, name TEXT NOT NULL)`,
|
||||
`CREATE TABLE acme_blog_post_categories (post_id INTEGER NOT NULL, category_id INTEGER NOT NULL, sort_order INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (post_id, category_id))`,
|
||||
} {
|
||||
if err := tx.Exec(stmt).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
return tx.Exec(`DROP TABLE IF EXISTS acme_blog_post_categories, acme_blog_categories, acme_blog_comments`).Error
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func ExampleFill() {
|
||||
input := map[string]any{"title": "Hello", "views": 3, "slug": "forged"}
|
||||
var post Post
|
||||
// production=false logs each dropped key once, to catch typos in development.
|
||||
if err := lagoon.Fill(&post, post.Fillable(), input, true); err != nil {
|
||||
fmt.Println(err)
|
||||
}
|
||||
fmt.Printf("%q %q %d\n", post.Title, post.Slug, post.Views)
|
||||
|
||||
err := lagoon.Fill(&post, post.Fillable(), map[string]any{"views": "many"}, true)
|
||||
var typeErr *lagoon.FillTypeError
|
||||
if errors.As(err, &typeErr) {
|
||||
fmt.Println("invalid value for", typeErr.Key)
|
||||
}
|
||||
// Output:
|
||||
// "Hello" "" 3
|
||||
// invalid value for views
|
||||
}
|
||||
|
||||
func ExampleValidate() {
|
||||
rules := map[string]string{
|
||||
"title": "required|max:10",
|
||||
"views": "nullable|integer|max:1000",
|
||||
}
|
||||
input := map[string]any{"title": "", "views": 5000}
|
||||
// A nil translator gives the built-in English messages; unique: rules
|
||||
// need a database handle instead of nil.
|
||||
errs, err := lagoon.Validate(context.Background(), nil, &Post{}, rules, input, nil)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
}
|
||||
out, _ := json.Marshal(errs)
|
||||
fmt.Println(string(out))
|
||||
// Output:
|
||||
// {"title":["The title field is required."],"views":["The views may not be greater than 1000."]}
|
||||
}
|
||||
|
||||
func ExampleHasHidden() {
|
||||
post := Post{ID: 1, Title: "Hello", APIToken: lagoon.NewEncrypted("s3cret")}
|
||||
out, _ := json.Marshal(post)
|
||||
fmt.Println(string(out))
|
||||
var _ lagoon.HasHidden = post
|
||||
// Output:
|
||||
// {"id":1,"title":"Hello","slug":"","views":0}
|
||||
}
|
||||
|
||||
func ExampleOrderBy() {
|
||||
// A dry-run handle shows the SQL without a database.
|
||||
db, _ := gorm.Open(postgres.New(postgres.Config{DSN: "host=127.0.0.1"}), &gorm.Config{DryRun: true, DisableAutomaticPing: true})
|
||||
allowed := []string{"title", "views"}
|
||||
|
||||
q, err := lagoon.OrderBy(db.Model(&Post{}), "views", "desc", allowed)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
var posts []Post
|
||||
fmt.Println(q.Find(&posts).Statement.SQL.String())
|
||||
|
||||
_, err = lagoon.OrderBy(db, "api_token", "asc", allowed)
|
||||
fmt.Println(err)
|
||||
_, err = lagoon.OrderBy(db, "title", "asc; DROP TABLE acme_blog_posts", allowed)
|
||||
fmt.Println(err)
|
||||
// Output:
|
||||
// SELECT * FROM "acme_blog_posts" WHERE "acme_blog_posts"."deleted_at" IS NULL ORDER BY views DESC
|
||||
// lagoon: order column "api_token" is not allow-listed
|
||||
// lagoon: order direction "asc; DROP TABLE acme_blog_posts" is not allow-listed
|
||||
}
|
||||
|
||||
func ExamplePaginate() {
|
||||
rows := []map[string]any{{"id": 3, "title": "Third"}}
|
||||
page := lagoon.Paginate(rows, 2, 2, 3)
|
||||
out, _ := json.Marshal(page)
|
||||
fmt.Println(string(out))
|
||||
|
||||
empty, _ := json.Marshal(lagoon.Paginate[map[string]any](nil, 1, 15, 0))
|
||||
fmt.Println(string(empty))
|
||||
// Output:
|
||||
// {"data":[{"id":3,"title":"Third"}],"meta":{"current_page":2,"last_page":2,"per_page":2,"total":3}}
|
||||
// {"data":[],"meta":{"current_page":1,"last_page":1,"per_page":15,"total":0}}
|
||||
}
|
||||
|
||||
func ExampleJsonable() {
|
||||
tags := lagoon.Jsonable[[]string]{Data: []string{"go", "cms"}, Valid: true}
|
||||
v, _ := tags.Value()
|
||||
fmt.Println(v)
|
||||
|
||||
var none lagoon.Jsonable[[]string] // Valid false stores SQL NULL
|
||||
v, _ = none.Value()
|
||||
fmt.Println(v)
|
||||
|
||||
var read lagoon.Jsonable[[]string]
|
||||
_ = read.Scan(`["winter"]`)
|
||||
fmt.Println(read.Get(), read.Valid)
|
||||
// Output:
|
||||
// ["go","cms"]
|
||||
// <nil>
|
||||
// [winter] true
|
||||
}
|
||||
|
||||
func ExampleEncrypted() {
|
||||
// The application publishes the keys from app.key at boot; a test can
|
||||
// install a key directly.
|
||||
key := []byte("0123456789abcdef0123456789abcdef")
|
||||
if err := lagoon.PublishEncryptionKeys(nil, key, nil); err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
token := lagoon.NewEncrypted("s3cret")
|
||||
stored, _ := token.Value() // what the column holds
|
||||
fmt.Println(strings.Contains(fmt.Sprint(stored), "s3cret"))
|
||||
|
||||
var read lagoon.Encrypted
|
||||
if err := read.Scan(stored); err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
out, _ := json.Marshal(map[string]any{"api_token": read})
|
||||
fmt.Println(read, string(out))
|
||||
fmt.Println(read.Reveal())
|
||||
// Output:
|
||||
// false
|
||||
// [redacted] {"api_token":"[redacted]"}
|
||||
// s3cret
|
||||
}
|
||||
|
||||
// createPost validates input, mass-assigns the fillable keys and inserts
|
||||
// the post. It returns the validation errors, if any.
|
||||
func createPost(ctx context.Context, db *gorm.DB, input map[string]any) (*Post, map[string][]string, error) {
|
||||
// docs:start create-post
|
||||
rules := map[string]string{
|
||||
"title": "required|max:255|unique:acme_blog_posts",
|
||||
"views": "nullable|integer|min:0",
|
||||
}
|
||||
var post Post
|
||||
errs, err := lagoon.Validate(ctx, db, &post, rules, input, nil)
|
||||
if err != nil || errs != nil {
|
||||
return nil, errs, err
|
||||
}
|
||||
if err := lagoon.Fill(&post, post.Fillable(), input, false); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if err := db.WithContext(ctx).Create(&post).Error; err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return &post, nil, nil
|
||||
// docs:end create-post
|
||||
}
|
||||
|
||||
// TestDocsModels runs the create-post region of the Models page.
|
||||
func TestDocsModels(t *testing.T) {
|
||||
db := lagoon.DocsDB(t, "docs_models")
|
||||
plugins := []party.Plugin{&BlogPlugin{}}
|
||||
if err := lagoon.Migrate(db, plugins); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
post, errs, err := createPost(t.Context(), db, map[string]any{"title": "Hello World", "views": 2, "slug": "forged"})
|
||||
if err != nil || errs != nil {
|
||||
t.Fatalf("createPost: %v %v", errs, err)
|
||||
}
|
||||
if post.ID == 0 || post.Slug != "hello-world" || post.Views != 2 {
|
||||
t.Fatalf("post = %+v", post)
|
||||
}
|
||||
_, errs, err = createPost(t.Context(), db, map[string]any{"title": "Hello World"})
|
||||
if err != nil || len(errs["title"]) != 1 {
|
||||
t.Fatalf("duplicate title: %v %v", errs, err)
|
||||
}
|
||||
}
|
||||
|
||||
// migrateBlog runs the framework and plugin migrations, prints the status
|
||||
// and rolls back the plugin's last migration.
|
||||
func migrateBlog(db *gorm.DB) ([]string, error) {
|
||||
var lines []string
|
||||
// docs:start migrate
|
||||
plugins := []party.Plugin{&BlogPlugin{}}
|
||||
if err := lagoon.Migrate(db, plugins); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := lagoon.Status(db, plugins)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, row := range rows {
|
||||
lines = append(lines, fmt.Sprintf("%s %s %v", row.Plugin, row.Table, row.IDs))
|
||||
}
|
||||
if err := lagoon.RollbackLast(db, plugins, "acme.blog"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// docs:end migrate
|
||||
return lines, nil
|
||||
}
|
||||
|
||||
// TestDocsMigrate runs the migrate region of the Migrations page.
|
||||
func TestDocsMigrate(t *testing.T) {
|
||||
db := lagoon.DocsDB(t, "docs_migrate")
|
||||
lines, err := migrateBlog(db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := "acme.blog summer_migrations_acme_blog [20260101000100_create_posts 20260101000200_create_comments_and_categories]"
|
||||
if len(lines) != 1 || lines[0] != want {
|
||||
t.Fatalf("status = %q, want %q", lines, want)
|
||||
}
|
||||
if db.Migrator().HasTable("acme_blog_comments") {
|
||||
t.Fatal("rollback left acme_blog_comments")
|
||||
}
|
||||
if !db.Migrator().HasTable("acme_blog_posts") {
|
||||
t.Fatal("rollback removed acme_blog_posts")
|
||||
}
|
||||
}
|
||||
|
||||
// listPosts returns one page of posts sorted by a column the client names.
|
||||
func listPosts(ctx context.Context, db *gorm.DB, sort, dir string, page, perPage int) (lagoon.Page[Post], error) {
|
||||
// docs:start list-posts
|
||||
q, err := lagoon.OrderBy(db.WithContext(ctx).Model(&Post{}), sort, dir, []string{"title", "views"})
|
||||
if err != nil {
|
||||
return lagoon.Page[Post]{}, err // answer 422: the client asked for a column it may not sort by
|
||||
}
|
||||
var total int64
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
return lagoon.Page[Post]{}, err
|
||||
}
|
||||
var posts []Post
|
||||
if err := q.Offset((page - 1) * perPage).Limit(perPage).Find(&posts).Error; err != nil {
|
||||
return lagoon.Page[Post]{}, err
|
||||
}
|
||||
return lagoon.Paginate(posts, page, perPage, total), nil
|
||||
// docs:end list-posts
|
||||
}
|
||||
|
||||
// TestDocsQueries runs the list-posts region of the Queries page.
|
||||
func TestDocsQueries(t *testing.T) {
|
||||
db := lagoon.DocsDB(t, "docs_queries")
|
||||
if err := lagoon.Migrate(db, []party.Plugin{&BlogPlugin{}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i, title := range []string{"Alpha", "Beta", "Gamma"} {
|
||||
if err := db.Create(&Post{Title: title, Views: i * 10}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
page, err := listPosts(t.Context(), db, "views", "desc", 2, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(page.Data) != 1 || page.Data[0].Title != "Alpha" || page.Meta.LastPage != 2 || page.Meta.Total != 3 {
|
||||
t.Fatalf("page = %+v", page)
|
||||
}
|
||||
if _, err := listPosts(t.Context(), db, "api_token", "asc", 1, 2); err == nil {
|
||||
t.Fatal("sorting by api_token succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
// setCategories replaces a post's categories in the given order and reads
|
||||
// them back through the pivot.
|
||||
func setCategories(ctx context.Context, db *gorm.DB, post *Post, categoryIDs []uint) ([]Category, error) {
|
||||
// docs:start pivot
|
||||
if err := lagoon.RegisterJoinTable(db, &Post{}, "Categories", &PostCategory{}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err := lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error {
|
||||
if err := tx.Where("post_id = ?", post.ID).Delete(&PostCategory{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
rows := make([]PostCategory, len(categoryIDs))
|
||||
for i, id := range categoryIDs {
|
||||
rows[i] = PostCategory{PostID: post.ID, CategoryID: id, SortOrder: i}
|
||||
}
|
||||
return tx.Create(&rows).Error
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var categories []Category
|
||||
err = db.WithContext(ctx).
|
||||
Joins("JOIN acme_blog_post_categories pc ON pc.category_id = acme_blog_categories.id").
|
||||
Where("pc.post_id = ?", post.ID).
|
||||
Order("pc.sort_order").
|
||||
Find(&categories).Error
|
||||
return categories, err
|
||||
// docs:end pivot
|
||||
}
|
||||
|
||||
// TestDocsRelations runs the pivot region of the Relations page and the
|
||||
// soft-delete cascade of Post.BeforeDelete.
|
||||
func TestDocsRelations(t *testing.T) {
|
||||
db := lagoon.DocsDB(t, "docs_relations")
|
||||
if err := lagoon.Migrate(db, []party.Plugin{&BlogPlugin{}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
post := Post{Title: "Hello"}
|
||||
news, events := Category{Name: "News"}, Category{Name: "Events"}
|
||||
for _, v := range []any{&post, &news, &events} {
|
||||
if err := db.Create(v).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
got, err := setCategories(t.Context(), db, &post, []uint{events.ID, news.ID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 2 || got[0].Name != "Events" || got[1].Name != "News" {
|
||||
t.Fatalf("categories = %+v", got)
|
||||
}
|
||||
var loaded Post
|
||||
if err := db.Preload("Categories").First(&loaded, post.ID).Error; err != nil || len(loaded.Categories) != 2 {
|
||||
t.Fatalf("Preload(Categories) = %+v, %v", loaded.Categories, err)
|
||||
}
|
||||
|
||||
if err := db.Create(&Comment{PostID: post.ID, Body: "First"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Delete(&post).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var left int64
|
||||
if err := db.Model(&Comment{}).Where("post_id = ?", post.ID).Count(&left).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if left != 0 {
|
||||
t.Fatalf("%d comments left after the post was deleted", left)
|
||||
}
|
||||
}
|
||||
|
||||
// publishPost renames a post in a transaction and notifies after commit.
|
||||
// fail makes the transaction roll back.
|
||||
func publishPost(ctx context.Context, db *gorm.DB, id uint, fail bool, log *[]string) error {
|
||||
// docs:start publish
|
||||
return lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error {
|
||||
if err := tx.Model(&Post{}).Where("id = ?", id).Update("title", "Published").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
lagoon.AfterCommit(ctx, tx, func(ctx context.Context, db *gorm.DB) {
|
||||
*log = append(*log, fmt.Sprintf("post %d published", id)) // broadcast, index, send mail...
|
||||
})
|
||||
if fail {
|
||||
return errors.New("rolled back") // the AfterCommit work never runs
|
||||
}
|
||||
return nil
|
||||
})
|
||||
// docs:end publish
|
||||
}
|
||||
|
||||
// nestedPublish runs a savepoint inside a transaction; a failing savepoint
|
||||
// drops its own AfterCommit work only.
|
||||
func nestedPublish(ctx context.Context, db *gorm.DB, log *[]string) error {
|
||||
// docs:start nested
|
||||
return lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error {
|
||||
lagoon.AfterCommit(ctx, tx, func(context.Context, *gorm.DB) { *log = append(*log, "outer") })
|
||||
|
||||
// A nested Transaction is a savepoint. Pass it the outer tx: given
|
||||
// the root db handle it returns an error instead.
|
||||
_ = lagoon.Transaction(ctx, tx, func(ctx context.Context, tx *gorm.DB) error {
|
||||
lagoon.AfterCommit(ctx, tx, func(context.Context, *gorm.DB) { *log = append(*log, "dropped") })
|
||||
return errors.New("savepoint rolled back")
|
||||
})
|
||||
return lagoon.Transaction(ctx, tx, func(ctx context.Context, tx *gorm.DB) error {
|
||||
lagoon.AfterCommit(ctx, tx, func(context.Context, *gorm.DB) { *log = append(*log, "inner") })
|
||||
return nil
|
||||
})
|
||||
})
|
||||
// docs:end nested
|
||||
}
|
||||
|
||||
// TestDocsTransactions runs the publish and nested regions of the
|
||||
// Transactions page, and checks the rules the page states.
|
||||
func TestDocsTransactions(t *testing.T) {
|
||||
db := lagoon.DocsDB(t, "docs_transactions")
|
||||
if err := lagoon.Migrate(db, []party.Plugin{&BlogPlugin{}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
post := Post{Title: "Draft"}
|
||||
if err := db.Create(&post).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx := t.Context()
|
||||
|
||||
var log []string
|
||||
if err := publishPost(ctx, db, post.ID, true, &log); err == nil || len(log) != 0 {
|
||||
t.Fatalf("rolled-back publish: err %v, log %q", err, log)
|
||||
}
|
||||
if err := publishPost(ctx, db, post.ID, false, &log); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := fmt.Sprintf("post %d published", post.ID); len(log) != 1 || log[0] != want {
|
||||
t.Fatalf("log = %q, want %q", log, want)
|
||||
}
|
||||
|
||||
log = nil
|
||||
if err := nestedPublish(ctx, db, &log); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Join(log, ",") != "outer,inner" {
|
||||
t.Fatalf("nested log = %q, want outer,inner", log)
|
||||
}
|
||||
|
||||
// A nested Transaction given the root handle fails without running.
|
||||
ran := false
|
||||
err := lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error {
|
||||
return lagoon.Transaction(ctx, db, func(context.Context, *gorm.DB) error {
|
||||
ran = true
|
||||
return nil
|
||||
})
|
||||
})
|
||||
if err == nil || ran {
|
||||
t.Fatalf("nested Transaction on the root handle: err %v, ran %v", err, ran)
|
||||
}
|
||||
|
||||
// Inside a plain GORM transaction AfterCommit skips the work.
|
||||
log = nil
|
||||
if err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
lagoon.AfterCommit(ctx, tx, func(context.Context, *gorm.DB) { log = append(log, "foreign") })
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(log) != 0 {
|
||||
t.Fatalf("AfterCommit ran inside a plain GORM transaction: %q", log)
|
||||
}
|
||||
}
|
||||
|
||||
// installCallbacks registers a GORM callback from a plugin's Boot through
|
||||
// OnDatabase; its after-commit work runs once the insert commits.
|
||||
func installCallbacks(app *backpack.App, log *[]string) error {
|
||||
// docs:start on-database
|
||||
return lagoon.OnDatabase(app, func(_ *sql.DB, gdb *gorm.DB) error {
|
||||
return gdb.Callback().Create().After("gorm:create").Before("gorm:commit_or_rollback_transaction").Register("acme:post_created", func(db *gorm.DB) {
|
||||
post, ok := db.Statement.Dest.(*Post)
|
||||
if db.Error != nil || !ok {
|
||||
return
|
||||
}
|
||||
lagoon.AfterCommit(db.Statement.Context, db, func(ctx context.Context, db *gorm.DB) {
|
||||
*log = append(*log, "created "+post.Slug) // runs only once the insert is committed
|
||||
})
|
||||
})
|
||||
})
|
||||
// docs:end on-database
|
||||
}
|
||||
|
||||
// TestDocsOnDatabase runs the on-database region of the Transactions page.
|
||||
func TestDocsOnDatabase(t *testing.T) {
|
||||
db := lagoon.DocsDB(t, "docs_on_database")
|
||||
if err := lagoon.Migrate(db, []party.Plugin{&BlogPlugin{}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app := backpack.New(nil)
|
||||
var log []string
|
||||
if err := installCallbacks(app, &log); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := lagoon.Publish(app, sqlDB, db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&Post{Title: "Hello"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(log) != 1 || log[0] != "created hello" {
|
||||
t.Fatalf("log = %q", log)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDocsModelDeclarations checks the model and plugin declarations the
|
||||
// Database pages show, without a database.
|
||||
func TestDocsModelDeclarations(t *testing.T) {
|
||||
if got := (Post{}).TableName(); got != "acme_blog_posts" {
|
||||
t.Fatalf("TableName = %q", got)
|
||||
}
|
||||
if got := (Post{}).Hidden(); len(got) != 3 {
|
||||
t.Fatalf("Hidden = %q", got)
|
||||
}
|
||||
post := &Post{Title: " Hello World "}
|
||||
if err := post.BeforeCreate(nil); err != nil || post.Slug != "hello-world" {
|
||||
t.Fatalf("BeforeCreate: slug %q, err %v", post.Slug, err)
|
||||
}
|
||||
if err := post.BeforeDelete(nil); err == nil {
|
||||
t.Fatal("BeforeDelete accepted a nil transaction")
|
||||
}
|
||||
if n := len((&BlogPlugin{}).Migrations()); n != 2 {
|
||||
t.Fatalf("Migrations() = %d, want 2", n)
|
||||
}
|
||||
}
|
||||
21
modules/lagoon/export_docs_test.go
Normal file
21
modules/lagoon/export_docs_test.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package lagoon
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// DocsDB returns a GORM handle on a fresh ICU pl-PL database named name in
|
||||
// this package's Postgres harness, 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 DocsDB(t *testing.T, name string) *gorm.DB {
|
||||
t.Helper()
|
||||
db, _ := dedicatedDB(t, name)
|
||||
gdb, err := Use(t.Context(), db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return gdb
|
||||
}
|
||||
78
modules/phrasebook/example_test.go
Normal file
78
modules/phrasebook/example_test.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package phrasebook_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing/fstest"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/phrasebook"
|
||||
"git.golem15.com/golem15/summercms/modules/towel"
|
||||
)
|
||||
|
||||
// langFS stands in for the plugin's embedded lang directory.
|
||||
var langFS = fstest.MapFS{
|
||||
"lang/en/posts.yaml": {Data: []byte(`title: Posts
|
||||
greeting: "Hello, :name"
|
||||
shout: "Welcome, :NAME"
|
||||
count:
|
||||
one: ":count post"
|
||||
other: ":count posts"
|
||||
drafts: "{0} No drafts|{1} One draft|[2,*] :count drafts"
|
||||
`)},
|
||||
"lang/pl/posts.yaml": {Data: []byte(`title: Posty
|
||||
count:
|
||||
one: ":count post"
|
||||
few: ":count posty"
|
||||
many: ":count postów"
|
||||
other: ":count posta"
|
||||
`)},
|
||||
}
|
||||
|
||||
func ExampleTranslator_Get() {
|
||||
cat := phrasebook.NewCatalog()
|
||||
if err := cat.Load("acme.blog", langFS); err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
tr := phrasebook.NewTranslator(cat, phrasebook.Options{Locale: "en", Fallback: "en"})
|
||||
|
||||
// surf stores the request locale on the context; here the example does.
|
||||
ctx := towel.WithLocale(context.Background(), "pl")
|
||||
fmt.Println(tr.Get(ctx, "acme.blog::posts.title", nil))
|
||||
// pl has no greeting, so the fallback locale answers.
|
||||
fmt.Println(tr.Get(ctx, "acme.blog::posts.greeting", map[string]string{"name": "Ada"}))
|
||||
fmt.Println(tr.GetIn("en", "acme.blog::posts.shout", map[string]string{"name": "Ada"}))
|
||||
// A missing key comes back as the key.
|
||||
fmt.Println(tr.Get(ctx, "acme.blog::posts.missing", nil))
|
||||
// Output:
|
||||
// Posty
|
||||
// Hello, Ada
|
||||
// Welcome, ADA
|
||||
// acme.blog::posts.missing
|
||||
}
|
||||
|
||||
func ExampleTranslator_Choice() {
|
||||
cat := phrasebook.NewCatalog()
|
||||
if err := cat.Load("acme.blog", langFS); err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
tr := phrasebook.NewTranslator(cat, phrasebook.Options{Locale: "en", Fallback: "en"})
|
||||
|
||||
for _, n := range []int{1, 3, 5, 22} {
|
||||
fmt.Println(tr.ChoiceIn("pl", "acme.blog::posts.count", n, nil))
|
||||
}
|
||||
fmt.Println(tr.ChoiceIn("en", "acme.blog::posts.count", 5, nil))
|
||||
for _, n := range []int{0, 1, 7} {
|
||||
fmt.Println(tr.ChoiceIn("en", "acme.blog::posts.drafts", n, nil))
|
||||
}
|
||||
// Output:
|
||||
// 1 post
|
||||
// 3 posty
|
||||
// 5 postów
|
||||
// 22 posty
|
||||
// 5 posts
|
||||
// No drafts
|
||||
// One draft
|
||||
// 7 drafts
|
||||
}
|
||||
73
modules/postcard/example_test.go
Normal file
73
modules/postcard/example_test.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package postcard_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing/fstest"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/postcard"
|
||||
)
|
||||
|
||||
// mailFS stands in for the plugin's embedded views/mail directory.
|
||||
var mailFS = fstest.MapFS{
|
||||
"views/mail/welcome.htm": {Data: []byte(`subject = "Welcome, {{ .name }}"
|
||||
layout = "blog"
|
||||
description = "Sent after registration"
|
||||
==
|
||||
Hi **{{ .name }}**, thanks for joining the blog.
|
||||
`)},
|
||||
"views/mail/layouts/blog.htm": {Data: []byte(`name = "Blog"
|
||||
==
|
||||
{{ .Content }}
|
||||
|
||||
-- The Acme blog
|
||||
==
|
||||
<div class="blog-mail">{{ .Content }}</div>
|
||||
<style>{{ .brandCss }}{{ .css }}</style>
|
||||
`)},
|
||||
}
|
||||
|
||||
func ExampleMailer_Send() {
|
||||
// At boot, postcard.BootPlugin registers what pact.HasMailTemplates
|
||||
// declares; a test registers the same thing directly.
|
||||
cat := postcard.NewCatalog()
|
||||
err := cat.Register("acme.blog", mailFS,
|
||||
[]string{"acme.blog::mail.welcome"},
|
||||
map[string]string{"blog": "acme.blog::mail.layouts.blog"})
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
driver := postcard.NewMemoryDriver() // mail.driver: memory
|
||||
mailer := postcard.NewMailer(cat, driver, postcard.Options{From: "blog@example.com"})
|
||||
|
||||
err = mailer.Send(context.Background(), postcard.Message{
|
||||
Template: "acme.blog::mail.welcome",
|
||||
To: []string{"ada@example.com"},
|
||||
Vars: map[string]any{"name": "Ada"},
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
sent := driver.Messages()[0]
|
||||
fmt.Println(sent.From, sent.To, sent.Subject)
|
||||
fmt.Println(sent.Text)
|
||||
fmt.Println(strings.Contains(sent.HTML, `<div class="blog-mail"><p>Hi <strong>Ada</strong>`))
|
||||
|
||||
// Header injection is refused before any driver sees the message.
|
||||
err = mailer.Send(context.Background(), postcard.Message{
|
||||
Template: "acme.blog::mail.welcome",
|
||||
To: []string{"ada@example.com\r\nBcc: all@example.com"},
|
||||
Vars: map[string]any{"name": "Ada"},
|
||||
})
|
||||
fmt.Println(err != nil, len(driver.Messages()))
|
||||
// Output:
|
||||
// blog@example.com [ada@example.com] Welcome, Ada
|
||||
// Hi **Ada**, thanks for joining the blog.
|
||||
//
|
||||
// -- The Acme blog
|
||||
// true
|
||||
// true 1
|
||||
}
|
||||
208
modules/surf/example_test.go
Normal file
208
modules/surf/example_test.go
Normal file
@@ -0,0 +1,208 @@
|
||||
package surf_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
"git.golem15.com/golem15/summercms/modules/surf"
|
||||
"git.golem15.com/golem15/summercms/modules/wire"
|
||||
)
|
||||
|
||||
// secret signs the example's tokens. A real application reads its JWT
|
||||
// secret from configuration.
|
||||
const secret = "example-secret-that-is-long-enough"
|
||||
|
||||
// users loads the principal behind a token subject.
|
||||
type users struct{}
|
||||
|
||||
func (users) FindByID(ctx context.Context, id uint) (*bouncer.Principal, error) {
|
||||
return &bouncer.Principal{ID: id}, nil
|
||||
}
|
||||
|
||||
// BlogPlugin is the acme.blog plugin; only its HTTP surface is shown here.
|
||||
type BlogPlugin struct {
|
||||
trusted []netip.Prefix
|
||||
}
|
||||
|
||||
func (p *BlogPlugin) ID() string { return "acme.blog" }
|
||||
func (p *BlogPlugin) Requires() []string { return nil }
|
||||
|
||||
// Register reads http.trusted_proxies once, for the plugin's bucket keys.
|
||||
func (p *BlogPlugin) Register(app *backpack.App) error {
|
||||
p.trusted = surf.TrustedProxies(app.Config)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *BlogPlugin) Boot(app *backpack.App) error { return nil }
|
||||
|
||||
// Middlewares registers the plugin's named middleware: here, a JWT guard
|
||||
// that answers 401 when the request has no valid token.
|
||||
func (p *BlogPlugin) Middlewares() map[string]pact.Middleware {
|
||||
guards := bouncer.NewRegistry()
|
||||
guard := bouncer.NewJWTGuard(secret, users{}, bouncer.NewMemoryBlacklist())
|
||||
if err := guards.Register(p.ID(), "acme.auth", guard); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
auth, err := guards.Middleware("acme.auth")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return map[string]pact.Middleware{"acme.auth": auth}
|
||||
}
|
||||
|
||||
// Buckets declares a named rate limit, used as throttle:blog.comments.
|
||||
func (p *BlogPlugin) Buckets() map[string]surf.Bucket {
|
||||
return map[string]surf.Bucket{
|
||||
"blog.comments": {
|
||||
Max: 1,
|
||||
Decay: time.Minute,
|
||||
Key: func(r *http.Request) string { return "comments|" + surf.ClientIP(r, p.trusted) },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Routes is the Go form of the plugin's routes.php.
|
||||
func (p *BlogPlugin) Routes(r pact.Router) error {
|
||||
r.Group("/api/blog", surf.Use("throttle:60,1"), func(g pact.Router) {
|
||||
g.Get("/posts/{id}", showPost)
|
||||
g.Where("id", `[0-9]+`)
|
||||
g.Get("/posts/{status}/list", listPosts)
|
||||
g.WhereIn("status", "draft", "published")
|
||||
|
||||
// An auth group: every route inside needs a signed-in user.
|
||||
g.Group("", surf.Use("acme.auth"), func(auth pact.Router) {
|
||||
auth.Get("/me", showMe)
|
||||
auth.Post("/posts/{id}/comments", addComment, "throttle:blog.comments", "body.limit:65536")
|
||||
})
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func showPost(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := surf.IntParam(r, "id")
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
wire.WriteJSON(w, http.StatusOK, map[string]any{"id": id})
|
||||
}
|
||||
|
||||
func listPosts(w http.ResponseWriter, r *http.Request) {
|
||||
wire.WriteJSON(w, http.StatusOK, map[string]any{"status": r.PathValue("status"), "data": wire.Slice[string](nil)})
|
||||
}
|
||||
|
||||
func showMe(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := bouncer.User(r.Context())
|
||||
wire.WriteJSON(w, http.StatusOK, map[string]any{"id": user.ID})
|
||||
}
|
||||
|
||||
func addComment(w http.ResponseWriter, r *http.Request) {
|
||||
wire.WriteJSON(w, http.StatusCreated, map[string]any{"created": true})
|
||||
}
|
||||
|
||||
func ExampleAssemble() {
|
||||
// The application passes its config; http.body_limits is required there.
|
||||
app := backpack.New(nil)
|
||||
plugin := &BlogPlugin{}
|
||||
if err := plugin.Register(app); err != nil { // the runtime calls Register
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
h, err := surf.Assemble(app, []party.Plugin{plugin})
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
token, _, _ := bouncer.Mint(secret, "42", "http://127.0.0.1:8080/api/login", time.Hour)
|
||||
|
||||
do := func(method, path string, auth bool) {
|
||||
req := httptest.NewRequest(method, path, strings.NewReader("{}"))
|
||||
if auth {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
fmt.Println(method, path, rec.Code, strings.TrimSpace(rec.Body.String()))
|
||||
}
|
||||
do("GET", "/api/blog/posts/7", false)
|
||||
do("GET", "/api/blog/posts/seven", false)
|
||||
do("GET", "/api/blog/posts/draft/list", false)
|
||||
do("GET", "/api/blog/posts/deleted/list", false)
|
||||
do("GET", "/api/blog/me", false)
|
||||
do("GET", "/api/blog/me", true)
|
||||
do("POST", "/api/blog/posts/7/comments", true)
|
||||
do("POST", "/api/blog/posts/7/comments", true)
|
||||
// Output:
|
||||
// GET /api/blog/posts/7 200 {"id":7}
|
||||
// GET /api/blog/posts/seven 404 404 page not found
|
||||
// GET /api/blog/posts/draft/list 200 {"data":[],"status":"draft"}
|
||||
// GET /api/blog/posts/deleted/list 404 404 page not found
|
||||
// GET /api/blog/me 401 {"error":true,"message":"Token not provided"}
|
||||
// GET /api/blog/me 200 {"id":42}
|
||||
// POST /api/blog/posts/7/comments 201 {"created":true}
|
||||
// POST /api/blog/posts/7/comments 429 {"message":"Too Many Attempts."}
|
||||
}
|
||||
|
||||
func ExampleBuildRouter() {
|
||||
r, err := surf.BuildRouter(backpack.New(nil), []party.Plugin{&BlogPlugin{}})
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
for _, rt := range r.Routes() {
|
||||
fmt.Println(rt.Method, rt.Pattern, rt.PluginID, rt.Middleware)
|
||||
}
|
||||
// Output:
|
||||
// GET /api/blog/posts/{id} acme.blog [throttle:60,1]
|
||||
// GET /api/blog/posts/{status}/list acme.blog [throttle:60,1]
|
||||
// GET /api/blog/me acme.blog [throttle:60,1 acme.auth]
|
||||
// POST /api/blog/posts/{id}/comments acme.blog [throttle:60,1 acme.auth throttle:blog.comments body.limit:65536]
|
||||
}
|
||||
|
||||
func ExampleClientIP() {
|
||||
cfg, err := compass.Open(compass.Options{Dir: "config", Env: "development", Environ: []string{}})
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
_ = cfg.Set("http.trusted_proxies", []string{"10.0.0.0/8"})
|
||||
trusted := surf.TrustedProxies(cfg)
|
||||
|
||||
// Through the load balancer at 10.0.0.5: the forwarded client is used.
|
||||
viaProxy := httptest.NewRequest("GET", "/api/blog/posts", nil)
|
||||
viaProxy.RemoteAddr = "10.0.0.5:4711"
|
||||
viaProxy.Header.Set("X-Forwarded-For", "198.51.100.23, 10.0.0.9")
|
||||
fmt.Println(surf.ClientIP(viaProxy, trusted))
|
||||
|
||||
// Straight from the internet: a forged header is ignored.
|
||||
direct := httptest.NewRequest("GET", "/api/blog/posts", nil)
|
||||
direct.RemoteAddr = "203.0.113.7:5000"
|
||||
direct.Header.Set("X-Forwarded-For", "127.0.0.1")
|
||||
fmt.Println(surf.ClientIP(direct, trusted))
|
||||
// Output:
|
||||
// 198.51.100.23
|
||||
// 203.0.113.7
|
||||
}
|
||||
|
||||
// TestDocsDeclarations checks the plugin declarations the Routing and Rate
|
||||
// limiting pages show.
|
||||
func TestDocsDeclarations(t *testing.T) {
|
||||
p := &BlogPlugin{}
|
||||
if _, ok := p.Middlewares()["acme.auth"]; !ok {
|
||||
t.Fatal("Middlewares() has no acme.auth")
|
||||
}
|
||||
if b := p.Buckets()["blog.comments"]; b.Max != 1 || b.Decay != time.Minute {
|
||||
t.Fatalf("Buckets() = %+v", b)
|
||||
}
|
||||
}
|
||||
45
modules/wire/example_test.go
Normal file
45
modules/wire/example_test.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package wire_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/wire"
|
||||
)
|
||||
|
||||
// postJSON is the response shape of one acme.blog post.
|
||||
type postJSON struct {
|
||||
ID uint `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Tags []string `json:"tags"`
|
||||
Featured wire.TriBool `json:"featured"`
|
||||
Pinned wire.TriBool `json:"pinned"`
|
||||
PublishedAt wire.Time `json:"published_at"`
|
||||
}
|
||||
|
||||
func ExampleWriteJSON() {
|
||||
var tags []string // nil: the post has no tags
|
||||
warsaw := time.FixedZone("CEST", 2*60*60)
|
||||
body := postJSON{
|
||||
ID: 1,
|
||||
Title: "Tips & <tricks>",
|
||||
Tags: wire.Slice(tags),
|
||||
Featured: wire.TriBool{},
|
||||
Pinned: wire.TriBool{Value: true, Valid: true},
|
||||
PublishedAt: wire.Time{Time: time.Date(2026, 9, 30, 14, 5, 0, 0, warsaw)},
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
wire.WriteJSON(rec, http.StatusOK, map[string]any{"data": body})
|
||||
fmt.Println(rec.Code, rec.Header().Get("Content-Type"))
|
||||
fmt.Printf("%s|\n", rec.Body.String())
|
||||
|
||||
rec = httptest.NewRecorder()
|
||||
wire.WriteOpaque500(rec)
|
||||
fmt.Println(rec.Code, rec.Body.String())
|
||||
// Output:
|
||||
// 200 application/json
|
||||
// {"data":{"id":1,"title":"Tips & <tricks>","tags":[],"featured":null,"pinned":true,"published_at":"2026-09-30T12:05:00+00:00"}}|
|
||||
// 500 {"error":true,"message":"Internal server error"}
|
||||
}
|
||||
84
modules/wristband/example_test.go
Normal file
84
modules/wristband/example_test.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package wristband_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http/httptest"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/wristband"
|
||||
)
|
||||
|
||||
// newServer builds the authorization server of an application served at
|
||||
// https://blog.example.com. The application sets Issuer and Resource for
|
||||
// its own deployment; the defaults cover everything else.
|
||||
func newServer() *wristband.Server {
|
||||
opts := wristband.DefaultOptions()
|
||||
opts.Issuer = "https://blog.example.com"
|
||||
opts.Resource = "https://blog.example.com/mcp"
|
||||
opts.ScopesSupported = []string{"read", "write", "offline_access"}
|
||||
return wristband.NewServer(opts)
|
||||
}
|
||||
|
||||
func ExampleServer_Metadata() {
|
||||
srv := newServer()
|
||||
// srv.SetBackend(backend) attaches the application's stores; the
|
||||
// metadata document does not need them.
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Metadata(rec, httptest.NewRequest("GET", "/.well-known/oauth-authorization-server", nil))
|
||||
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &doc); err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
for _, key := range []string{
|
||||
"issuer",
|
||||
"authorization_endpoint",
|
||||
"token_endpoint",
|
||||
"registration_endpoint",
|
||||
"scopes_supported",
|
||||
"grant_types_supported",
|
||||
"code_challenge_methods_supported",
|
||||
} {
|
||||
fmt.Println(key, doc[key])
|
||||
}
|
||||
// Output:
|
||||
// issuer https://blog.example.com
|
||||
// authorization_endpoint https://blog.example.com/oauth/mcp/authorize
|
||||
// token_endpoint https://blog.example.com/oauth/mcp/token
|
||||
// registration_endpoint https://blog.example.com/oauth/mcp/register
|
||||
// scopes_supported [read write offline_access]
|
||||
// grant_types_supported [authorization_code refresh_token]
|
||||
// code_challenge_methods_supported [S256]
|
||||
}
|
||||
|
||||
func ExampleRejectRedirectURI() {
|
||||
for _, uri := range []string{
|
||||
"https://client.example.org/callback",
|
||||
"http://127.0.0.1:33418/callback",
|
||||
"http://client.example.org/callback",
|
||||
} {
|
||||
if reason := wristband.RejectRedirectURI(uri); reason != "" {
|
||||
fmt.Println("rejected:", reason)
|
||||
continue
|
||||
}
|
||||
fmt.Println("accepted:", uri)
|
||||
}
|
||||
// Output:
|
||||
// accepted: https://client.example.org/callback
|
||||
// accepted: http://127.0.0.1:33418/callback
|
||||
// rejected: Redirect URI must be https:// or loopback http://127.0.0.1 / http://localhost: http://client.example.org/callback
|
||||
}
|
||||
|
||||
func ExampleIssueClientCredentials() {
|
||||
// A confidential client gets a secret, shown once; store only the hash.
|
||||
id, secret, hash, err := wristband.IssueClientCredentials("client_secret_post")
|
||||
fmt.Println(id != "", secret != "", hash != nil && *hash != secret, err)
|
||||
|
||||
// A public client (PKCE only) gets no secret.
|
||||
_, secret, hash, err = wristband.IssueClientCredentials("none")
|
||||
fmt.Println(secret == "", hash == nil, err)
|
||||
// Output:
|
||||
// true true true <nil>
|
||||
// true true <nil>
|
||||
}
|
||||
Reference in New Issue
Block a user