Contentful Content Management API の躓きポイントメモ

## Contentfulとは？

Contentful は Headless CMS Service です。 https://www.contentful.com/

## 今回したかったこと

古い Blog システムを、 Contentful + Netlify + Nuxt.js で置き換えました。

古い Blog 記事も、新しいシステムに移行する必要があったので、移行スクリプトを書きました。想像していたよりも躓きが多かったので、どなたかの役に立つように、メモとして残しておきます。

## 移行方法

移行には Python と csv ファイルを用いました。Python の実行環境については説明しません。3系を使っています。

## ドキュメント

やはり困ったら公式ドキュメントに行けば良い・・・のですが、それでも躓いたポイントがありました。

https://contentful.github.io/contentful-management.py/#usage

## 躓きポイント

### Create 時に明示的に ID を渡さなければならない 

ドキュメントに、唐突に下記のような記述が出てきます。

```python
new_asset = client.assets('my_space_id', 'environment_id').create(
    'new_asset_id',
    file_attributes
)
```

はて、'new_asset_id' ってなんだ・・・。僕が思うに、リソースを新たに作成した際には、システムが自動でIDを採番すべきだと思うのですが、そうなっていません。

このIDがグローバルユニークである必要があるのか（他のテナントともかぶってはいけないのか）、自分のテナント内でユニークならいいのか、そのモデルの中でユニークならいいのか、さっぱりわかりません。

その後のユースケースを考えると、おそらく「自分のテナント内でユニークなら良い」ということな気がします。僕は下記の関数でお茶を濁しました。

```python
# resouce_name: モデル名やAsset名を渡す
def getRandomString(resource_name):
    return resource_name + ''.join(random.choice(string.ascii_uppercase + string.ascii_lowercase + string.digits) for _ in range(30))

```

#### 追記

> When creating resources, you can specify an ID or let the API generate a random ID for you. 
> https://www.contentful.com/developers/docs/references/content-management-api/#/introduction/resource-ids

Python SDK の方ではなく、API仕様書の方には、IDは自動採番されると書いてありますね・・・。使った限りは、IDを渡さないとエラーになると思うのですが・・・。

### asset の publish タイミング

アイキャッチなどの画像をアップロードする必要がありました。

ちなみに Contentful は、ローカルからファイルをアップロードするだけでなく、公開リンクから画像をダウンロードすることもできます。非常にシンプルなプログラムが書けますし、手動の画像移行も楽に行えるのでとても便利です。今回のプロジェクトでは、Google Driveの公開リンクから画像を移行することにしました。

GUIからも同様の操作が行えます。↓

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/20160/82eafc1f-c37e-8ac0-e313-dfa29e020235.png)

さて、公式ドキュメントをみると、こんな感じのスクリプトが書けます。

```python
fid = 'hoge'
downloadLink = 'https://docs.google.com/uc?export=download&id=hoge'
attributes = {
    'fields': {
        'title': {
          'en-US': fid
        },
        'file': {
            'en-US': {
                'contentType': 'image',
                'fileName': fid,
                'upload': downloadLink
            }
        }
    }
}
asset = environment.assets().create(
  getRandomString('image'), # この関数は前述
  attributes
)
asset.process() # ダウンロード処理
asset.publish() # 公開
```

すると、publishするときにこんなエラーが出ます。

> Message: Version mismatch error. The version you specified was incorrect. This may be due to someone else editing the content.

なんじゃこりゃ・・・。Transactionみたいな概念があるのか・・・？と思いましたがありませんし、回避方法がわからず苦戦しました。

ヒントは公式ドキュメントのここです。

> Processing an asset:
> `asset.process()`
> This will process the file for every available locale and provide you with a downloadable URL within Contentful.

最初は見落としたんですが、`downloadable URL` なるものが発行されれば process が終了したということなのか！と気づきました。

ということで、process と publish の間に下記のメソッドを挟むのがポイントです。

```python
asset.process()
while 'url' not in asset.fields()['file']: # url が発行されるまで待つ
  time.sleep(1)
  asset.reload()
asset.publish()
```

そもそも、

- fields が関数だ
- その戻り値が Dictionary だ
- proces, reload, publish は object 自身に作用する（ `asset = asset.process()` のように再代入が必要ない）

などが全然わからなかったので、try&errorでようやく見つけた形です。僕の Python 力が低いのも原因だとは思いますが、強力な型付け言語ではないのに、ドキュメントが不足しているのは辛い・・・。

### NaN の扱い

CSVのロードには `pandas` を使いました。移行データに欠損値が多いので、標準モジュールよりも pandas の方が扱いやすそうだと思ったからです。

NaNとなる部分は、Editor上も非必須項目なので、そのままの状態で `entries().create` などに投げつけると下記のエラーが返ってきます。

```python
attributes = {
    'content_type_id': 'contentTypeName',
    'fields': {
        'hoge': {
          'en-US': NaN,
        }
    }
}
environment.entries().create(getRandomString('contentTypeName'), attributes) # getRandomString は前述
```

> contentful_management.errors.BadRequestError: HTTP status code: 400
> Message: Invalid JSON in request body

エラーメッセージからは NaN が原因だとわからないので、普通に JSON の形が間違っているのかと思ってしまいました。しかし、結局 NaN は受け付けないのだと気づいたので、下記のように修正しました。

```python
hoge = NaN
if type(hoge) == str:
    attributes['fields']['hoge'] = {
        'en-US': hoge
    }
```

str かどうかは、作業内容によると思いますが、ご参考まで。

### Boolean

Boolean の attribute を持つ Entry を処理する際に、下記エラーに遭遇しました。

```python
attributes = {
    'content_type_id': 'contentTypeName',
    'fields': {
        'somethingEnabled': True
    }
}
environment.entries().create(getRandomString('contentTypeName'), attributes) # getRandomString は前述
```

> contentful_management.errors.UnprocessableEntityError: HTTP status code: 422
> Message: Validation error
> Details: 
>         * Name: type - Path: '['fields', 'somethingEnabled']' - Value: 'True'

Boolean が Validation Error ではじかれてしまいました。Primitive な Boolean は受け付けないのか？と考え、文字列にしてみたり数値にしてみたりいろいろやりました。結果正解はこちらです。

```python
attributes = {
    'content_type_id': 'contentTypeName',
    'fields': {
        'somethingEnabled': {
            'en-US': True
        }
    }
}
```

boolean にもローカライズが必要ということですね。今回は僕は英語のコンテンツだけでやりましたが、ja-JP も en-US もある場合は、2つずつ登録することになるのかと・・・。ローカライズとは、文字の翻訳のことで、IDやBooleanは翻訳しないのが自然かとも思いましたが・・・。余裕があるときにPRでも出してみようかと思います。

### 参照型

記事が複数のカテゴリを参照したり、画像を参照したりすることはよくあります。画面からの操作では非常に直感的に扱えるのですが、API経由だと本当に複雑でドキュメントからでは全く読み取れないので、ここにメモを残しておきます。

結論から書けば、1つの参照の場合は

```python
'category': {
  'en-US': {
    'sys': {
      'type': 'Link',
      'linkType': 'Entry', # Entry, Asset
      'id': categoryId
    }
  }
},
```

というように、`en-US.sys.id` という参照構造にします。

複数の参照の場合は

```python
'categories': {
  'en-US': [
    {
      'sys': {
        'type': 'Link',
        'linkType': 'Entry',
        'id': categoryId
      }
    }
  ]
}
```

というように、 `en-US[0].sys.id` のように、`en-US` 直下に配列を置きます。

ここからわかることは2つあり

* 当然ですが、コンテンツは子コンテンツから先に作っていく（画像⇒カテゴリ⇒記事のような順番）
* IDはテナントでユニークにいなければならない（記事IDとカテゴリIDが重複してはいけない）

ということになります。上記の Link 指定で、ContentType は指定していませんから、IDから逆引きでコンテンツを判定しているためです。

以上ご参考になれば幸いです。