README
¶
Contentful Commander
A Go library for Contentful migrations that provides a high-level interface for working with Contentful spaces, entities, and performing bulk operations.
Features
- Unified Entity Interface: Work with both Contentful entries and assets through a common interface
- Space Model Caching: Load and cache entire space models for efficient operations
- Content Model Helpers: Safe lookup helpers for content types, fields, and validation metadata
- Dual CMA/CDA Loading: Load management (CMA) and delivery (CDA) views side-by-side for diff-style comparisons
- Locale-Aware Operations: Native support for Contentful's localization system with locale-specific field access
- Type-Safe Field Access: Specialized methods for different field types (string, float64, bool, references)
- Reference Resolution: Direct access to referenced entities with automatic broken reference handling
- Referrer Path Traversal: Walk up the referrer chain to trace how an entity is reached, with stop conditions and cycle detection
- Asset-Specific Methods: Dedicated methods for asset title, description, and file access
- Null/Empty Field Detection: First-class
IsFieldNullOrEmptycheck for nil, empty string, empty map, and empty slice values - Flexible Filtering: Filter entities by content type, publication status, CDA availability, timestamps, and custom criteria
- Collection Operations: Chain operations like filtering, mapping, grouping, and reducing
- Migration Execution: Execute batch operations with dry-run support, concurrent execution, and comprehensive error handling
- Asset Creation: Create assets from a remote URL with
CreateAssetFromURL, which handles the upload/process/wait cycle - DeepL Translation: Built-in DeepL API integration for automated field translation with cost tracking
- Basic RichText Markdown Conversion: Convert supported Contentful RichText documents to/from a safe Markdown subset
- Incremental Cache Updates: Efficiently refresh only recently changed entities using
UpdateSpaceModel, ordered by-sys.updatedAt - Concurrent Loading: Parallel loading of entries and assets for faster space initialization, with adaptive per-content-type entry page sizes
- Selective Loading: Load entries only (
SkipAssets) or assets only (SkipEntries) to save time and bandwidth - Configuration Management: Load configuration from environment variables
- Portable Design: Only depends on
github.com/foomo/contentfuland standard library
Quick Start
package main
import (
"log"
"github.com/foomo/contentfulcommander/commanderclient"
)
func main() {
// Load config from environment variables
config := commanderclient.LoadConfigFromEnv()
// Initialize ready-to-use client with logger and loaded space model
client, logger, err := commanderclient.Init(config)
if err != nil {
log.Fatal(err)
}
// Filter entities
entries := client.FilterEntities(
commanderclient.FilterByContentType("product"),
commanderclient.FilterPublished(),
)
// Process entities
entries.ForEach(func(entity commanderclient.Entity) {
logger.Info("Processing %s", entity.GetID())
})
}
Core Concepts
Entity Interface
All Contentful entries and assets implement the Entity interface:
type Entity interface {
// Basic entity information
GetID() string
GetType() string // "Entry" or "Asset"
GetContentType() string // Content type ID for entries
GetCreatedAt() time.Time
GetUpdatedAt() time.Time
GetVersion() int
IsPublished() bool
GetPublishingStatus() string // "draft", "published", or "changed"
// Field access methods
GetFields() map[string]any
GetFieldValue(fieldName string, locale Locale) any
GetFieldValueWithFallback(fieldName string, locale Locale, defaultLocale Locale) any
// Type-safe field access (primarily for entries)
GetFieldValueAsString(fieldName string, locale Locale) string
GetFieldValueAsFloat64(fieldName string, locale Locale) float64
GetFieldValueAsBool(fieldName string, locale Locale) bool
// Reference handling
GetFieldValueAsReference(fieldName string, locale Locale) *contentful.Entry
GetFieldValueAsReferencedEntity(fieldName string, locale Locale) (Entity, bool)
GetFieldValueAsReferences(fieldName string, locale Locale) []*contentful.Entry
GetFieldValueAsReferencedEntities(fieldName string, locale Locale) *EntityCollection
// Advanced field access
GetFieldValueInto(fieldName string, locale Locale, target any) error
IsFieldNullOrEmpty(fieldName string, locale Locale) bool
// Entity-specific methods
GetTitle(locale Locale) string
GetDescription(locale Locale) string
GetFile(locale Locale) *contentful.File
// Graph traversal
GetParents(contentTypes []string) *EntityCollection // direct referrers
GetReferrerPath(fieldNames []string, opts ...PathOption) ([]Entity, error) // full referrer chain (root → self)
// CDA (Content Delivery API) view
HasCDAView() bool // true if a published CDA snapshot is attached
CDAView() Entity // the published view, or nil
// Utility methods
SetFieldValue(fieldName string, locale Locale, value any)
GetSys() *contentful.Sys
IsEntry() bool
IsAsset() bool
}
Publishing Status
The library provides accurate publishing status detection based on Contentful's versioning system:
- Draft:
PublishedVersion == 0(never been published) - Published:
Version - PublishedVersion == 1(current published version) - Changed:
Version - PublishedVersion > 1(has unpublished changes)
entity := client.GetEntity("some-id")
status := entity.GetPublishingStatus() // "draft", "published", or "changed"
isPublished := entity.IsPublished() // true only if status == "published"
CDA Views
When a CDA (Content Delivery API) key is provided, the client loads published snapshots alongside the CMA (management) data and attaches them to each entity. This lets you compare draft vs. published content without extra API calls.
// Enable CDA views via config
config := commanderclient.LoadConfigFromEnv() // reads CONTENTFUL_CDAKEY
// or set manually: config.CDAToken = "your-cda-key"
client, logger, err := commanderclient.Init(config)
// Check CDA availability on the client
if client.HasCDA() {
log.Println("CDA views are available")
}
// Access the published view of any entity
entity, _ := client.GetEntity("some-id")
if entity.HasCDAView() {
published := entity.CDAView()
draftTitle := entity.GetFieldValueAsString("title", locale)
liveTitle := published.GetFieldValueAsString("title", locale)
if draftTitle != liveTitle {
log.Printf("Title changed: %q -> %q", liveTitle, draftTitle)
}
}
// Filter entities by CDA availability
withCDA := client.FilterEntities(commanderclient.FilterHasCDAView()) // has published version
noCDA := client.FilterEntities(commanderclient.FilterNoCDAView()) // draft-only
The MigrationClient provides the main interface for working with Contentful spaces:
// Initialize ready-to-use client
config := commanderclient.LoadConfigFromEnv()
client, logger, err := commanderclient.Init(config)
if err != nil {
log.Fatal(err)
}
// Get entities (all return EntityCollection for consistency)
allEntities := client.GetAllEntities()
entries := client.GetEntries()
assets := client.GetAssets()
specificEntries := client.GetEntitiesByContentType("product")
// Filter entities
filtered := client.FilterEntities(
commanderclient.FilterByContentType("product", "category"),
commanderclient.FilterPublished(),
commanderclient.FilterByUpdatedAfter(time.Now().AddDate(0, -1, 0)),
)
Content Model Helpers
After the space model is loaded, MigrationClient can safely look up content model metadata without reaching into SpaceModel directly:
contentType, ok := client.GetContentType("article")
if !ok {
log.Println("content type is missing or the space model is not loaded")
} else {
log.Printf("Content type: %s", contentType.Name)
}
field, ok := client.GetContentTypeField("article", "title")
if ok && commanderclient.FieldIsEditable(field) {
log.Printf("Field %s can be edited", field.ID)
}
Both helpers return false when the space model is not loaded, when the content type or field is missing, or when the cached model contains nil entries.
Incremental Cache Updates
After the initial LoadSpaceModel, you can efficiently refresh only the entities that have changed using UpdateSpaceModel. This avoids reloading the entire space and is ideal for long-running processes.
client, logger, err := commanderclient.Init(config)
if err != nil {
log.Fatal(err)
}
// ... perform work ...
// Incrementally update the cache with only recently changed entities
if err := client.UpdateSpaceModel(ctx, logger); err != nil {
log.Fatal(err)
}
How it works:
- The space model records the start time of each load/update (not the end time), so entities that changed during a previous load are caught on the next update
- Entities are fetched in pages of 100, ordered by
-sys.updatedAt(most recently changed first) - Pagination stops as soon as the oldest entity on a page is older than the previous update start time; entities with the exact cutoff timestamp are refreshed again to avoid missing rounded timestamps
- Entries and assets are updated concurrently; CDA views are refreshed in a second phase if a CDA key is configured
Collection Operations
Collections provide powerful operations for working with groups of entities:
collection := client.FilterEntities(filters...)
// Basic operations
count := collection.Count()
entities := collection.Get()
entity, exists := collection.GetByID("entity-id")
// Chaining operations
result := collection.
Filter(commanderclient.FilterPublished()).
Limit(100).
Skip(50)
// Iteration
collection.ForEach(func(entity Entity) {
// Process each entity sequentially
})
collection.ForEachConcurrent(5, func(entity Entity) {
// Process each entity concurrently with concurrency level of 5
})
// Data extraction
ids := collection.ExtractIDs()
contentTypes := collection.ExtractContentTypes()
fieldValues := collection.ExtractFields("title")
// Grouping operations
contentTypeGroups := collection.GroupByContentType()
statusGroups := collection.GroupByPublishingStatus()
customGroups := collection.GroupBy(func(entity Entity) string {
return entity.GetContentType()
})
// Counting operations
contentTypeCounts := collection.CountByContentType()
statusCounts := collection.CountByPublishingStatus()
// Statistics
stats := collection.GetStats()
fmt.Printf("Total: %d, Entries: %d, Assets: %d\n",
stats.TotalCount, stats.EntryCount, stats.AssetCount)
// Migration operations
updateOps := collection.ToUpdateOperations(map[string]any{
"newField": "newValue",
})
publishOps := collection.ToPublishOperations()
deleteOps := collection.ToDeleteOperations()
Field Access Methods
The library provides multiple ways to access field values, each optimized for different use cases:
Basic Field Access
// Get raw field value
value := entity.GetFieldValue("title", commanderclient.Locale("en"))
// Get field value with fallback to default locale
value := entity.GetFieldValueWithFallback("title", commanderclient.Locale("fr"), defaultLocale)
Type-Safe Field Access
// Get field as specific types (returns zero value if not found or wrong type)
title := entity.GetFieldValueAsString("title", commanderclient.Locale("en"))
price := entity.GetFieldValueAsFloat64("price", commanderclient.Locale("en"))
isActive := entity.GetFieldValueAsBool("isActive", commanderclient.Locale("en"))
Reference Handling
// Get reference as contentful.Entry
reference := entity.GetFieldValueAsReference("category", commanderclient.Locale("en"))
// Get actual referenced entity (resolves the reference)
if categoryEntity, found := entity.GetFieldValueAsReferencedEntity("category", commanderclient.Locale("en")); found {
categoryTitle := categoryEntity.GetFieldValueAsString("title", commanderclient.Locale("en"))
}
// Get multiple references as slice
references := entity.GetFieldValueAsReferences("tags", commanderclient.Locale("en"))
// Get multiple referenced entities as collection (broken references automatically skipped)
tagEntities := entity.GetFieldValueAsReferencedEntities("tags", commanderclient.Locale("en"))
tagEntities.ForEach(func(tagEntity commanderclient.Entity) {
fmt.Printf("Tag: %s\n", tagEntity.GetFieldValueAsString("name", commanderclient.Locale("en")))
})
// Reverse lookup: find all entities that reference this entry
// Returns parents across all content types
allParents := entity.GetParents(nil)
// Filter parents by content type
pageParents := entity.GetParents([]string{"page", "landingPage"})
Referrer Path Traversal
Walk up the referrer chain through specific link fields to get the full path from root to a given entity. Useful for building breadcrumbs, validating content trees, or understanding how an entity is reached.
// Get the full path from root to this entity, following "children" link fields
path, err := article.GetReferrerPath([]string{"children"})
// path = [page, section, article]
// Stop at a specific content type (e.g. only care about section → leaf)
path, err := article.GetReferrerPath([]string{"children"}, commanderclient.StopAtContentType("section"))
// path = [section, article]
// Stop at a specific entity ID
path, err := article.GetReferrerPath([]string{"children"}, commanderclient.StopAtEntityID("section-42"))
// Multiple field names — matches if any of the listed fields contain the reference
path, err := asset.GetReferrerPath([]string{"hero", "image", "thumbnail"})
The method returns sentinel errors for two edge cases:
// Multiple entities reference the same child through the searched fields
_, err := entity.GetReferrerPath([]string{"children"})
if errors.Is(err, commanderclient.ErrAmbiguousPath) {
// handle ambiguity
}
// A cycle is detected in the referrer chain
_, err := entity.GetReferrerPath([]string{"children"})
if errors.Is(err, commanderclient.ErrCircularReference) {
// handle cycle
}
Null/Empty Check
// Check if a field value is nil, an empty string, an empty map, or an empty slice
if entity.IsFieldNullOrEmpty("description", commanderclient.Locale("en")) {
log.Println("Description is missing or empty")
}
// Works with assets too (checks title, description, file)
if asset.IsFieldNullOrEmpty("file", commanderclient.Locale("de")) {
log.Println("No German file uploaded")
}
Advanced Field Access
// Unmarshal field value directly into a struct (entries only). Useful for JSON/Object fields.
type Query struct {
Operation string `json:"operation"`
Elements []ElementType `json:"elements"`
}
var myQuery Query
if err := categoryEntity.GetFieldValueInto("catalogueQuery", commanderclient.Locale("en"), &myQuery); err != nil {
log.Printf("Error: %v", err)
}
Field Validation Metadata
Content model fields can expose validation metadata such as size limits, allowed values, and regular expressions. Because contentful.Field is defined in the Contentful SDK, these helpers are ordinary package functions:
field, ok := client.GetContentTypeField("article", "slug")
if !ok {
log.Fatal("missing field")
}
if commanderclient.FieldIsEditable(field) {
log.Println("field is editable")
}
if maxLength, ok := commanderclient.FieldMaxLength(field); ok {
log.Printf("max length: %d", maxLength)
}
if minLength, ok := commanderclient.FieldMinLength(field); ok {
log.Printf("min length: %d", minLength)
}
if allowedValues, ok := commanderclient.FieldAllowedValues(field); ok {
log.Printf("allowed values: %v", allowedValues)
}
if pattern, flags, ok := commanderclient.FieldRegex(field); ok {
log.Printf("regex: /%s/%s", pattern, flags)
}
summary := commanderclient.GetFieldValidationSummary(field)
FieldMaxLength returns the lowest non-zero max from size validations, and FieldMinLength returns the highest min. FieldAllowedValues returns the first predefined-values validation. GetFieldValidationSummary returns a serializable FieldValidationSummary:
type FieldValidationSummary struct {
MinLength *int `json:"minLength,omitempty"`
MaxLength *int `json:"maxLength,omitempty"`
AllowedValues []any `json:"allowedValues,omitempty"`
RegexPattern string `json:"regexPattern,omitempty"`
RegexFlags string `json:"regexFlags,omitempty"`
}
Entity-Specific Methods
// Get title (uses content type display field for entries, asset title for assets)
title := entity.GetTitle(commanderclient.Locale("en"))
// Get the raw locale value of the content type display field.
// Unlike GetTitle, this does not apply locale fallback.
displayName := client.GetEntryDisplayName(entity, commanderclient.Locale("en"))
// Get description (assets only, returns empty string for entries)
description := entity.GetDescription(commanderclient.Locale("en"))
// Get file information (assets only, returns nil for entries)
file := entity.GetFile(commanderclient.Locale("en"))
if file != nil {
fmt.Printf("File: %s (%s)\n", file.Name, file.ContentType)
fmt.Printf("URL: %s\n", file.URL)
}
RichText Markdown Conversion
For service integrations that need a simple editable text format, Contentful RichText can be converted to and from a supported Markdown subset:
value := entity.GetFieldValue("body", commanderclient.Locale("en"))
markdown, warnings, err := commanderclient.RichTextToMarkdown(value)
if err != nil {
log.Fatal(err)
}
for _, warning := range warnings {
log.Printf("RichText warning: %s", warning)
}
if err := commanderclient.IsSupportedRichTextMarkdown(markdown); err != nil {
log.Fatal(err)
}
richText, err := commanderclient.MarkdownToRichText(markdown)
if err != nil {
log.Fatal(err)
}
entity.SetFieldValue("body", commanderclient.Locale("de"), richText)
Supported Markdown/RichText constructs:
- Document root
- Paragraphs
- Headings levels 1-3
- Unordered and ordered lists
- List items
- Bold and italic text
- Hyperlinks (see below)
- Tables (see below)
- Plain text line breaks where practical
Hyperlinks
- External links round-trip as standard Markdown:
[text](https://example.com). - Entry and asset hyperlinks have no URL, so they use a custom scheme that round-trips losslessly:
[text](entry:<id>)and[text](asset:<id>). - Link text may itself contain bold/italic, e.g.
[**bold** label](https://example.com). - Images (
) remain unsupported and are rejected on write.
Tables
Tables convert to and from GitHub-Flavored Markdown pipe tables:
| Name | Price |
| --- | --- |
| Widget | 9.99 |
The first row is the header row, and cells hold inline content (text, bold/italic, links). Because GFM tables cannot represent everything Contentful tables can, content that does not fit is degraded with a warnings entry on read: block content inside a cell (lists, multiple paragraphs) is flattened to text, header cells outside the first row become regular cells, a table with no header row uses its first row as the header, and ragged rows are padded to the column count.
Unsupported Markdown is rejected on write so callers do not accidentally persist lossy RichText. Unsupported RichText nodes encountered while reading are rendered as plain text where possible and reported in warnings. Unsupported constructs include embedded entries, embedded assets, images, arbitrary custom nodes, raw HTML, code spans, code blocks, blockquotes, and horizontal rules.
Locale Support
Working with Locales
// Get space locales
locales := client.GetLocales()
defaultLocale := client.GetDefaultLocale()
// Access field values for specific locales
entity := entries[0]
value := entity.GetFieldValue("title", commanderclient.Locale("en"))
// Access field values with fallback to default locale
value := entity.GetFieldValueWithFallback("title", commanderclient.Locale("fr"), defaultLocale)
// Set field values for specific locales
entity.SetFieldValue("title", commanderclient.Locale("de"), "Deutscher Titel")
// Get all fields (always locale maps)
fields := entity.GetFields()
Locale-Aware Filtering
// Filter by field value for a specific locale
englishEntries := client.FilterEntities(
commanderclient.FilterByFieldValueWithLocale("title", commanderclient.Locale("en"), "Welcome"),
)
// Filter by field value with fallback to default locale
entriesWithWelcome := client.FilterEntities(
commanderclient.FilterByFieldValueWithFallback("title", commanderclient.Locale("fr"), defaultLocale, "Welcome"),
)
// Filter by empty/not-empty field values for a specific locale
missingGermanDescriptions := client.FilterEntities(
commanderclient.FilterByFieldEmptyWithLocale("description", commanderclient.Locale("de")),
)
englishDescriptions := client.FilterEntities(
commanderclient.FilterByFieldNotEmptyWithLocale("description", commanderclient.Locale("en")),
)
// Filter by locale availability
multiLocaleEntries := client.FilterEntities(
commanderclient.FilterByLocaleAvailability([]commanderclient.Locale{
commanderclient.Locale("en"),
commanderclient.Locale("de"),
}),
)
Locale-Aware Operations
// Extract field values for a specific locale
collection := commanderclient.NewEntityCollection(entries)
englishTitles := collection.ExtractFieldValues("title", commanderclient.Locale("en"))
// Extract field values with fallback to default locale
frenchTitles := collection.ExtractFieldValuesWithFallback("title", commanderclient.Locale("fr"), defaultLocale)
// Modify entities directly, then create operations
collection.ForEach(func(entity commanderclient.Entity) {
entity.SetFieldValue("description", commanderclient.Locale("en"), "Updated description in English")
entity.SetFieldValue("description", commanderclient.Locale("de"), "Aktualisierte Beschreibung auf Deutsch")
})
// Create migration operations
operations := collection.ToUpdateOperations()
Migration with Locale Targeting
// Configure migration to target specific locales
options := commanderclient.DefaultMigrationOptions()
options.TargetLocales = []commanderclient.Locale{
commanderclient.Locale("en"),
commanderclient.Locale("de"),
}
executor := commanderclient.NewMigrationExecutor(client, options)
DeepL Translation
The library provides built-in DeepL API integration for automated translation of Contentful fields.
DeepL Client Setup
// Create a DeepL client
deeplClient := commanderclient.NewDeepLClient("your-deepl-api-key")
// With custom options
deeplClient := commanderclient.NewDeepLClient(
"your-deepl-api-key",
commanderclient.WithDeepLBaseURL("https://api-free.deepl.com/v2"), // For free tier
commanderclient.WithDeepLTimeout(30 * time.Second),
)
DeepL Translator
The DeepLTranslator pairs Contentful locales with DeepL language codes for seamless translation:
// Define source and target locales
source := commanderclient.SourceLocale{
Locale: commanderclient.Locale("en-US"),
DeepLLang: commanderclient.DeepLSourceEN,
}
target := commanderclient.TargetLocale{
Locale: commanderclient.Locale("de-DE"),
DeepLLang: commanderclient.DeepLTargetDE,
}
// Create translator
translator := commanderclient.NewDeepLTranslator(deeplClient, source, target)
Translating Fields
// Translate a single field (works with string and RichText fields)
billedChars, err := translator.TranslateField(entity, "title")
if err != nil {
log.Printf("Translation failed: %v", err)
}
log.Printf("Billed characters: %d", billedChars)
// Batch translation (more efficient for RichText with many text nodes)
billedChars, err := translator.TranslateFieldBatch(entity, "description")
// Translate only if target locale is empty (incremental translation)
billedChars, err := translator.TranslateFieldIfEmpty(entity, "title")
billedChars, err := translator.TranslateFieldBatchIfEmpty(entity, "description")
Supported Languages
Source Languages:
DeepLSourceDE(German),DeepLSourceEN(English),DeepLSourceFR(French)DeepLSourceES(Spanish),DeepLSourceIT(Italian),DeepLSourceNL(Dutch)DeepLSourcePL(Polish),DeepLSourcePT(Portuguese),DeepLSourceRU(Russian)DeepLSourceJA(Japanese),DeepLSourceZH(Chinese)
Target Languages:
DeepLTargetDE,DeepLTargetENGB,DeepLTargetENUS,DeepLTargetFRDeepLTargetES,DeepLTargetIT,DeepLTargetNL,DeepLTargetPLDeepLTargetPTBR,DeepLTargetPTPT,DeepLTargetRU,DeepLTargetJA,DeepLTargetZH
Translation Cost Tracking
All translation methods return the number of billed characters, allowing you to track API usage:
totalBilled := 0
entries := client.FilterEntities(commanderclient.FilterByContentType("article"))
entries.ForEach(func(entity commanderclient.Entity) {
billed, err := translator.TranslateFieldBatch(entity, "body")
if err != nil {
log.Printf("Translation error: %v", err)
return
}
totalBilled += billed
})
log.Printf("Total billed characters: %d", totalBilled)
Text Utilities
The library includes text utilities that are automatically applied during translation:
// Match case style from a reference string
result := commanderclient.MatchCase("hello world", "ORIGINAL") // Returns "HELLO WORLD"
result := commanderclient.MatchCase("HELLO WORLD", "original") // Returns "hello world"
result := commanderclient.MatchCase("hello world", "Original") // Returns "Hello world"
// Fix URLs that might have been capitalized during translation
result := commanderclient.ToLowerURL("HTTPS://example.com") // Returns "hTTPS://example.com"
// Clean up URIs (lowercase, replace spaces with dashes)
result := commanderclient.FixURI(" My Page Title ") // Returns "my-page-title"
Direct DeepL API Usage
For advanced use cases, you can use the DeepL client directly:
// Translate a single text
translated, billedChars, err := deeplClient.TranslateText(
"Hello, world!",
commanderclient.DeepLTargetDE,
commanderclient.DeepLSourceEN,
)
// Batch translation with full control
resp, err := deeplClient.Translate(commanderclient.DeepLTranslateRequest{
Text: []string{"Hello", "World"},
TargetLang: commanderclient.DeepLTargetDE,
SourceLang: commanderclient.DeepLSourceEN,
Formality: commanderclient.DeepLFormalityMore,
ShowBilledChars: &showBilled,
})
Asset-Specific Usage
Assets have a fixed structure with only title, description, and file fields. The library provides dedicated methods for these:
// Get all assets
assets := client.GetAssets()
// Access asset-specific fields
assets.ForEach(func(asset commanderclient.Entity) {
// Get asset title for different locales
titleEN := asset.GetTitle(commanderclient.Locale("en"))
titleDE := asset.GetTitle(commanderclient.Locale("de"))
// Get asset description
description := asset.GetDescription(commanderclient.Locale("en"))
// Get file information
file := asset.GetFile(commanderclient.Locale("en"))
if file != nil {
fmt.Printf("Asset: %s\n", titleEN)
fmt.Printf("File: %s (%s)\n", file.Name, file.ContentType)
fmt.Printf("URL: %s\n", file.URL)
if file.Detail != nil {
fmt.Printf("Size: %d bytes\n", file.Detail.Size)
}
}
})
// Generic field methods return safe defaults for assets
value := asset.GetFieldValue("title", commanderclient.Locale("en")) // Returns nil
title := asset.GetFieldValueAsString("title", commanderclient.Locale("en")) // Returns ""
Migration Operations
The library supports the following migration operations, each defined as a constant for type safety:
Direct Draft Save and Publish
For service code that is not building a migration batch, use the explicit convenience methods on MigrationClient:
entity.SetFieldValue("title", commanderclient.Locale("en"), "Updated title")
// Persist the current in-memory fields without publishing.
// This does not republish an entity that was previously published.
if err := client.SaveDraft(ctx, entity); err != nil {
log.Fatal(err)
}
// Publish the entity when the caller explicitly wants to make it live.
if err := client.Publish(ctx, entity); err != nil {
log.Fatal(err)
}
SaveDraft uses the same persistence behavior as OperationUpsert, and Publish uses the same publish behavior as OperationPublish, without dry-run or interactive confirmation.
Creating Assets from a URL
Contentful fetches the file itself from a remote URL. The flow is create draft → trigger processing → wait
for processing to finish, and CreateAssetFromURL wraps all three:
// Writing an asset needs no space model, so both halves of the sync can be skipped:
// Init then only loads locales and content types.
config := commanderclient.LoadConfigFromEnv()
config.SkipEntries = true
config.SkipAssets = true
client, _, err := commanderclient.Init(config)
if err != nil {
log.Fatal(err)
}
// Returns once Contentful has processed the file, so the asset already has its CDN URL.
asset, err := client.CreateAssetFromURL(ctx,
"moonsample", // asset ID — empty lets Contentful generate one
"https://example.com/img/moon.jpg", // Contentful fetches this
"image/jpeg", // content type
"Moon", // title
commanderclient.Locale("en"), // empty uses the space default locale
)
if err != nil {
log.Fatal(err)
}
file := asset.GetFile(commanderclient.Locale("en"))
fmt.Printf("created %s at https:%s\n", asset.GetID(), file.URL)
// Same, but published rather than left as a draft.
asset, err = client.CreateAssetFromURLAndPublish(ctx, "moonsample2", url, "image/jpeg", "Moon", "en")
Details worth knowing:
- File name is derived from the URL's last path segment (
moon.jpgabove, query string stripped). If the URL has no usable segment, the asset ID is used. - Title is required. Contentful only processes locales that have a title, so an untitled asset would be silently skipped and never finish processing.
- Processing is asynchronous. The methods poll until the CDN URL appears, bounded by
ctx. Ifctxhas no deadline, they give up after 2 minutes. Usecontext.WithTimeoutto shorten that. - Creating over an existing ID is an error rather than an overwrite — use
SaveDraftto modify an existing asset. - The new asset is added to the client cache, so
GetEntity/GetAssetssee it immediately. - Only remote URLs are supported; uploading local file bytes is not.
Available Operations
// Migration operation constants
const (
OperationCreate = "create" // Create a new entity
OperationUpsert = "upsert" // Create or update an entity
OperationUpdate = "update" // Update an existing entity (preserves publishing status)
OperationDelete = "delete" // Delete an entity
OperationPublish = "publish" // Publish an entity
OperationUnpublish = "unpublish" // Unpublish an entity
)
Operation Details
OperationCreate: Creates a new entity (not commonly used as entities are typically created through Contentful UI)OperationUpsert: Creates a new entity or updates an existing one with new fieldsOperationUpdate: Updates an existing entity with new fields and preserves its current publishing status (if published, it will be republished)OperationDelete: Permanently deletes an entity from ContentfulOperationPublish: Publishes an entity (makes it available in the delivery API)OperationUnpublish: Unpublishes an entity (removes it from the delivery API but keeps it in the space)
Usage Examples
Execute batch operations with comprehensive error handling:
operations := []commanderclient.MigrationOperation{
{
EntityID: "entity-id",
Operation: commanderclient.OperationUpdate,
Entity: entity,
},
}
options := commanderclient.DefaultMigrationOptions()
options.DryRun = false
options.Confirm = true // Prompt before executing each operation
executor := commanderclient.NewMigrationExecutor(client, options)
results := executor.ExecuteBatch(ctx, operations)
// Check results
successCount := executor.GetSuccessCount()
errorCount := executor.GetErrorCount()
Concurrent Execution
ExecuteBatch runs operations concurrently by default with a concurrency level of 3. This means up to 3 API calls to Contentful will be made in parallel. Individual operation failures do not stop the batch - all operations are attempted.
// Optionally adjust concurrency level (default is 3)
client.SetConcurrency(5) // Run up to 5 operations in parallel
// Execute batch - runs concurrently
executor := commanderclient.NewMigrationExecutor(client, options)
results := executor.ExecuteBatch(ctx, operations)
Note: When options.Confirm = true, operations run sequentially to allow for stdin interaction.
With confirmations enabled, each operation's details (space, environment, entity metadata, and action) are printed before execution, and pressing Enter accepts the default Y.
Creating Different Types of Operations
// Update operation (most common)
updateOp := &commanderclient.MigrationOperation{
EntityID: "product-123",
Operation: commanderclient.OperationUpdate,
Entity: productEntity,
}
// Publish operation
publishOp := &commanderclient.MigrationOperation{
EntityID: "product-123",
Operation: commanderclient.OperationPublish,
Entity: productEntity,
}
// Delete operation
deleteOp := &commanderclient.MigrationOperation{
EntityID: "old-product-456",
Operation: commanderclient.OperationDelete,
Entity: oldProductEntity,
}
// Using collection methods to create operations
products := client.FilterEntities(
commanderclient.FilterByContentType("product"),
commanderclient.FilterDrafts(),
)
// Create update operations for all draft products
updateOps := products.ToUpdateOperations()
// Create publish operations for all products
publishOps := products.ToPublishOperations()
// Create delete operations for old products
oldProducts := client.FilterEntities(
commanderclient.FilterByContentType("product"),
commanderclient.FilterByUpdatedBefore(time.Now().AddDate(-2, 0, 0)),
)
deleteOps := oldProducts.ToDeleteOperations()
Built-in Filters
The library provides many built-in filters:
// Content type filters
commanderclient.FilterByContentType("product", "category")
commanderclient.FilterByType("Entry") // or "Asset"
// Publication status
commanderclient.FilterPublished()
commanderclient.FilterDrafts()
// CDA view filters (requires CDA key)
commanderclient.FilterHasCDAView() // has a published CDA snapshot
commanderclient.FilterNoCDAView() // draft-only or no CDA key
// Timestamp filters
commanderclient.FilterByCreatedAfter(time)
commanderclient.FilterByUpdatedAfter(time)
// Field filters
commanderclient.FilterByFieldValue("status", "active")
commanderclient.FilterByFieldExists("description")
commanderclient.FilterByFieldContains("title", "important")
commanderclient.FilterByFieldValueWithLocale("title", commanderclient.Locale("en"), "Welcome")
commanderclient.FilterByFieldEmptyWithLocale("description", commanderclient.Locale("de"))
commanderclient.FilterByFieldNotEmptyWithLocale("description", commanderclient.Locale("en"))
// ID patterns
commanderclient.FilterByIDPattern("prod-")
Configuration
Load configuration from environment variables and initialize a ready-to-use client:
// From environment variables
config := commanderclient.LoadConfigFromEnv()
// Or create custom config
config := &commanderclient.Config{
CMAToken: "your-cma-key",
CDAToken: "your-cda-key", // optional — enables CDA views
SpaceID: "your-space-id",
Environment: "master",
Verbose: true,
SkipAssets: false, // set true to skip loading assets entirely
SkipEntries: false, // set true to skip loading entries entirely
}
// Initialize ready-to-use client with logger and loaded space model
client, logger, err := commanderclient.Init(config)
if err != nil {
log.Fatal(err)
}
Environment variables:
CONTENTFUL_CMAKEY: CMA API key (mandatory)CONTENTFUL_CDAKEY: CDA API key (optional — enables CDA views on entities)CONTENTFUL_SPACE_ID: Space ID (mandatory)CONTENTFUL_ENVIRONMENT: Environment (default: "dev")CONTENTFUL_VERBOSE: Enable verbose loggingDEEPL_API_KEY: DeepL API key (required for translation features)
Code-only config options (not loaded from env):
Config.SkipAssets: Skip loading assets to save time and bandwidth when only entries are neededConfig.SkipEntries: Skip loading entries when only assets are needed
Both flags apply to LoadSpaceModel and UpdateSpaceModel, and to the CDA view phase when a CDA key is
configured. Locales and content types are always loaded. With only one entity kind in the cache, lookups
that cross the boundary return nothing — GetParents/GetReferrerPath on an asset finds no referring
entries when SkipEntries is set, and entry reference resolution finds no linked assets when SkipAssets
is set. Setting both flags loads locales and content types only.
Example Usage
See the example/ directory for complete examples that demonstrate:
- Loading space models
- Filtering entities by various criteria
- Type-safe field access
- Reference resolution and handling
- Asset-specific operations
- Collection operations and chaining
- Creating and executing migration operations
- Handling results and statistics
- DeepL translation with cost tracking
Basic Example
package main
import (
"log"
"github.com/foomo/contentfulcommander"
)
func main() {
// Load config and initialize ready-to-use client
config := commanderclient.LoadConfigFromEnv()
client, logger, err := commanderclient.Init(config)
if err != nil {
log.Fatal(err)
}
// Get entities as collections
allEntities := client.GetAllEntities()
entries := client.GetEntries()
assets := client.GetAssets()
// Filter entities
products := client.FilterEntities(
commanderclient.FilterByContentType("product"),
commanderclient.FilterPublished(),
)
// Process entries with type-safe field access
products.ForEach(func(entity commanderclient.Entity) {
title := entity.GetFieldValueAsString("title", commanderclient.Locale("en"))
price := entity.GetFieldValueAsFloat64("price", commanderclient.Locale("en"))
// Handle references
if categoryEntity, found := entity.GetFieldValueAsReferencedEntity("category", commanderclient.Locale("en")); found {
categoryName := categoryEntity.GetFieldValueAsString("name", commanderclient.Locale("en"))
logger.Info("Product: %s (Category: %s, Price: %.2f)", title, categoryName, price)
}
})
// Process assets
assets.ForEach(func(asset commanderclient.Entity) {
title := asset.GetTitle(commanderclient.Locale("en"))
file := asset.GetFile(commanderclient.Locale("en"))
if file != nil {
logger.Info("Asset: %s (%s)", title, file.Name)
}
})
}
Error Handling
The library provides comprehensive error handling:
// Check operation results
for _, result := range results {
if !result.Success {
log.Printf("Failed to %s %s: %v",
result.Operation, result.EntityID, result.Error)
}
}
// Get summary statistics
stats := client.GetStats()
log.Printf("Processed %d entities with %d errors",
stats.TotalEntities, stats.Errors)
Performance Considerations
- The library loads entire space models into memory for efficient operations
- Incremental updates: Use
UpdateSpaceModelinstead ofLoadSpaceModelto refresh only recently changed entities — significantly faster for large spaces with infrequent changes - Concurrent loading: Entries and assets are loaded in parallel for faster initialization. Initial entry loading is split by content type, starts with 1000-entry pages, and halves the page size only for content types that hit Contentful response-size limits. When a CDA key is provided, CDA views are loaded in a second concurrent phase after CMA data
- Skip assets or entries: Set
Config.SkipAssets = truefor entry-only migrations, orConfig.SkipEntries = truefor asset-only migrations — each skips the other kind's CMA and CDA load entirely - Concurrent batch execution:
ExecuteBatchruns operations concurrently (default: 3 parallel API calls, configurable viaclient.SetConcurrency(n)) - Use appropriate batch sizes for large operations
- Consider using dry-run mode for testing
- Filter entities early to reduce memory usage
- Use pagination for very large spaces
- For translation, use batch methods (
TranslateFieldBatch) for RichText fields with many text nodes
Dependencies
github.com/foomo/contentful: Contentful Go SDK- Standard Go library only
How to Contribute
Contributions are welcome! Please read the contributing guide.
License
Distributed under MIT License, please see license file within the code for more details.
Documentation
¶
There is no documentation for this package.