diff --git a/docs/04-api-reference.md b/docs/04-api-reference.md index 7533eb4..dd14789 100644 --- a/docs/04-api-reference.md +++ b/docs/04-api-reference.md @@ -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 ``` diff --git a/erm/space.go b/erm/space.go index 93c890a..8e9e224 100644 --- a/erm/space.go +++ b/erm/space.go @@ -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") } diff --git a/erm/templates/contentful_vo_lib.gotmpl b/erm/templates/contentful_vo_lib.gotmpl index 8c5f118..997d4cc 100644 --- a/erm/templates/contentful_vo_lib.gotmpl +++ b/erm/templates/contentful_vo_lib.gotmpl @@ -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 { diff --git a/erm/templates/contentful_vo_lib_contenttype.gotmpl b/erm/templates/contentful_vo_lib_contenttype.gotmpl index 4d59f72..b51dc9a 100644 --- a/erm/templates/contentful_vo_lib_contenttype.gotmpl +++ b/erm/templates/contentful_vo_lib_contenttype.gotmpl @@ -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") } - 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) + if publishingStatus == StatusPublished { + vo.Sys.Version++ + err = vo.CC.Client.Entries.Publish(ctx, vo.CC.SpaceID, cfEntry) + if err != nil { + return fmt.Errorf("CfShopCategory UpdateEntry: publish operation failed: %w", err) + } } return } diff --git a/test/testapi/gocontentfulvolib.go b/test/testapi/gocontentfulvolib.go index 9f18105..9c8ef9c 100644 --- a/test/testapi/gocontentfulvolib.go +++ b/test/testapi/gocontentfulvolib.go @@ -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 { diff --git a/test/testapi/gocontentfulvolibbrand.go b/test/testapi/gocontentfulvolibbrand.go index 1325256..509794b 100644 --- a/test/testapi/gocontentfulvolibbrand.go +++ b/test/testapi/gocontentfulvolibbrand.go @@ -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") } - err = vo.CC.Client.Entries.Publish(ctx, vo.CC.SpaceID, cfEntry) - if err != nil { - return fmt.Errorf("CfBrand UpdateEntry: publish operation failed: %w", err) + if publishingStatus == StatusPublished { + vo.Sys.Version++ + err = vo.CC.Client.Entries.Publish(ctx, vo.CC.SpaceID, cfEntry) + if err != nil { + return fmt.Errorf("CfShopCategory UpdateEntry: publish operation failed: %w", err) + } } return } diff --git a/test/testapi/gocontentfulvolibcategory.go b/test/testapi/gocontentfulvolibcategory.go index 3ccda72..d30171e 100644 --- a/test/testapi/gocontentfulvolibcategory.go +++ b/test/testapi/gocontentfulvolibcategory.go @@ -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") } - err = vo.CC.Client.Entries.Publish(ctx, vo.CC.SpaceID, cfEntry) - if err != nil { - return fmt.Errorf("CfCategory UpdateEntry: publish operation failed: %w", err) + if publishingStatus == StatusPublished { + vo.Sys.Version++ + err = vo.CC.Client.Entries.Publish(ctx, vo.CC.SpaceID, cfEntry) + if err != nil { + return fmt.Errorf("CfShopCategory UpdateEntry: publish operation failed: %w", err) + } } return } diff --git a/test/testapi/gocontentfulvolibproduct.go b/test/testapi/gocontentfulvolibproduct.go index 5095226..8e3bd40 100644 --- a/test/testapi/gocontentfulvolibproduct.go +++ b/test/testapi/gocontentfulvolibproduct.go @@ -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") } - err = vo.CC.Client.Entries.Publish(ctx, vo.CC.SpaceID, cfEntry) - if err != nil { - return fmt.Errorf("CfProduct UpdateEntry: publish operation failed: %w", err) + if publishingStatus == StatusPublished { + vo.Sys.Version++ + err = vo.CC.Client.Entries.Publish(ctx, vo.CC.SpaceID, cfEntry) + if err != nil { + return fmt.Errorf("CfShopCategory UpdateEntry: publish operation failed: %w", err) + } } return } diff --git a/test/testapi/meta.go b/test/testapi/meta.go index 83e7ab6..b613ea6 100644 --- a/test/testapi/meta.go +++ b/test/testapi/meta.go @@ -1,2 +1,2 @@ -// gocontentful version: 1.1.1 +// gocontentful version: 1.1.2 package testapi