# 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 ```bash go mod init myapp ``` ### 2. Install PocketBase SDK ```bash go get github.com/pocketbase/pocketbase@latest ``` ### 3. Basic PocketBase App Create `main.go`: ```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 ```bash go run main.go pocketbase.go serve --http=0.0.0.0:8090 ``` ## Core Concepts ### Event Hooks Execute code on specific events: ```go // 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](https://pocketbase.io/docs/go-event-hooks/)). 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 ```go 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 ```go 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 ```go 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 ```go 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 ```go 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 ```go 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 ```go record, err := app.FindRecordById("posts", "id") if err != nil { return err } if err := app.Delete(record); err != nil { return err } ``` ### Query Builder ```go 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 ```go // 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 ```go // 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 ```go // 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 ```go // 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 ```go // 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 ```go // 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 ```go 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 ```go // 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 ```go 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 ```go 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 ```bash # Build go build -o myapp main.go # Run ./myapp serve --http=0.0.0.0:8090 ``` ### Docker Deployment ```dockerfile 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 ```go 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 ```go 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 ```go 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 ```go 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 ```go 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 ```go 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 ```go 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](go_event_hooks.md) - Detailed hook documentation - [Database](go_database.md) - Database operations - [Routing](go_routing.md) - Custom API endpoints - [Migrations](go_migrations.md) - Database migrations - [Testing](go_testing.md) - Testing strategies - [Logging](go_logging.md) - Logging and monitoring