feat: improved asset and generic entry API, docs

This commit is contained in:
Cristian Vidmar 2025-08-13 16:26:29 +02:00
parent bb57ab35b2
commit 8938a65f43
9 changed files with 409 additions and 19 deletions

View File

@ -241,9 +241,10 @@ a "Version mismatch" error. This is needed even if you have just upserted the en
(vo *CfPerson) UpdateEntry(cc *ContentfulClient) (err error)
```
Shortcut function that upserts and publishes the entry. Note that before calling this you will need to retrieve the
entry with one of the Manage\* functions above to acquire the Sys object that contains the version information. Otherwise
the API call will fail with a "Version mismatch" error. Using this shortcut function avoids retrieving the entry twice.
First upserts the entry and then it publishes it only if it was already published before upserting.
The rationale is to respect the publishing status of entries and prevent unexpected go-live of content.
Note that before calling this you will need to retrieve theentry with one of the Manage\* functions above to acquire the Sys object that contains the version information.
Otherwise the API call will fail with a "Version mismatch" error. Using this shortcut function avoids retrieving the entry twice.
```go
(vo *CfPerson) DeleteEntry(cc *ContentfulClient) (err error)
@ -313,6 +314,21 @@ Sets a generic entry's field value.
Upserts the generic entry to the space it came from.
```go
(genericEntry *GenericEntry) Update(ctx context.Context) (err error)
```
Upserts the generic entry and publishes it only if it was already published before upserting. Only available for
ClientModeCMA. Before calling this you should retrieve the entry to acquire the Sys version; otherwise the API may fail
with a "Version mismatch" error.
```go
(genericEntry *GenericEntry) GetPublishingStatus() string
```
Returns the publishing status of the entry as per the Contentful editor UI. The value is one of `StatusDraft`,
`StatusChanged`, or `StatusPublished`.
### Asset functions
```go
@ -352,6 +368,27 @@ ToAssetReference(asset *contentful.Asset) (refSys ContentTypeSys)
Converts the asset to a reference. You need to do this before you add the asset to a reference field of an entry.
```go
(cc *ContentfulClient) UpsertAsset(ctx context.Context, asset *contentful.Asset) error
```
Upserts an asset into the space. Only available for ClientModeCMA. Normalizes file URLs by removing the `https:` prefix
for each locale file, and tolerates idempotency errors returned by the SDK.
```go
(cc *ContentfulClient) PublishAsset(ctx context.Context, asset *contentful.Asset) error
```
Publishes an asset. Only available for ClientModeCMA. The call is idempotent and tolerates "Not published" responses
from the SDK.
```go
(cc *ContentfulClient) UpdateAsset(ctx context.Context, asset *contentful.Asset) (err error)
```
First upserts the asset and then publishes it only if it was already published before upserting. This respects the
current publishing status and avoids unintended go-live of assets. Only available for ClientModeCMA.
```go
(cc *ContentfulClient) DeleteAsset(asset *contentful.Asset) error
```

View File

@ -30,7 +30,7 @@ type spaceConf struct {
func getLocales(ctx context.Context, CMA *contentful.Contentful, spaceID string) (locales []Locale, err error) {
col, err := CMA.Locales.List(ctx, spaceID).GetAll()
if err != nil {
log.Fatal("Couldn't get locales")
log.Fatalf("Couldn't get locales: %v", err)
}
for _, item := range col.Items {
var locale Locale
@ -49,7 +49,6 @@ func getLocales(ctx context.Context, CMA *contentful.Contentful, spaceID string)
// GetContentTypes retrieves content type definition from Contentful
func getContentTypes(ctx context.Context, CMA *contentful.Contentful, spaceID string) (contentTypes []ContentType, err error) {
col := CMA.ContentTypes.List(ctx, spaceID)
_, err = col.GetAll()
if err != nil {
@ -148,7 +147,7 @@ func GenerateAPI(ctx context.Context, dir, packageName, spaceID, cmaKey, environ
}
packageDir := filepath.Join(dir, packageName)
errMkdir := os.MkdirAll(packageDir, 0766)
errMkdir := os.MkdirAll(packageDir, 0o766)
if errMkdir != nil {
return errors.Wrap(errMkdir, "could not create target folder")
}

View File

@ -277,6 +277,78 @@ func (ref ContentfulReferencedEntry) ContentType() (contentType string) {
return ref.Entry.Sys.ContentType.Sys.ID
}
func (cc *ContentfulClient) UpsertAsset(ctx context.Context, asset *contentful.Asset) error {
if cc == nil || cc.Client == nil {
return errors.New("UpsertAsset: No client available")
}
if cc.clientMode != ClientModeCMA {
return errors.New("UpsertAsset: Only available in ClientModeCMA")
}
if asset.Fields != nil && asset.Fields.File != nil {
for _, locale := range SpaceLocales {
if asset.Fields.File[string(locale)] != nil {
asset.Fields.File[string(locale)].URL = strings.ReplaceAll(asset.Fields.File[string(locale)].URL, "https:", "")
}
}
}
errUpsert := cc.Client.Assets.Upsert(ctx, cc.SpaceID, asset)
if errUpsert != nil && !strings.Contains(errUpsert.Error(), "Not upserted") {
return errUpsert
}
return nil
}
func (cc *ContentfulClient) PublishAsset(ctx context.Context, asset *contentful.Asset) error {
if cc == nil || cc.Client == nil {
return errors.New("PublishAsset: No client available")
}
if cc.clientMode != ClientModeCMA {
return errors.New("PublishAsset: Only available in ClientModeCMA")
}
errPublish := cc.Client.Assets.Publish(ctx, cc.SpaceID, asset)
if errPublish != nil && !strings.Contains(errPublish.Error(), "Not published") {
return errPublish
}
return nil
}
func GetAssetPublishingStatus(asset *contentful.Asset) string {
if asset == nil {
return ""
}
if asset.Sys.PublishedVersion == 0 {
return StatusDraft
}
if asset.Sys.Version-asset.Sys.PublishedVersion == 1 {
return StatusPublished
}
return StatusChanged
}
func (cc *ContentfulClient) UpdateAsset(ctx context.Context, asset *contentful.Asset) (err error) {
if asset == nil {
return errors.New("UpdateAsset: Generic Entry is nil")
}
if cc == nil {
return errors.New("UpdateAsset: Generic Entry has nil Contentful client")
}
if cc.clientMode != ClientModeCMA {
return errors.New("UpdateAsset: Generic Entry not in ClientModeCMA")
}
publishingStatus := GetAssetPublishingStatus(asset)
err = cc.UpsertAsset(ctx,asset)
if err != nil {
return fmt.Errorf("UpdateAsset: upsert operation failed: %w", err)
}
if publishingStatus == StatusPublished {
err = cc.PublishAsset(ctx, asset)
if err != nil {
return fmt.Errorf("UpdateAsset: publish operation failed: %w", err)
}
}
return
}
func (cc *ContentfulClient) DeleteAsset(ctx context.Context, asset *contentful.Asset) error {
if cc == nil || cc.Client == nil {
return errors.New("DeleteAsset: No client available")
@ -919,6 +991,9 @@ func (genericEntry *GenericEntry) FieldAsReference(fieldName string, locale ...L
if err != nil {
return nil, err
}
if cts.Sys.ID == "" {
return nil, errors.New("not a reference")
}
referencedEntry, err := genericEntry.CC.GetGenericEntry(cts.Sys.ID)
if err != nil || referencedEntry == nil {
return nil, err
@ -1049,6 +1124,9 @@ func (genericEntry *GenericEntry) FieldAsMultipleReference(fieldName string, loc
}
var refs []*EntryReference
for _, cts := range ctss {
if cts.Sys.ID == "" {
continue
}
referencedEntry, err := genericEntry.CC.GetGenericEntry(cts.Sys.ID)
if err != nil || referencedEntry == nil {
return nil, err
@ -1255,6 +1333,62 @@ func (genericEntry *GenericEntry) Upsert(ctx context.Context) error {
return nil
}
func (genericEntry *GenericEntry) Update(ctx context.Context) (err error) {
if genericEntry == nil {
return errors.New("Update: Generic Entry is nil")
}
if genericEntry.CC == nil {
return errors.New("Update: Generic Entry has nil Contentful client")
}
if genericEntry.CC.clientMode != ClientModeCMA {
return errors.New("Update: Generic Entry not in ClientModeCMA")
}
publishingStatus := genericEntry.GetPublishingStatus()
cfEntry := &contentful.Entry{}
tmp, errMarshal := json.Marshal(genericEntry)
if errMarshal != nil {
return errors.New("Update: Can't marshal JSON from VO")
}
errUnmarshal := json.Unmarshal(tmp, &cfEntry)
if errUnmarshal != nil {
return errors.New("Update: Can't unmarshal JSON into CF entry")
}
err = genericEntry.CC.Client.Entries.Upsert(ctx, genericEntry.CC.SpaceID, cfEntry)
if err != nil {
return fmt.Errorf("Update: upsert operation failed: %w", err)
}
tmp, errMarshal = json.Marshal(cfEntry)
if errMarshal != nil {
return errors.New("Update: Can't marshal JSON back from CF entry")
}
errUnmarshal = json.Unmarshal(tmp, &genericEntry)
if errUnmarshal != nil {
return errors.New("Update: Can't unmarshal JSON back into Generic Entry")
}
if publishingStatus == StatusPublished {
genericEntry.Sys.Version++
err = genericEntry.CC.Client.Entries.Publish(ctx, genericEntry.CC.SpaceID, cfEntry)
if err != nil {
return fmt.Errorf("Update: publish operation failed: %w", err)
}
}
return
}
func (genericEntry *GenericEntry) GetPublishingStatus() string {
if genericEntry == nil {
return ""
}
if genericEntry.Sys.PublishedVersion == 0 {
return StatusDraft
}
if genericEntry.Sys.Version-genericEntry.Sys.PublishedVersion == 1 {
return StatusPublished
}
return StatusChanged
}
func (cc *ContentfulClient) ClientMode() ClientMode {
return cc.clientMode
}
@ -2260,6 +2394,41 @@ func (n *RichTextGenericNode) richTextRenderHTML(w io.Writer, linkResolver LinkR
return
}
// MarshalJSON implements custom JSON marshalling for RichTextGenericNode for compatibility with Contentful's upsert API
func (n *RichTextGenericNode) MarshalJSON() ([]byte, error) {
if n == nil {
return []byte("null"), nil
}
if n.Data == nil {
n.Data = make(map[string]interface{})
}
if n.NodeType == "text" {
// For text nodes, include Data, Value and Marks, but not Content
return json.Marshal(&struct {
NodeType string `json:"nodeType"`
Data map[string]interface{} `json:"data"`
Value string `json:"value"`
Marks []RichTextMark `json:"marks"`
}{
NodeType: n.NodeType,
Data: n.Data,
Value: n.Value,
Marks: n.Marks,
})
} else {
// For non-text nodes, exclude Data and Marks
return json.Marshal(&struct {
NodeType string `json:"nodeType"`
Data map[string]interface{} `json:"data"`
Content []*RichTextGenericNode `json:"content"`
}{
NodeType: n.NodeType,
Content: n.Content,
Data: n.Data,
})
}
}
func stringSliceContains(s []string, e string) bool {
for _, a := range s {
if a == e {

View File

@ -625,6 +625,7 @@ func (vo *Cf{{ firstCap $contentType.Sys.ID }}) UpdateEntry(ctx context.Context)
if vo.CC.clientMode != ClientModeCMA {
return errors.New("UpdateEntry: Only available in ClientModeCMA")
}
publishingStatus := vo.GetPublishingStatus()
cfEntry := &contentful.Entry{}
tmp, errMarshal := json.Marshal(vo)
if errMarshal != nil {
@ -647,9 +648,12 @@ func (vo *Cf{{ firstCap $contentType.Sys.ID }}) UpdateEntry(ctx context.Context)
if errUnmarshal != nil {
return errors.New("Cf{{ firstCap $contentType.Sys.ID }} UpdateEntry: Can't unmarshal JSON back into VO")
}
if publishingStatus == StatusPublished {
vo.Sys.Version++
err = vo.CC.Client.Entries.Publish(ctx, vo.CC.SpaceID, cfEntry)
if err != nil {
return fmt.Errorf("Cf{{ firstCap $contentType.Sys.ID }} UpdateEntry: publish operation failed: %w", err)
return fmt.Errorf("CfShopCategory UpdateEntry: publish operation failed: %w", err)
}
}
return
}

View File

@ -294,6 +294,78 @@ func (ref ContentfulReferencedEntry) ContentType() (contentType string) {
return ref.Entry.Sys.ContentType.Sys.ID
}
func (cc *ContentfulClient) UpsertAsset(ctx context.Context, asset *contentful.Asset) error {
if cc == nil || cc.Client == nil {
return errors.New("UpsertAsset: No client available")
}
if cc.clientMode != ClientModeCMA {
return errors.New("UpsertAsset: Only available in ClientModeCMA")
}
if asset.Fields != nil && asset.Fields.File != nil {
for _, locale := range SpaceLocales {
if asset.Fields.File[string(locale)] != nil {
asset.Fields.File[string(locale)].URL = strings.ReplaceAll(asset.Fields.File[string(locale)].URL, "https:", "")
}
}
}
errUpsert := cc.Client.Assets.Upsert(ctx, cc.SpaceID, asset)
if errUpsert != nil && !strings.Contains(errUpsert.Error(), "Not upserted") {
return errUpsert
}
return nil
}
func (cc *ContentfulClient) PublishAsset(ctx context.Context, asset *contentful.Asset) error {
if cc == nil || cc.Client == nil {
return errors.New("PublishAsset: No client available")
}
if cc.clientMode != ClientModeCMA {
return errors.New("PublishAsset: Only available in ClientModeCMA")
}
errPublish := cc.Client.Assets.Publish(ctx, cc.SpaceID, asset)
if errPublish != nil && !strings.Contains(errPublish.Error(), "Not published") {
return errPublish
}
return nil
}
func GetAssetPublishingStatus(asset *contentful.Asset) string {
if asset == nil {
return ""
}
if asset.Sys.PublishedVersion == 0 {
return StatusDraft
}
if asset.Sys.Version-asset.Sys.PublishedVersion == 1 {
return StatusPublished
}
return StatusChanged
}
func (cc *ContentfulClient) UpdateAsset(ctx context.Context, asset *contentful.Asset) (err error) {
if asset == nil {
return errors.New("UpdateAsset: Generic Entry is nil")
}
if cc == nil {
return errors.New("UpdateAsset: Generic Entry has nil Contentful client")
}
if cc.clientMode != ClientModeCMA {
return errors.New("UpdateAsset: Generic Entry not in ClientModeCMA")
}
publishingStatus := GetAssetPublishingStatus(asset)
err = cc.UpsertAsset(ctx, asset)
if err != nil {
return fmt.Errorf("UpdateAsset: upsert operation failed: %w", err)
}
if publishingStatus == StatusPublished {
err = cc.PublishAsset(ctx, asset)
if err != nil {
return fmt.Errorf("UpdateAsset: publish operation failed: %w", err)
}
}
return
}
func (cc *ContentfulClient) DeleteAsset(ctx context.Context, asset *contentful.Asset) error {
if cc == nil || cc.Client == nil {
return errors.New("DeleteAsset: No client available")
@ -949,6 +1021,9 @@ func (genericEntry *GenericEntry) FieldAsReference(fieldName string, locale ...L
if err != nil {
return nil, err
}
if cts.Sys.ID == "" {
return nil, errors.New("not a reference")
}
referencedEntry, err := genericEntry.CC.GetGenericEntry(cts.Sys.ID)
if err != nil || referencedEntry == nil {
return nil, err
@ -1079,6 +1154,9 @@ func (genericEntry *GenericEntry) FieldAsMultipleReference(fieldName string, loc
}
var refs []*EntryReference
for _, cts := range ctss {
if cts.Sys.ID == "" {
continue
}
referencedEntry, err := genericEntry.CC.GetGenericEntry(cts.Sys.ID)
if err != nil || referencedEntry == nil {
return nil, err
@ -1284,6 +1362,62 @@ func (genericEntry *GenericEntry) Upsert(ctx context.Context) error {
return nil
}
func (genericEntry *GenericEntry) Update(ctx context.Context) (err error) {
if genericEntry == nil {
return errors.New("Update: Generic Entry is nil")
}
if genericEntry.CC == nil {
return errors.New("Update: Generic Entry has nil Contentful client")
}
if genericEntry.CC.clientMode != ClientModeCMA {
return errors.New("Update: Generic Entry not in ClientModeCMA")
}
publishingStatus := genericEntry.GetPublishingStatus()
cfEntry := &contentful.Entry{}
tmp, errMarshal := json.Marshal(genericEntry)
if errMarshal != nil {
return errors.New("Update: Can't marshal JSON from VO")
}
errUnmarshal := json.Unmarshal(tmp, &cfEntry)
if errUnmarshal != nil {
return errors.New("Update: Can't unmarshal JSON into CF entry")
}
err = genericEntry.CC.Client.Entries.Upsert(ctx, genericEntry.CC.SpaceID, cfEntry)
if err != nil {
return fmt.Errorf("Update: upsert operation failed: %w", err)
}
tmp, errMarshal = json.Marshal(cfEntry)
if errMarshal != nil {
return errors.New("Update: Can't marshal JSON back from CF entry")
}
errUnmarshal = json.Unmarshal(tmp, &genericEntry)
if errUnmarshal != nil {
return errors.New("Update: Can't unmarshal JSON back into Generic Entry")
}
if publishingStatus == StatusPublished {
genericEntry.Sys.Version++
err = genericEntry.CC.Client.Entries.Publish(ctx, genericEntry.CC.SpaceID, cfEntry)
if err != nil {
return fmt.Errorf("Update: publish operation failed: %w", err)
}
}
return
}
func (genericEntry *GenericEntry) GetPublishingStatus() string {
if genericEntry == nil {
return ""
}
if genericEntry.Sys.PublishedVersion == 0 {
return StatusDraft
}
if genericEntry.Sys.Version-genericEntry.Sys.PublishedVersion == 1 {
return StatusPublished
}
return StatusChanged
}
func (cc *ContentfulClient) ClientMode() ClientMode {
return cc.clientMode
}
@ -2303,6 +2437,41 @@ func (n *RichTextGenericNode) richTextRenderHTML(w io.Writer, linkResolver LinkR
return
}
// MarshalJSON implements custom JSON marshalling for RichTextGenericNode for compatibility with Contentful's upsert API
func (n *RichTextGenericNode) MarshalJSON() ([]byte, error) {
if n == nil {
return []byte("null"), nil
}
if n.Data == nil {
n.Data = make(map[string]interface{})
}
if n.NodeType == "text" {
// For text nodes, include Data, Value and Marks, but not Content
return json.Marshal(&struct {
NodeType string `json:"nodeType"`
Data map[string]interface{} `json:"data"`
Value string `json:"value"`
Marks []RichTextMark `json:"marks"`
}{
NodeType: n.NodeType,
Data: n.Data,
Value: n.Value,
Marks: n.Marks,
})
} else {
// For non-text nodes, exclude Data and Marks
return json.Marshal(&struct {
NodeType string `json:"nodeType"`
Data map[string]interface{} `json:"data"`
Content []*RichTextGenericNode `json:"content"`
}{
NodeType: n.NodeType,
Content: n.Content,
Data: n.Data,
})
}
}
func stringSliceContains(s []string, e string) bool {
for _, a := range s {
if a == e {

View File

@ -712,6 +712,7 @@ func (vo *CfBrand) UpdateEntry(ctx context.Context) (err error) {
if vo.CC.clientMode != ClientModeCMA {
return errors.New("UpdateEntry: Only available in ClientModeCMA")
}
publishingStatus := vo.GetPublishingStatus()
cfEntry := &contentful.Entry{}
tmp, errMarshal := json.Marshal(vo)
if errMarshal != nil {
@ -734,9 +735,12 @@ func (vo *CfBrand) UpdateEntry(ctx context.Context) (err error) {
if errUnmarshal != nil {
return errors.New("CfBrand UpdateEntry: Can't unmarshal JSON back into VO")
}
if publishingStatus == StatusPublished {
vo.Sys.Version++
err = vo.CC.Client.Entries.Publish(ctx, vo.CC.SpaceID, cfEntry)
if err != nil {
return fmt.Errorf("CfBrand UpdateEntry: publish operation failed: %w", err)
return fmt.Errorf("CfShopCategory UpdateEntry: publish operation failed: %w", err)
}
}
return
}

View File

@ -476,6 +476,7 @@ func (vo *CfCategory) UpdateEntry(ctx context.Context) (err error) {
if vo.CC.clientMode != ClientModeCMA {
return errors.New("UpdateEntry: Only available in ClientModeCMA")
}
publishingStatus := vo.GetPublishingStatus()
cfEntry := &contentful.Entry{}
tmp, errMarshal := json.Marshal(vo)
if errMarshal != nil {
@ -498,9 +499,12 @@ func (vo *CfCategory) UpdateEntry(ctx context.Context) (err error) {
if errUnmarshal != nil {
return errors.New("CfCategory UpdateEntry: Can't unmarshal JSON back into VO")
}
if publishingStatus == StatusPublished {
vo.Sys.Version++
err = vo.CC.Client.Entries.Publish(ctx, vo.CC.SpaceID, cfEntry)
if err != nil {
return fmt.Errorf("CfCategory UpdateEntry: publish operation failed: %w", err)
return fmt.Errorf("CfShopCategory UpdateEntry: publish operation failed: %w", err)
}
}
return
}

View File

@ -1314,6 +1314,7 @@ func (vo *CfProduct) UpdateEntry(ctx context.Context) (err error) {
if vo.CC.clientMode != ClientModeCMA {
return errors.New("UpdateEntry: Only available in ClientModeCMA")
}
publishingStatus := vo.GetPublishingStatus()
cfEntry := &contentful.Entry{}
tmp, errMarshal := json.Marshal(vo)
if errMarshal != nil {
@ -1336,9 +1337,12 @@ func (vo *CfProduct) UpdateEntry(ctx context.Context) (err error) {
if errUnmarshal != nil {
return errors.New("CfProduct UpdateEntry: Can't unmarshal JSON back into VO")
}
if publishingStatus == StatusPublished {
vo.Sys.Version++
err = vo.CC.Client.Entries.Publish(ctx, vo.CC.SpaceID, cfEntry)
if err != nil {
return fmt.Errorf("CfProduct UpdateEntry: publish operation failed: %w", err)
return fmt.Errorf("CfShopCategory UpdateEntry: publish operation failed: %w", err)
}
}
return
}

View File

@ -1,2 +1,2 @@
// gocontentful version: 1.1.1
// gocontentful version: 1.1.2
package testapi