18 KiB
18 KiB
Go Overview - PocketBase
Overview
PocketBase can be extended using Go, allowing you to:
- Add custom API endpoints
- Implement event hooks
- Create custom database migrations
- Build scheduled jobs
- Add custom middleware
- Integrate external services
- Extend authentication
Project Structure
myapp/
├── go.mod
├── pocketbase.go
├── migrations/
│ └── 1703123456_initial.go
├── hooks/
│ └── hooks.go
└── main.go
Creating a Go Extension
1. Initialize Go Module
go mod init myapp
2. Install PocketBase SDK
go get github.com/pocketbase/pocketbase@latest
3. Basic PocketBase App
Create main.go:
package main
import (
"log"
"net/http"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/core"
)
func main() {
app := pocketbase.New()
// Add custom API endpoint
app.OnServe().BindFunc(func(se *core.ServeEvent) error {
// Custom routes
se.Router.GET("/api/hello", func(e *core.RequestEvent) error {
return e.JSON(200, map[string]string{
"message": "Hello from Go!",
})
})
return se.Next()
})
// Start the app
if err := app.Start(); err != nil {
log.Fatal(err)
}
}
4. Run the Application
go run main.go pocketbase.go serve --http=0.0.0.0:8090
Core Concepts
Event Hooks
Execute code on specific events:
// On record create
app.OnRecordCreate("posts").BindFunc(func(e *core.RecordCreateEvent) error {
log.Println("Post created:", e.Record.GetString("title"))
return e.Next()
})
// On record update
app.OnRecordUpdate("posts").BindFunc(func(e *core.RecordUpdateEvent) error {
log.Println("Post updated:", e.Record.GetString("title"))
return e.Next()
})
// On record delete
app.OnRecordDelete("posts").BindFunc(func(e *core.RecordDeleteEvent) error {
log.Println("Post deleted:", e.Record.GetString("title"))
return e.Next()
})
// On authentication
app.OnRecordAuth().BindFunc(func(e *core.RecordAuthEvent) error {
log.Println("User authenticated:", e.Record.GetString("email"))
return e.Next()
})
Event Arguments
PocketBase exposes a different event struct for each hook (see the official event hooks reference). Common fields you will interact with include:
e.App– the running PocketBase instance (database access, configuration, cron, etc.).e.Record– the record being created, updated, deleted, or authenticated.e.RecordOriginal– the previous value during update hooks.e.Next()– call to continue the handler chain after your logic.
Refer to the linked docs for the complete list of fields exposed by each event type.
Custom API Endpoints
Create GET Endpoint
app.OnServe().BindFunc(func(se *core.ServeEvent) error {
se.Router.GET("/api/stats", func(e *core.RequestEvent) error {
totalPosts, err := e.App.CountRecords("posts")
if err != nil {
return e.InternalServerError("failed to count posts", err)
}
totalUsers, err := e.App.CountRecords("users")
if err != nil {
return e.InternalServerError("failed to count users", err)
}
return e.JSON(200, map[string]any{
"total_posts": totalPosts,
"total_users": totalUsers,
"authenticated": e.Auth != nil,
})
})
return se.Next()
})
Create POST Endpoint
import "github.com/pocketbase/dbx"
app.OnServe().BindFunc(func(se *core.ServeEvent) error {
se.Router.POST("/api/search", func(e *core.RequestEvent) error {
// Parse request body
var req struct {
Query string `json:"query"`
}
if err := e.BindBody(&req); err != nil {
return e.BadRequestError("invalid body", err)
}
// Search posts with a safe filter
records, err := e.App.FindRecordsByFilter(
"posts",
"title ~ {:query}",
"-created",
50,
0,
dbx.Params{"query": req.Query},
)
if err != nil {
return e.InternalServerError("Search failed", err)
}
return e.JSON(200, map[string]any{
"results": records,
"count": len(records),
})
})
return se.Next()
})
Custom Middleware
app.OnServe().BindFunc(func(se *core.ServeEvent) error {
se.Router.BindFunc(func(e *core.RequestEvent) error {
// Add CORS headers
e.Response.Header().Set("Access-Control-Allow-Origin", "*")
e.Response.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE")
e.Response.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
if e.Request.Method == http.MethodOptions {
return e.NoContent(http.StatusOK)
}
return e.Next()
})
return se.Next()
})
Database Operations
Find Records
import "github.com/pocketbase/dbx"
// Find single record
record, err := app.FindRecordById("posts", "RECORD_ID")
// Find multiple records with a filter and pagination
records, err := app.FindRecordsByFilter(
"posts",
"status = {:status}",
"-created",
50,
0,
dbx.Params{"status": "published"},
)
// Custom query with the record query builder
records := []*core.Record{}
err := app.RecordQuery("posts").
AndWhere(dbx.Like("title", "pocketbase")).
OrderBy("created DESC").
Limit(50).
All(&records)
// Find with relations
record, err = app.FindRecordById("posts", "id")
if err == nil {
if errs := app.ExpandRecord(record, []string{"author", "comments"}, nil); len(errs) > 0 {
// handle expand error(s)
}
}
Create Records
collection, err := app.FindCollectionByNameOrId("posts")
if err != nil {
return err
}
record := core.NewRecord(collection)
record.Set("title", "My Post")
record.Set("content", "Post content")
record.Set("author", "USER_ID")
if err := app.Save(record); err != nil {
return err
}
Update Records
record, err := app.FindRecordById("posts", "id")
if err != nil {
return err
}
record.Set("title", "Updated Title")
record.Set("content", "Updated content")
if err := app.Save(record); err != nil {
return err
}
Delete Records
record, err := app.FindRecordById("posts", "id")
if err != nil {
return err
}
if err := app.Delete(record); err != nil {
return err
}
Query Builder
records := []*core.Record{}
err := app.RecordQuery("posts").
AndWhere(dbx.HashExp{"status": "published"}).
AndWhere(dbx.NewExp("created >= {:date}", dbx.Params{"date": "2024-01-01"})).
OrderBy("created DESC").
Offset(0).
Limit(50).
All(&records)
// Combine conditions with OR
records = []*core.Record{}
err = app.RecordQuery("posts").
AndWhere(dbx.Or(
dbx.HashExp{"status": "published"},
dbx.HashExp{"author": userId},
)).
All(&records)
Event Hooks Examples
Auto-populate Fields
// Auto-set author on post create
app.OnRecordCreateRequest("posts").BindFunc(func(e *core.RecordRequestEvent) error {
if e.Auth != nil {
e.Record.Set("author", e.Auth.Id)
}
return e.Next()
})
// Auto-set slug from title
app.OnRecordCreate("posts").BindFunc(func(e *core.RecordCreateEvent) error {
title := e.Record.GetString("title")
slug := strings.ToLower(strings.ReplaceAll(title, " ", "-"))
e.Record.Set("slug", slug)
return e.Next()
})
Validation
// Custom validation
app.OnRecordCreate("posts").BindFunc(func(e *core.RecordCreateEvent) error {
title := e.Record.GetString("title")
if len(title) < 5 {
return errors.New("title must be at least 5 characters")
}
return e.Next()
})
// Check permissions
app.OnRecordCreateRequest("posts").BindFunc(func(e *core.RecordRequestEvent) error {
if e.Auth == nil {
return e.ForbiddenError("authentication required", nil)
}
role := e.Auth.GetString("role")
if role != "admin" && role != "author" {
return e.ForbiddenError("insufficient permissions", nil)
}
return e.Next()
})
Cascading Updates
// When post is updated, update related comments
app.OnRecordUpdate("posts").BindFunc(func(e *core.RecordUpdateEvent) error {
// Check if status changed
oldStatus := e.RecordOriginal.GetString("status")
newStatus := e.Record.GetString("status")
if oldStatus != newStatus && newStatus == "published" {
comments, err := e.App.FindRecordsByFilter(
"comments",
"post = {:postId}",
"",
0,
0,
dbx.Params{"postId": e.Record.Id},
)
if err != nil {
return err
}
for _, comment := range comments {
comment.Set("status", "approved")
if err := e.App.Save(comment); err != nil {
return err
}
}
}
return e.Next()
})
Send Notifications
// Send email when post is published
app.OnRecordUpdate("posts").BindFunc(func(e *core.RecordUpdateEvent) error {
oldStatus := e.RecordOriginal.GetString("status")
newStatus := e.Record.GetString("status")
if oldStatus != "published" && newStatus == "published" {
author, err := e.App.FindRecordById("users", e.Record.GetString("author"))
if err == nil {
log.Println("Sending notification to:", author.GetString("email"))
}
}
return e.Next()
})
Log Activities
// Log all record changes using the builtin logger
app.OnRecordCreate("posts").BindFunc(func(e *core.RecordCreateEvent) error {
e.App.Logger().Info("post created", "recordId", e.Record.Id)
return e.Next()
})
app.OnRecordUpdate("posts").BindFunc(func(e *core.RecordUpdateEvent) error {
e.App.Logger().Info(
"post updated",
"recordId", e.Record.Id,
"statusFrom", e.RecordOriginal.GetString("status"),
"statusTo", e.Record.GetString("status"),
)
return e.Next()
})
app.OnRecordDelete("posts").BindFunc(func(e *core.RecordDeleteEvent) error {
e.App.Logger().Info("post deleted", "recordId", e.Record.Id)
return e.Next()
})
Scheduled Jobs
Create Background Job
// Register job
app.Cron().MustAdd("daily-backup", "0 2 * * *", func() {
log.Println("Running daily backup...")
backupDir := "./backups"
if err := os.MkdirAll(backupDir, 0o755); err != nil {
log.Println("Backup failed:", err)
return
}
// Your backup logic here
log.Println("Backup completed")
})
// Or add a job during serve
app.OnServe().BindFunc(func(se *core.ServeEvent) error {
se.App.Cron().MustAdd("cleanup", "@every 5m", func() {
log.Println("Running cleanup task...")
// Cleanup logic
})
return se.Next()
})
File Handling
Custom File Upload
app.OnServe().BindFunc(func(se *core.ServeEvent) error {
se.Router.POST("/api/upload", func(e *core.RequestEvent) error {
files, err := e.FindUploadedFiles("file")
if err != nil {
return e.BadRequestError("no file uploaded", err)
}
collection, err := e.App.FindCollectionByNameOrId("uploads")
if err != nil {
return e.NotFoundError("uploads collection not found", err)
}
record := core.NewRecord(collection)
// Attach the uploaded file(s) to a file field
record.Set("document", files)
if err := e.App.Save(record, files...); err != nil {
return e.InternalServerError("failed to save file", err)
}
return e.JSON(http.StatusOK, record)
})
return se.Next()
})
Custom Auth Provider
// Custom OAuth provider
app.OnRecordAuthWithOAuth2().BindFunc(func(e *core.RecordAuthWithOAuth2Event) error {
if e.Provider != "custom" {
return e.Next()
}
// Fetch user info from custom provider
userInfo, err := fetchCustomUserInfo(e.OAuth2UserData)
if err != nil {
return err
}
// Find or create user
user, err := e.App.FindAuthRecordByData("users", "email", userInfo.Email)
if err != nil {
collection, err := e.App.FindCollectionByNameOrId("users")
if err != nil {
return err
}
user = core.NewRecord(collection)
user.Set("email", userInfo.Email)
user.Set("password", "") // OAuth users don't need password
user.Set("emailVisibility", false)
user.Set("verified", true)
user.Set("name", userInfo.Name)
if err := e.App.Save(user); err != nil {
return err
}
}
e.Record = user
return e.Next()
})
Testing
Unit Tests
package main
import (
"testing"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tests"
)
func TestCustomEndpoint(t *testing.T) {
app := pocketbase.NewWithConfig(config{})
// Add test endpoint
app.OnServe().BindFunc(func(se *core.ServeEvent) error {
se.Router.GET("/api/test", func(e *core.RequestEvent) error {
return e.JSON(200, map[string]string{
"status": "ok",
})
})
return se.Next()
})
e := tests.NewRequestEvent(app, nil)
// Test endpoint
e.GET("/api/test").Expect(t).Status(200).JSON().Equal(map[string]interface{}{
"status": "ok",
})
}
Integration Tests
func TestRecordCreation(t *testing.T) {
app := pocketbase.New()
app.MustSeed()
client := tests.NewClient(app)
// Test authenticated request
auth := client.AuthRecord("users", "test@example.com", "password")
post := client.CreateRecord("posts", map[string]interface{}{
"title": "Test Post",
"content": "Test content",
}, auth.Token)
if post.GetString("title") != "Test Post" {
t.Errorf("Expected title 'Test Post', got %s", post.GetString("title"))
}
}
Deployment
Build and Run
# Build
go build -o myapp main.go
# Run
./myapp serve --http=0.0.0.0:8090
Docker Deployment
FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY . .
RUN go mod download
RUN go build -o myapp main.go
FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY --from=builder /app/myapp .
COPY --from=builder /app/pocketbase ./
CMD ["./myapp", "serve", "--http=0.0.0.0:8090"]
Best Practices
1. Error Handling
app.OnRecordCreate("posts").BindFunc(func(e *core.RecordCreateEvent) error {
if err := validatePost(e.Record); err != nil {
return err
}
return e.Next()
})
func validatePost(record *core.Record) error {
title := record.GetString("title")
if len(title) == 0 {
return errors.New("title is required")
}
if len(title) > 200 {
return errors.New("title too long")
}
return nil
}
2. Logging
app.OnRecordCreate("posts").BindFunc(func(e *core.RecordCreateEvent) error {
e.App.Logger().Info("Post created",
"id", e.Record.Id,
"title", e.Record.GetString("title"),
)
return e.Next()
})
3. Security
app.OnServe().BindFunc(func(se *core.ServeEvent) error {
limiter := rate.NewLimiter(10, 20) // 10 req/sec, burst 20
se.Router.BindFunc(func(e *core.RequestEvent) error {
if !limiter.Allow() {
return e.TooManyRequestsError("rate limit exceeded", nil)
}
return e.Next()
})
return se.Next()
})
4. Configuration
type Config struct {
ExternalAPIKey string
EmailFrom string
}
func (c Config) Name() string {
return "myapp"
}
func main() {
app := pocketbase.NewWithConfig(Config{
ExternalAPIKey: os.Getenv("API_KEY"),
EmailFrom: "noreply@example.com",
})
// Use config in hooks
app.OnRecordCreate("posts").BindFunc(func(e *core.RecordCreateEvent) error {
cfg := e.App.Config().(*Config)
// Use cfg.ExternalAPIKey
return e.Next()
})
}
Common Patterns
1. Soft Delete
app.OnRecordDelete("posts").BindFunc(func(e *core.RecordDeleteEvent) error {
// Instead of deleting, mark as deleted
e.Record.Set("status", "deleted")
e.Record.Set("deleted_at", time.Now())
if err := e.App.Save(e.Record); err != nil {
return err
}
return e.Next()
})
2. Audit Trail
app.OnRecordCreateRequest("").BindFunc(func(e *core.RecordRequestEvent) error {
if e.Collection.Name == "posts" || e.Collection.Name == "comments" {
if e.Auth != nil {
e.Record.Set("created_by", e.Auth.Id)
}
if info, err := e.RequestInfo(); err == nil {
e.Record.Set("created_ip", info.RealIP)
}
}
return e.Next()
})
app.OnRecordUpdateRequest("").BindFunc(func(e *core.RecordRequestEvent) error {
if e.Collection.Name == "posts" || e.Collection.Name == "comments" {
if e.Auth != nil {
e.Record.Set("updated_by", e.Auth.Id)
}
e.Record.Set("updated_at", time.Now())
}
return e.Next()
})
3. Data Synchronization
app.OnRecordCreate("posts").BindFunc(func(e *core.RecordCreateEvent) error {
// Sync with external service
if err := syncToExternalAPI(e.Record); err != nil {
e.App.Logger().Warn("sync failed", "error", err)
}
return e.Next()
})
func syncToExternalAPI(record *core.Record) error {
// Implement external API sync
return nil
}
Related Topics
- Event Hooks - Detailed hook documentation
- Database - Database operations
- Routing - Custom API endpoints
- Migrations - Database migrations
- Testing - Testing strategies
- Logging - Logging and monitoring