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:
Jakub Zych
2026-09-30 22:59:25 +02:00
parent 9d37d56486
commit efb35a2d35
28 changed files with 3138 additions and 0 deletions

View 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
}

View 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
}

View File

@@ -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>
}

View 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
}

View 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)
}
}

View 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
}

View 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
}

View 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
}

View 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)
}
}

View 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"}
}

View 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>
}