Skip to content

Instantly share code, notes, and snippets.

@garyblankenship
Created April 23, 2025 12:43
Show Gist options
  • Save garyblankenship/28b51d42edb52270fdede17204c24219 to your computer and use it in GitHub Desktop.
Save garyblankenship/28b51d42edb52270fdede17204c24219 to your computer and use it in GitHub Desktop.
PocketBase Blueprint
package main
import (
"bytes"
"encoding/json"
"flag"
"fmt"
"io"
"io/ioutil"
"log"
"net/http"
"net/url"
"os"
"path"
"regexp"
"strings"
"time"
"github.com/iancoleman/strcase"
"gopkg.in/yaml.v3"
)
// --- PocketBase API Structures ---
// Auth Response
type AdminAuthResponse struct {
Token string `json:"token"`
Admin struct {
ID string `json:"id"`
Email string `json:"email"`
} `json:"admin"`
}
// Collection List Item (Simplified)
type PBListCollectionItem struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
System bool `json:"system"`
Schema []PocketBaseField `json:"schema"` // Include schema for comparison/update
ListRule *string `json:"listRule"`
ViewRule *string `json:"viewRule"`
CreateRule *string `json:"createRule"`
UpdateRule *string `json:"updateRule"`
DeleteRule *string `json:"deleteRule"`
Options map[string]interface{} `json:"options"`
}
// API Response for listing collections
type PBListCollectionsResponse struct {
Page int `json:"page"`
PerPage int `json:"perPage"`
TotalItems int `json:"totalItems"`
TotalPages int `json:"totalPages"`
Items []PBListCollectionItem `json:"items"`
}
// Structures match the previous script (PocketBaseSchema, PocketBaseField, PocketBaseOptions)
type PocketBaseSchema struct {
ID string `json:"id,omitempty"` // Needed for updates if known
Name string `json:"name"`
Type string `json:"type"` // "base", "auth", "view"
System bool `json:"system"`
Schema []PocketBaseField `json:"schema"`
Indexes []string `json:"indexes,omitempty"` // Usually managed by PB
ListRule *string `json:"listRule"`
ViewRule *string `json:"viewRule"`
CreateRule *string `json:"createRule"`
UpdateRule *string `json:"updateRule"`
DeleteRule *string `json:"deleteRule"`
Options map[string]interface{} `json:"options"`
}
type PocketBaseField struct {
ID string `json:"id,omitempty"` // PB assigns this
Name string `json:"name"`
Type string `json:"type"`
System bool `json:"system"`
Required bool `json:"required"`
Presentable bool `json:"presentable"`
Unique bool `json:"unique"`
Options PocketBaseOptions `json:"options"`
}
type PocketBaseOptions map[string]interface{}
// --- Blueprint YAML Structures (same as before) ---
type BlueprintDraft struct {
Models map[string]map[string]interface{} `yaml:"models"`
Controllers interface{} `yaml:"controllers,omitempty"`
Seeders interface{} `yaml:"seeders,omitempty"`
}
// --- Globals and Regex (same as before) ---
var (
httpClient = &http.Client{Timeout: 15 * time.Second}
adminToken = "" // Store authenticated token globally
pbBaseURL = "" // Store base URL globally
fieldRegex = regexp.MustCompile(`^([\w:]+)(?:\(([^)]+)\))?((?:\s+\w+(?::'?[\w\s,]+'?)?)*)$`)
relationshipRegex = regexp.MustCompile(`^(\w+)\s+([\w\/]+)(.*)$`)
modifierRegex = regexp.MustCompile(`\s+(\w+)(?::'?([\w\s,]+)'?)?`)
)
// Intermediate structure to hold relation info before IDs are known
type PendingRelation struct {
OwnerCollectionName string // Name of the collection this field belongs to
FieldName string // Name of the relation field (snake_case)
TargetModelName string // Original Blueprint model name of the target
IsMultiple bool // Is this a hasMany/belongsToMany style relation?
Required bool // Is the relation required?
CascadeDelete bool // Should cascade delete be enabled?
}
func main() {
// --- Command Line Flags ---
inputFile := flag.String("i", "draft.yml", "Input Blueprint YAML file")
pbURL := flag.String("url", "http://127.0.0.1:8090", "PocketBase instance URL")
adminEmail := flag.String("email", "", "PocketBase Admin Email")
adminPassword := flag.String("password", "", "PocketBase Admin Password")
defaultListRule := flag.String("listRule", "", "Default PocketBase listRule (eg. \"@request.auth.id != ''\") - leave empty for null")
defaultViewRule := flag.String("viewRule", "", "Default PocketBase viewRule")
defaultCreateRule := flag.String("createRule", "", "Default PocketBase createRule")
defaultUpdateRule := flag.String("updateRule", "", "Default PocketBase updateRule")
defaultDeleteRule := flag.String("deleteRule", "", "Default PocketBase deleteRule")
flag.Parse()
if *adminEmail == "" || *adminPassword == "" {
log.Fatal("Admin email and password are required (-email, -password)")
}
pbBaseURL = *pbURL // Store globally
// --- Read YAML ---
yamlFile, err := ioutil.ReadFile(*inputFile)
if err != nil {
log.Fatalf("Error reading YAML file %s: %v", *inputFile, err)
}
var draft BlueprintDraft
err = yaml.Unmarshal(yamlFile, &draft)
if err != nil {
log.Fatalf("Error unmarshalling YAML: %v", err)
}
// Convert models definitions to map[string]string
convertedModels := make(map[string]map[string]string)
for modelName, fields := range draft.Models {
convFields := make(map[string]string)
for fname, def := range fields {
if s, ok := def.(string); ok {
convFields[fname] = s
} else {
convFields[fname] = fmt.Sprintf("%v", def)
}
}
convertedModels[modelName] = convFields
}
if len(convertedModels) == 0 {
log.Fatalf("No 'models' section found in %s", *inputFile)
}
// --- Authenticate with PocketBase ---
err = authenticateAdmin(*adminEmail, *adminPassword)
if err != nil {
log.Fatalf("Authentication failed: %v", err)
}
fmt.Println("Successfully authenticated with PocketBase.")
// --- Get Existing Collections ---
existingCollections, err := getExistingCollections() // Map name -> PBListCollectionItem
if err != nil {
log.Fatalf("Failed to fetch existing collections: %v", err)
}
fmt.Printf("Found %d existing user collections.\n", len(existingCollections))
// --- Pass 1: Create/Update Collections (Basic Fields Only) ---
fmt.Println("\n--- Pass 1: Creating/Updating Collections (Basic Fields) ---")
collectionNameMap := make(map[string]string) // Map Original Model Name -> snake_case name
collectionIDMap := make(map[string]string) // Map snake_case name -> PocketBase ID
pendingRelations := []PendingRelation{} // Store relations to process in Pass 2
processedSchemas := make(map[string]PocketBaseSchema) // Store schemas generated in Pass 1
for modelName := range convertedModels {
collectionName := strcase.ToSnake(modelName)
collectionNameMap[modelName] = collectionName
}
for modelName, fields := range convertedModels {
collectionName := collectionNameMap[modelName]
fmt.Printf("Processing Model: %s -> Collection: %s\n", modelName, collectionName)
pbSchema := PocketBaseSchema{
Name: collectionName,
Type: "base", // Default
System: false,
Schema: []PocketBaseField{},
Options: make(map[string]interface{}),
}
// Apply default rules
applyDefaultRules(&pbSchema, *defaultListRule, *defaultViewRule, *defaultCreateRule, *defaultUpdateRule, *defaultDeleteRule)
// Detect Auth Collection
detectAuthCollection(modelName, fields, &pbSchema)
processedFields := make(map[string]bool)
// --- Iterate through Blueprint fields ---
for fieldName, definition := range fields {
fieldNameSnake := strcase.ToSnake(fieldName)
if processedFields[fieldNameSnake] || shouldSkipField(fieldName) {
continue
}
// Check for relationship first (only store it for Pass 2)
isRelation, relInfo := checkForRelationship(definition, fieldNameSnake, collectionName, modelName, fields)
if isRelation {
pendingRelations = append(pendingRelations, relInfo)
// If it's a belongsTo, we might have a foreignId field defined too.
// Mark the conventional foreign key name (e.g., user_id) as processed
// if this relationship defines it implicitly.
if strings.ToLower(relInfo.FieldName) != fieldNameSnake { // e.g., definition is 'author: belongsTo User' but fieldNameSnake is 'author'
fkGuess := strcase.ToSnake(fieldName) + "_id" // Guess the FK field name
if _, hasExplicitFkField := fields[fkGuess]; !hasExplicitFkField { // If there isn't a separate author_id: foreignId field defined
processedFields[fkGuess] = true // Avoid processing author_id separately later
fmt.Printf(" - Marked implicit FK '%s' as handled by relationship '%s'\n", fkGuess, fieldNameSnake)
}
}
processedFields[fieldNameSnake] = true // Mark the relationship name itself as processed
fmt.Printf(" - Storing relationship '%s' for Pass 2\n", fieldNameSnake)
continue // Skip adding relation fields in Pass 1
}
// If not a relationship, parse as standard field
fmt.Printf(" - Processing Field: %s (%s)\n", fieldNameSnake, definition)
pbField, err := mapBlueprintFieldToPocketBase(fieldNameSnake, definition, fields)
if err != nil {
log.Printf(" ! Warning: %v. Skipping field.", err)
continue
}
// Special handling for foreignId: treat as TEXT in pass 1 if we didn't store a pending relation for it
// This is a fallback in case the 'relation: belongsTo Model' syntax wasn't used.
if pbField.Type == "relation" && !isPendingRelation(pendingRelations, collectionName, fieldNameSnake) {
fmt.Printf(" ! Warning: Field '%s' looks like a foreign key but no corresponding 'belongsTo' relation found. Creating as 'text' for now. Define relationships explicitly (e.g., 'author: belongsTo User') for proper relation fields.\n", fieldNameSnake)
pbField.Type = "text" // Fallback: store the ID as text in Pass 1
pbField.Options = PocketBaseOptions{} // Clear relation options
}
// Only add non-relation fields in Pass 1
if pbField.Type != "relation" {
pbSchema.Schema = append(pbSchema.Schema, pbField)
processedFields[fieldNameSnake] = true
} else {
// This case should ideally be handled by checkForRelationship storing a PendingRelation
fmt.Printf(" > Skipping relation-type field '%s' in Pass 1 (will be handled by explicit relationship definition or fallback).\n", fieldNameSnake)
processedFields[fieldNameSnake] = true // Mark as processed
}
} // End field loop
// --- Send API Request (Create or Update) ---
existing, exists := existingCollections[collectionName]
var createdOrUpdatedCollection PBListCollectionItem
var apiErr error
if exists {
fmt.Printf(" > Collection '%s' exists (ID: %s). Updating...\n", collectionName, existing.ID)
pbSchema.ID = existing.ID // Ensure ID is set for PATCH
createdOrUpdatedCollection, apiErr = updateCollection(pbSchema)
collectionIDMap[collectionName] = existing.ID // Store existing ID
} else {
fmt.Printf(" > Collection '%s' does not exist. Creating...\n", collectionName)
createdOrUpdatedCollection, apiErr = createCollection(pbSchema)
if apiErr == nil {
collectionIDMap[collectionName] = createdOrUpdatedCollection.ID // Store new ID
}
}
if apiErr != nil {
log.Printf(" ! Error processing collection %s: %v", collectionName, apiErr)
// Optionally decide if the whole process should stop on error
// continue // Continue with the next collection despite error
os.Exit(1) // Or exit immediately
} else {
// Store the schema state *after* Pass 1 API call, including the ID
finalSchema := PocketBaseSchema{ // Convert from PBListCollectionItem back to PocketBaseSchema
ID: createdOrUpdatedCollection.ID,
Name: createdOrUpdatedCollection.Name,
Type: createdOrUpdatedCollection.Type,
System: createdOrUpdatedCollection.System,
Schema: createdOrUpdatedCollection.Schema,
ListRule: createdOrUpdatedCollection.ListRule,
ViewRule: createdOrUpdatedCollection.ViewRule,
CreateRule: createdOrUpdatedCollection.CreateRule,
UpdateRule: createdOrUpdatedCollection.UpdateRule,
DeleteRule: createdOrUpdatedCollection.DeleteRule,
Options: createdOrUpdatedCollection.Options,
}
processedSchemas[collectionName] = finalSchema
fmt.Printf(" > Successfully processed '%s' (ID: %s).\n", collectionName, collectionIDMap[collectionName])
}
} // End model loop (Pass 1)
// --- Pass 2: Update Collections with Relation Fields ---
fmt.Println("\n--- Pass 2: Updating Collections with Relation Fields ---")
if len(pendingRelations) == 0 {
fmt.Println("No relations defined in draft.yml. Skipping Pass 2.")
}
// Group pending relations by the collection they belong to
relationsByOwner := make(map[string][]PendingRelation)
for _, rel := range pendingRelations {
relationsByOwner[rel.OwnerCollectionName] = append(relationsByOwner[rel.OwnerCollectionName], rel)
}
// Process relations for each collection that has them
for ownerCollectionName, ownerRelations := range relationsByOwner {
fmt.Printf("Processing relations for collection: %s\n", ownerCollectionName)
currentSchema, ok := processedSchemas[ownerCollectionName]
if !ok {
log.Printf(" ! Error: Schema for owner collection '%s' not found from Pass 1. Skipping relations.", ownerCollectionName)
continue
}
schemaNeedsUpdate := false
updatedSchemaFields := make([]PocketBaseField, len(currentSchema.Schema))
copy(updatedSchemaFields, currentSchema.Schema) // Start with existing fields
existingFieldNames := make(map[string]int) // Map name to index in updatedSchemaFields
for i, f := range updatedSchemaFields {
existingFieldNames[f.Name] = i
}
for _, rel := range ownerRelations {
targetCollectionName, ok := collectionNameMap[rel.TargetModelName]
if !ok {
log.Printf(" ! Error: Target model '%s' for relation '%s' on '%s' not found in draft.yml. Skipping.", rel.TargetModelName, rel.FieldName, ownerCollectionName)
continue
}
targetCollectionID, ok := collectionIDMap[targetCollectionName]
if !ok {
log.Printf(" ! Error: ID for target collection '%s' not found (was it created successfully in Pass 1?). Skipping relation '%s'.", targetCollectionName, rel.FieldName)
continue
}
fmt.Printf(" - Adding/Updating relation field: %s -> %s (ID: %s)\n", rel.FieldName, targetCollectionName, targetCollectionID)
pbField := PocketBaseField{
Name: rel.FieldName,
Type: "relation",
System: false,
Required: rel.Required,
Presentable: true,
Unique: false, // Usually false for relations, except maybe some hasOne
Options: PocketBaseOptions{
"collectionId": targetCollectionID, // Use the actual ID!
"cascadeDelete": rel.CascadeDelete,
"minSelect": nil,
"maxSelect": nil, // Default unlimited for multiple
"displayFields": nil, // User configures this
},
}
if rel.IsMultiple {
pbField.Options["maxSelect"] = nil // Many relation
} else {
pbField.Options["maxSelect"] = 1 // Single relation (belongsTo, hasOne)
}
// Set minSelect if required
if rel.Required {
pbField.Options["minSelect"] = 1
} else {
pbField.Options["minSelect"] = nil // Explicitly set nil for optional
}
// Check if the field already exists (maybe from a previous run or manual creation)
if index, fieldExists := existingFieldNames[rel.FieldName]; fieldExists {
// Field exists, check if it needs updating (type or options changed)
existingField := updatedSchemaFields[index]
if existingField.Type != "relation" || !areOptionsEqual(existingField.Options, pbField.Options) {
fmt.Printf(" > Updating existing field '%s' to relation.\n", rel.FieldName)
updatedSchemaFields[index] = pbField // Replace the existing field definition
schemaNeedsUpdate = true
} else {
fmt.Printf(" > Relation field '%s' already exists and seems up-to-date.\n", rel.FieldName)
}
} else {
// Field doesn't exist, add it
fmt.Printf(" > Adding new relation field '%s'.\n", rel.FieldName)
updatedSchemaFields = append(updatedSchemaFields, pbField)
schemaNeedsUpdate = true
existingFieldNames[rel.FieldName] = len(updatedSchemaFields)-1 // Update index map
}
} // End loop through relations for this owner
// If any relations were added/updated for this collection, send PATCH request
if schemaNeedsUpdate {
fmt.Printf(" > Sending update for collection '%s' with new/modified relations...\n", ownerCollectionName)
currentSchema.Schema = updatedSchemaFields // Update the schema array in the payload
_, apiErr := updateCollection(currentSchema) // Send PATCH with the updated schema list
if apiErr != nil {
log.Printf(" ! Error updating collection %s with relations: %v", ownerCollectionName, apiErr)
// Continue or exit based on desired robustness
} else {
fmt.Printf(" > Successfully updated '%s' with relations.\n", ownerCollectionName)
}
} else {
fmt.Printf(" > No relation updates needed for '%s'.\n", ownerCollectionName)
}
} // End loop through owners (Pass 2)
fmt.Println("\n--- Sync Process Completed ---")
fmt.Println("Review PocketBase UI and logs for any potential issues.")
}
// --- Helper Functions ---
// Simplified check for option equality (only checks keys used in relation)
func areOptionsEqual(o1, o2 PocketBaseOptions) bool {
keys := []string{"collectionId", "cascadeDelete", "minSelect", "maxSelect"}
for _, k := range keys {
v1, ok1 := o1[k]
v2, ok2 := o2[k]
if ok1 != ok2 || fmt.Sprintf("%v", v1) != fmt.Sprintf("%v", v2) { // Basic value comparison
return false
}
}
return true
}
// Check if a relation is already stored in pendingRelations
func isPendingRelation(pending []PendingRelation, ownerName, fieldName string) bool {
for _, p := range pending {
if p.OwnerCollectionName == ownerName && p.FieldName == fieldName {
return true
}
}
return false
}
func applyDefaultRules(schema *PocketBaseSchema, list, view, create, update, delete string) {
if list != "" { schema.ListRule = &list } else { schema.ListRule = nil }
if view != "" { schema.ViewRule = &view } else { schema.ViewRule = nil }
if create != "" { schema.CreateRule = &create } else { schema.CreateRule = nil }
if update != "" { schema.UpdateRule = &update } else { schema.UpdateRule = nil }
if delete != "" { schema.DeleteRule = &delete } else { schema.DeleteRule = nil }
}
func detectAuthCollection(modelName string, fields map[string]string, schema *PocketBaseSchema) {
if strings.EqualFold(modelName, "user") || strings.EqualFold(modelName, "auth") {
hasEmail := false
hasPassword := false
hasUsername := false // Check for username auth possibility
for fieldName := range fields {
lFieldName := strings.ToLower(fieldName)
if lFieldName == "email" { hasEmail = true }
if strings.Contains(lFieldName, "password") { hasPassword = true } // Basic check
if lFieldName == "username" { hasUsername = true}
}
if hasEmail && hasPassword {
fmt.Printf(" > Detected potential auth collection: %s\n", schema.Name)
schema.Type = "auth"
schema.Options["allowEmailAuth"] = true
schema.Options["allowOAuth2Auth"] = true // Default true
schema.Options["allowUsernameAuth"] = hasUsername // Enable if username field found
schema.Options["exceptEmailDomains"] = nil
schema.Options["manageRule"] = nil
schema.Options["onlyEmailDomains"] = nil
// PocketBase automatically adds username/email constraints based on allow options
// schema.Options["requireEmail"] = true // PB handles this based on allowEmailAuth
}
}
}
func shouldSkipField(fieldName string) bool {
// Skip special Blueprint fields handled by PocketBase implicitly or not applicable
skip := map[string]bool{
"id": true,
"timestamps": true,
"softDeletes": true,
"rememberToken": true,
}
_, shouldSkip := skip[fieldName]
if shouldSkip {
fmt.Printf(" - Skipping Blueprint meta-field: %s\n", fieldName)
}
return shouldSkip
}
// Checks if a blueprint definition string defines a relationship.
// If it does, returns true and a PendingRelation struct.
func checkForRelationship(definition, fieldNameSnake, ownerCollectionName, ownerModelName string, allFields map[string]string) (bool, PendingRelation) {
relMatch := relationshipRegex.FindStringSubmatch(definition)
if len(relMatch) == 0 {
return false, PendingRelation{} // Not a relationship definition like "author: belongsTo User"
}
relType := strings.ToLower(relMatch[1])
relModel := relMatch[2]
// relModifiersStr := strings.TrimSpace(relMatch[3]) // TODO: Parse modifiers like foreignKey for cascadeDelete?
isMultiple := false
cascadeDelete := false // Default false, maybe infer from modifiers later
switch relType {
case "belongsto", "hasone", "morphto": // Treat as single relation
isMultiple = false
case "hasmany", "belongstomany", "morphmany", "morphtomany": // Treat as multiple relation
isMultiple = true
default:
fmt.Printf(" ! Warning: Unsupported relationship type '%s' for field '%s'. Skipping relation.\n", relType, fieldNameSnake)
return false, PendingRelation{} // Treat as not a valid relation for processing
}
// Determine required status from original definition if possible
required := true // Default to required for belongsTo unless nullable found
if isMultiple {
required = false // Many relations are usually not required
} else {
_, _, modifiersStr := parseFieldDefinition(definition)
modifiers := parseModifiers(modifiersStr)
if _, nullable := modifiers["nullable"]; nullable {
required = false
}
}
// Look for explicit foreign key definition to check for `constrained`
fkFieldNameGuess := fieldNameSnake + "_id" // e.g., author -> author_id
if fkDef, ok := allFields[fkFieldNameGuess]; ok {
_, _, modifiersStr := parseFieldDefinition(fkDef)
modifiers := parseModifiers(modifiersStr)
if _, constrained := modifiers["constrained"]; constrained {
fmt.Printf(" > Found 'constrained' on FK field '%s', enabling cascade delete for relation '%s'.\n", fkFieldNameGuess, fieldNameSnake)
cascadeDelete = true
}
}
return true, PendingRelation{
OwnerCollectionName: ownerCollectionName,
FieldName: fieldNameSnake,
TargetModelName: relModel,
IsMultiple: isMultiple,
Required: required,
CascadeDelete: cascadeDelete,
}
}
// Maps a single Blueprint field definition (non-relationship) to PocketBaseField
func mapBlueprintFieldToPocketBase(fieldNameSnake, definition string, allFields map[string]string) (PocketBaseField, error) {
fieldType, typeOptionsStr, modifiersStr := parseFieldDefinition(definition)
modifiers := parseModifiers(modifiersStr)
pbField := PocketBaseField{
Name: fieldNameSnake,
Type: "text", // Default
System: false,
Required: true, // Default required unless 'nullable'
Presentable: true,
Unique: false,
Options: make(PocketBaseOptions),
}
if _, exists := modifiers["nullable"]; exists {
pbField.Required = false
}
if _, exists := modifiers["unique"]; exists {
pbField.Unique = true
}
// PocketBase handles default values differently (not in schema definition)
parts := strings.Split(fieldType, ":")
baseType := parts[0]
typeArgs := ""
if len(parts) > 1 { typeArgs = parts[1] }
// --- Type Mapping Logic (mostly same as previous script) ---
switch baseType {
case "string", "char":
pbField.Type = "text"
if typeArgs != "" { pbField.Options["max"] = parseInt(typeArgs, 0) }
pbField.Options["min"] = nil; pbField.Options["pattern"] = ""
// Infer email/url
lFieldName := strings.ToLower(fieldNameSnake)
if strings.Contains(lFieldName, "email") {
pbField.Type = "email"; delete(pbField.Options, "max"); delete(pbField.Options, "pattern"); pbField.Options["exceptDomains"] = nil; pbField.Options["onlyDomains"] = nil
} else if strings.Contains(lFieldName, "url") || strings.Contains(lFieldName, "website") {
pbField.Type = "url"; delete(pbField.Options, "max"); delete(pbField.Options, "pattern"); pbField.Options["exceptDomains"] = nil; pbField.Options["onlyDomains"] = nil
}
case "integer", "tinyint", "smallint", "mediumint", "bigint", "unsignedInteger", "unsignedTinyint", "unsignedSmallint", "unsignedMediumint", "unsignedBigint", "increments", "tinyIncrements", "smallIncrements", "mediumIncrements", "bigIncrements":
pbField.Type = "number"; pbField.Options["min"] = nil; pbField.Options["max"] = nil
case "float", "double", "decimal", "unsignedDecimal":
pbField.Type = "number"; pbField.Options["min"] = nil; pbField.Options["max"] = nil
case "boolean":
pbField.Type = "bool"
case "date", "datetime", "datetimez", "timestamp", "timestampz", "time", "timez", "year":
pbField.Type = "date"; pbField.Options["min"] = ""; pbField.Options["max"] = ""
case "text", "tinytext", "mediumtext", "longtext":
pbField.Type = "text"; pbField.Options["min"] = nil; pbField.Options["max"] = nil; pbField.Options["pattern"] = ""
case "json", "jsonb":
pbField.Type = "json"
case "uuid":
pbField.Type = "text"; // Optional: pbField.Options["pattern"] = "..."
case "enum":
pbField.Type = "select"
values := []string{}
if typeOptionsStr != "" { values = strings.Split(typeOptionsStr, ",") }
for i := range values { values[i] = strings.TrimSpace(values[i]) }
pbField.Options["values"] = values; pbField.Options["maxSelect"] = 1
case "file": // Handle Blueprint file type
pbField.Type = "file"
pbField.Options["maxSelect"] = 1 // Default to single file
pbField.Options["maxSize"] = 5242880 // Default 5MB
pbField.Options["mimeTypes"] = []string{} // Allow any by default
pbField.Options["thumbs"] = []string{} // No thumbs by default
// Check for modifiers like 'multiple' if needed
case "foreignId", "foreignUuid":
// This is tricky. If handled by an explicit relation definition, it's skipped earlier.
// If reached here, it means only `user_id: foreignId` was defined.
// We *could* try to infer the relation here, but it's better handled by Pass 2 from explicit definitions.
// For Pass 1, we'll mark it as a relation type but with a placeholder collectionId. Pass 2 *should* ideally overwrite this if an explicit relation exists. If not, it might remain a text field (see logic in main loop).
pbField.Type = "relation" // Tentative type
pbField.Options["maxSelect"] = 1
pbField.Options["minSelect"] = nil
if pbField.Required { pbField.Options["minSelect"] = 1 }
pbField.Options["collectionId"] = "PLACEHOLDER_" + strings.TrimSuffix(fieldNameSnake, "_id") // Placeholder
pbField.Options["cascadeDelete"] = false
if _, constrained := modifiers["constrained"]; constrained {
pbField.Options["cascadeDelete"] = true
}
pbField.Options["displayFields"] = nil
// A warning about this fallback case is printed in the main loop if needed.
default:
return pbField, fmt.Errorf("unmapped Blueprint type '%s' for field '%s'. Defaulting to PocketBase type 'text'", baseType, fieldNameSnake)
}
return pbField, nil
}
// --- PocketBase API Interaction ---
func makeAPIRequest(method, apiPath string, payload interface{}) ([]byte, error) {
var reqBody io.Reader
if payload != nil {
jsonData, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("failed to marshal payload: %w", err)
}
reqBody = bytes.NewBuffer(jsonData)
fmt.Printf("DEBUG: %s %s Payload: %s\n", method, apiPath, string(jsonData))
}
// Debug the full URL being requested
fullURL, err := url.Parse(pbBaseURL)
if err != nil {
return nil, fmt.Errorf("invalid base URL: %w", err)
}
// Ensure the API path starts with /api/
if !strings.HasPrefix(apiPath, "/api/") {
apiPath = "/api/" + strings.TrimPrefix(apiPath, "/")
}
// Handle query parameters separately
pathParts := strings.SplitN(apiPath, "?", 2)
fullURL.Path = path.Join(fullURL.Path, pathParts[0])
if len(pathParts) > 1 {
fullURL.RawQuery = pathParts[1]
}
fmt.Printf("DEBUG: Full URL: %s\n", fullURL.String())
req, err := http.NewRequest(method, fullURL.String(), reqBody)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
if adminToken != "" {
req.Header.Set("Authorization", adminToken)
}
fmt.Printf("DEBUG: Making request to: %s %s\n", req.Method, req.URL.String())
resp, err := httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
fmt.Printf("DEBUG: Response status: %s\n", resp.Status)
defer resp.Body.Close()
respBody, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return respBody, fmt.Errorf("API error: %s (%d) - %s", http.StatusText(resp.StatusCode), resp.StatusCode, string(respBody))
}
return respBody, nil
}
func authenticateAdmin(email, password string) error {
// First verify the instance is reachable
_, err := makeAPIRequest(http.MethodGet, "/api/health", nil)
if err != nil {
return fmt.Errorf("failed to reach PocketBase instance: %w\n" +
"Please ensure:\n" +
"1. The PocketBase instance is running at the specified URL\n" +
"2. The URL is correct and accessible", err)
}
authPayload := map[string]string{
"identity": email,
"password": password,
}
// Try admin authentication
respBody, err := makeAPIRequest(http.MethodPost, "/api/collections/_superusers/auth-with-password", authPayload)
if err != nil {
if strings.Contains(err.Error(), "400") {
return fmt.Errorf("invalid admin credentials\n" +
"Please ensure:\n" +
"1. You're using the correct admin email and password\n" +
"2. The admin account exists and is enabled\n" +
"3. The password is correct")
}
return fmt.Errorf("admin authentication failed: %w", err)
}
var authResp AdminAuthResponse
if err := json.Unmarshal(respBody, &authResp); err != nil {
return fmt.Errorf("failed to parse auth response: %w - Body: %s", err, string(respBody))
}
if authResp.Token == "" {
return fmt.Errorf("authentication succeeded but token is empty")
}
adminToken = authResp.Token // Store token globally
return nil
}
func getExistingCollections() (map[string]PBListCollectionItem, error) {
collections := make(map[string]PBListCollectionItem)
page := 1
for {
// Use the correct collections endpoint for v0.26.3
apiPath := fmt.Sprintf("/api/collections?page=%d&perPage=500", page)
fmt.Printf("DEBUG: Making request to: %s\n", apiPath)
respBody, err := makeAPIRequest(http.MethodGet, apiPath, nil)
if err != nil {
return nil, fmt.Errorf("failed to list collections (page %d): %w", page, err)
}
var listResp PBListCollectionsResponse
if err := json.Unmarshal(respBody, &listResp); err != nil {
return nil, fmt.Errorf("failed to parse collection list response (page %d): %w - Body: %s", page, err, string(respBody))
}
for _, item := range listResp.Items {
collections[item.Name] = item
}
if listResp.Page >= listResp.TotalPages {
break // No more pages
}
page++
}
return collections, nil
}
func createCollection(schema PocketBaseSchema) (PBListCollectionItem, error) {
var createdCollection PBListCollectionItem
respBody, err := makeAPIRequest(http.MethodPost, "/api/collections", schema)
if err != nil {
return createdCollection, err
}
if err := json.Unmarshal(respBody, &createdCollection); err != nil {
return createdCollection, fmt.Errorf("failed to parse created collection response: %w - Body: %s", err, string(respBody))
}
return createdCollection, nil
}
func updateCollection(schema PocketBaseSchema) (PBListCollectionItem, error) {
if schema.ID == "" {
return PBListCollectionItem{}, fmt.Errorf("cannot update collection '%s', ID is missing", schema.Name)
}
var updatedCollection PBListCollectionItem
apiPath := fmt.Sprintf("/api/collections/%s", schema.ID)
// Send the whole schema definition for update. PocketBase PATCH should handle replacing/updating fields.
respBody, err := makeAPIRequest(http.MethodPatch, apiPath, schema)
if err != nil {
return updatedCollection, err
}
if err := json.Unmarshal(respBody, &updatedCollection); err != nil {
// Sometimes PATCH returns 204 No Content on success with empty body
if len(respBody) == 0 {
fmt.Printf(" > Update for '%s' returned 204 No Content (assumed successful).\n", schema.Name)
// Return the schema we sent, assuming it was applied
// PB doesn't return the full object on 204
tempSchema := PBListCollectionItem{
ID: schema.ID, Name: schema.Name, Type: schema.Type, System: schema.System,
Schema: schema.Schema, ListRule: schema.ListRule, ViewRule: schema.ViewRule,
CreateRule: schema.CreateRule, UpdateRule: schema.UpdateRule, DeleteRule: schema.DeleteRule,
Options: schema.Options,
}
return tempSchema, nil
}
return updatedCollection, fmt.Errorf("failed to parse updated collection response: %w - Body: %s", err, string(respBody))
}
return updatedCollection, nil
}
// --- YAML/Blueprint Parsing Helpers (mostly same as before) ---
func parseFieldDefinition(def string) (fieldType, typeOptions string, modifiers string) {
match := fieldRegex.FindStringSubmatch(def)
if len(match) < 4 {
parts := strings.Fields(def)
if len(parts) > 0 {
fieldType = parts[0]
if len(parts) > 1 { modifiers = strings.Join(parts[1:], " ") }
}
return
}
fieldType = match[1]; typeOptions = match[2]; modifiers = strings.TrimSpace(match[3])
return
}
func parseModifiers(modStr string) map[string]string {
mods := make(map[string]string)
matches := modifierRegex.FindAllStringSubmatch(modStr, -1)
for _, match := range matches {
if len(match) > 1 {
key := match[1]; value := ""; if len(match) > 2 { value = match[2] }
mods[key] = value
}
}
return mods
}
func parseInt(s string, defaultVal int) int {
var i int; _, err := fmt.Sscan(s, &i); if err != nil { return defaultVal }; return i
}
@garyblankenship
Copy link
Author

garyblankenship commented Apr 23, 2025

Usage:

go run pb_blueprint.go -url "http://127.0.0.1:8090" -email "[email protected]" -password "password" -i "draft.yml"

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment