PythonでAmazon PA-API 5.0を使って商品情報を検索する (SDK未使用)

Amazon Product Advertising API 5.0 （商品情報API) で商品情報を検索するコードの Python 版です。

公式の API テスト実行ツールでは Java と PHP のみだったので、Lambda などで Python を使いたくなり書いてみました。公式ドキュメントを参考にしながら、SDK を使わずに SHA-256 での署名生成も行っています。（AWSのリソースにアクセスするときも、この方法が使えるようです)

### PA-API 公式ドキュメント
[https://webservices.amazon.co.jp/paapi5/documentation/sending-request.html](https://webservices.amazon.co.jp/paapi5/documentation/sending-request.html)

### API を利用するために必要なもの
- Amazon アソシエイトのパートナータグ
    - `xxxxxxxxxxxxxxx-22`
- [PA-API](https://affiliate.amazon.co.jp/assoc_credentials/home) の認証キー
    - `Access Key`, `Secret Key`
- 利用したい場所 (Locale) の、[アマゾンのホストとリージョン](https://webservices.amazon.co.jp/paapi5/documentation/common-request-parameters.html#host-and-region)
    - アマゾンジャパンなら (2022/03 時点)
        - host: `webservices.amazon.co.jp`
        - region: `us-west-2`

### 処理の流れ
1. リクエストボディの作成（送信データの本体。検索したいキーワードなど）
2. 署名の作成（一番長いコード。認証するためのハッシュ値の生成）
3. リクエストヘッダの作成（送信データの補足情報。署名はここに入れる）
4. POST メソッドでHTTPリクエスト
5. レスポンスの確認（ステータスコードが 200 以外なら、ボディにエラーが含まれる）

全体的にはシンプルな HTTP リクエストのコードです。

:::note info
こちらのサンプルコードを利用しています。
AWS 署名バージョン4 を作る Python サンプルコード
[https://docs.aws.amazon.com/general/latest/gr/sigv4-signed-request-examples.html#sig-v4-examples-post](https://docs.aws.amazon.com/ja_jp/general/latest/gr/sigv4-signed-request-examples.html#sig-v4-examples-post)
:::


## コード
Python 3.9 で確認。クラス `AmzAPi5` に一通りの処理をまとめてあります。

```ruby
import json
import requests
import sys, os, base64, datetime, hashlib, hmac

# PA-APIで検索したい文字列をセット
keywords = 'iPhone'

# アクセスキーとシークレットキーをセット
amz_api = AmzApi5('Access Key', 'Secret Key')

# 利用したいアマゾンのホストとリージョンをセット
amz_api.set_region('us-west-2')
amz_api.set_market_place('www.amazon.co.jp')

# アソシエイトのパートナータグをセット
amz_api.set_partner_tag('xxxxxxxxxx-22')
  
# SHA-256 署名を生成して、POST メソッドで HTTP リクエスト
# Param: x-amz-target, endpoint, host, path, request_parameters(without PartnerTag & MarketPlace)
# ※request_parameters でPOST送信のボディを渡しているが、ParterTagとMarketPlaceはここでは省略。クラス内で処理
r = amz_api.request_post(
    'com.amazon.paapi5.v1.ProductAdvertisingAPIv1.SearchItems',
    'https://webservices.amazon.co.jp/paapi5/searchitems',
    'webservices.amazon.co.jp',
    '/paapi5/searchitems',
    {
        'Keywords': keywords,
        'PartnerType': 'Associates',
        'Operation': 'SearchItems',
        'Resources': [
            'BrowseNodeInfo.BrowseNodes',
			'BrowseNodeInfo.BrowseNodes.Ancestor',
			'BrowseNodeInfo.BrowseNodes.SalesRank',
			'BrowseNodeInfo.WebsiteSalesRank',
			..... (省略)
        ]
    }
)

# レスポンスを確認
return {
	'status_code': r.status_code,
	'body': r.text
}
```

```ruby
class AmzApi5():
    __config = {
        'content_encoding': 'amz-1.0',
        'content_type': 'application/json; charset=utf-8',
        'service': 'ProductAdvertisingAPI'
    }
    
    def __init__(self, access_key, secret_key):
        self.__access_key = access_key
        self.__secret_key = secret_key
        
    def set_region(self, region):
        self.__region = region
        
    def set_market_place(self, market_place):
        self.__market_place = market_place
        
    def set_partner_tag(self, partner_tag):
        self.__partner_tag = partner_tag
        
    def request_post(self, amz_target, endpoint, host, path, request_parameters):
        # Set Marketplace and PartnerTag
        request_parameters['Marketplace'] = self.__market_place
        request_parameters['PartnerTag'] = self.__partner_tag

        # ********************************************
        # Create Signature (AWS4-HMAC-SHA256)
        # ********************************************
        # Copyright 2010-2019 Amazon.com, Inc. or its affiliates. All Rights Reserved.
        #
        # This file is licensed under the Apache License, Version 2.0 (the "License").
        # You may not use this file except in compliance with the License. A copy of the
        # License is located at
        #
        # http://aws.amazon.com/apache2.0/
        #
        # This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS
        # OF ANY KIND, either express or implied. See the License for the specific
        # language governing permissions and limitations under the License.
        #
        # Original Code : Example Using POST (Python). See:
        # https://docs.aws.amazon.com/general/latest/gr/sigv4-signed-request-examples.html#sig-v4-examples-post
        # ********************************************
        
        # Key derivation functions. See:
        # http://docs.aws.amazon.com/general/latest/gr/signature-v4-examples.html#signature-v4-examples-python
        def sign(key, msg):
            return hmac.new(key, msg.encode('utf-8'), hashlib.sha256).digest()
        
        def getSignatureKey(key, dateStamp, regionName, serviceName):
            kDate = sign(('AWS4' + key).encode('utf-8'), dateStamp)
            kRegion = sign(kDate, regionName)
            kService = sign(kRegion, serviceName)
            kSigning = sign(kService, 'aws4_request')
            return kSigning
        
        # Read AWS access key from env. variables or configuration file. Best practice is NOT
        # to embed credentials in code.
        access_key = self.__access_key
        secret_key = self.__secret_key
        if access_key is None or secret_key is None:
            print('No access key is available.')
            sys.exit()
            
        # Create a date for headers and the credential string
        t = datetime.datetime.utcnow()
        amz_date = t.strftime('%Y%m%dT%H%M%SZ')
        date_stamp = t.strftime('%Y%m%d') # Date w/o time, used in credential scope
        
        # ************* TASK 1: CREATE A CANONICAL REQUEST *************
        # http://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html
        
        # Step 1 is to define the verb (GET, POST, etc.)--already done.
        
        # Step 2: Create canonical URI--the part of the URI from domain to query 
        # string (use '/' if no path)
        canonical_uri = path
        
        ## Step 3: Create the canonical query string. In this example, request
        # parameters are passed in the body of the request and the query string
        # is blank.
        canonical_querystring = ''
        
        # Step 4: Create the canonical headers and signed headers. Header names
        # must be trimmed and lowercase, and sorted in code point order from
        # low to high. Note that there is a trailing \n.
        # Required fields : content-type, host, x-amz-date, x-amz-target,
        # https://webservices.amazon.co.jp/paapi5/documentation/sending-request.html#signing
        canonical_headers = 'content-type:' + self.__config['content_type'] + '\n' + 'host:' + host + '\n' + 'x-amz-date:' + amz_date + '\n' + 'x-amz-target:' + amz_target + '\n'
        
        # Step 5: Create the list of signed headers. This lists the headers
        # in the canonical_headers list, delimited with ";" and in alpha order.
        # Note: The request can include any headers; canonical_headers and
        # signed_headers lists those that you want to be included in the 
        # hash of the request. "Host" and "x-amz-date" are always required.
        signed_headers = 'content-type;host;x-amz-date;x-amz-target'
        
        # Step 6: Create payload hash. In this example, the payload (body of
        # the request) contains the request parameters.
        request_parameters_dump = json.dumps(request_parameters)
        payload_hash = hashlib.sha256(request_parameters_dump.encode('utf-8')).hexdigest()
        
        # Step 7: Combine elements to create canonical request
        canonical_request = 'POST' + '\n' + canonical_uri + '\n' + canonical_querystring + '\n' + canonical_headers + '\n' + signed_headers + '\n' + payload_hash
        
        # ************* TASK 2: CREATE THE STRING TO SIGN*************
        # Match the algorithm to the hashing algorithm you use, either SHA-1 or
        # SHA-256 (recommended)
        algorithm = 'AWS4-HMAC-SHA256'
        credential_scope = date_stamp + '/' + self.__region + '/' + self.__config['service'] + '/' + 'aws4_request'
        string_to_sign = algorithm + '\n' +  amz_date + '\n' +  credential_scope + '\n' +  hashlib.sha256(canonical_request.encode('utf-8')).hexdigest()
        
        # ************* TASK 3: CALCULATE THE SIGNATURE *************
        # Create the signing key using the function defined above.
        signing_key = getSignatureKey(self.__secret_key, date_stamp, self.__region, self.__config['service'])
        
        # Sign the string_to_sign using the signing_key
        signature = hmac.new(signing_key, (string_to_sign).encode('utf-8'), hashlib.sha256).hexdigest()
    
        # ************* TASK 4: ADD SIGNING INFORMATION TO THE REQUEST *************
        # The signing information can be either in a query string value or in 
        # a header named Authorization. This code shows how to use a header.
        # Create authorization header and add to request headers
        authorization_header = algorithm + ' ' + 'Credential=' + self.__access_key + '/' + credential_scope + ', ' +  'SignedHeaders=' + signed_headers + ', ' + 'Signature=' + signature
        
        # ********************************************
        # Create Header
        # Python note: The 'host' header is added automatically by the Python 'requests' library.
        headers = {
            'content-encoding': self.__config['content_encoding'],
            'content-type': self.__config['content_type'],
            'x-amz-date': amz_date,
            'x-amz-target': amz_target,
            'Authorization': authorization_header
        }
        
        # ************* SEND THE REQUEST *************
        r = requests.post(endpoint, data=json.dumps(request_parameters), headers=headers)
        return r
```

:::note warn
上記は Apache License 2.0 [http://aws.amazon.com/apache2.0/](http://aws.amazon.com/apache2.0/) のコードが含まれています。
:::

## リクエストボディのサンプル
ここは特に難しいところはないですね。 `Resources` でどういった情報が欲しいのかを指定できます。わかりやすくするため、`PartnerTag` と `MarketPlace` は省略せずに明記していますが、上記のサンプルコードでは `AmzAPi5` クラス内で処理させています。

:::note info
Resources に指定できるパラメータ
[https://webservices.amazon.co.jp/paapi5/documentation/search-items.html#resources-parameter](https://webservices.amazon.co.jp/paapi5/documentation/search-items.html#resources-parameter)
:::

```json
'Keywords': 検索したいキーワード,
'PartnerTag': パートナータグ,
'Marketplace': 'www.amazon.co.jp'
'PartnerType': 'Associates',
'Operation': 'SearchItems',
'Resources': [
		'BrowseNodeInfo.BrowseNodes',
		'BrowseNodeInfo.BrowseNodes.Ancestor',
		'BrowseNodeInfo.BrowseNodes.SalesRank',
		'BrowseNodeInfo.WebsiteSalesRank',
		..... (省略)
]
```

*※このリクエストのボディも署名の素材に使われます。*

## リクエストヘッダのサンプル
POSTリクエストでは次のようなヘッダを送信します。 ここには署名が含まれます。

```console
POST / HTTP/1.1
host: webservices.amazon.co.jp
content-type: application/json; charset=utf-8
content-encoding: amz-1.0
x-amz-date: 20160925T120000Z
x-amz-target: com.amazon.paapi5.v1.ProductAdvertisingAPIv1.SearchItems
Authorization: AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20160925/us-west-2/ProductAdvertisingAPI/aws4_request, SignedHeaders=content-type;host;x-amz-date;x-amz-target, Signature=&5d672d79c15b13162d9279b0855cfba6789a8edb4c82c400e06b5924a6f2b5d7;
```

`x-amz-date` は署名生成時の日時。`x-amz-target` はリクエストするサービス (今回はPA-APIの検索) を指定しています。書式は省略しますが、とりあえずこの値を指定すると思ってください。`Authorization` が認証情報であり署名です。正規ユーザかを Amazon が確認するためのものです。

### SHA-256 署名を作るにあたって

署名はアクセスキーなどを含む単一の文字列で、前項の通り、リクエストヘッダにキー `Authorization` で含めます。構成は次のとおり。


```
Authorization: AWS4-HMAC-SHA256 Credential=[アクセスキー]/[YYYYMMDD]/[リージョン]/[サービス名]/aws4_request, SignedHeaders=[署名に含まれる内容], Signature=[署名本体。AWS署名バージョン4];
````

`AWS4-HMAC-SHA256 Credential=[アクセスキー]` までは簡単ですね。この署名のアルゴリズムが **AWS4-HMAC-SHA256** であることを示し、API のアクセスキー（平文）を伝えています。その後は `[署名の年月日]`/`[リージョン]`/`[サービス名]`/`aws4_request` が続きます。

次に、`SignedHeaders=content-type;host;x-amz-date;x-amz-target`。これは後ろに続く署名本体 `Signature` に含まれるヘッダの内容を示しています（セミコロンで区切る）。`SignedHeaders`の記述の順番には注意してください。（署名の本体は `Signature` ですが、 ハッシュ化に用いるヘッダ情報の文字列と順番が異なると、認証が通りません）

PA-API のサンプルコードによると、  ``content-type`` `host` `x-amz-date` `x-amz-target` を署名に含めることを要求しています。（試したところ、content-type はなくてもエラーは出ませんでしたが）

#### ハッシュ化の前後

具体的な作り方はコードを見てもらうとして、ハッシュ化の前後でどのようになるかを見てみます。
まず次のようなヘッダ情報の文字列を用意します。これがハッシュ化する前です。

```
content-type:'application/json; charset=utf-8' \n
host:'webservices.amazon.co.jp' \n
x-amz-date:'20160925T120000Z' \n
x-amz-target:'com.amazon.paapi5.v1.ProductAdvertisingAPIv1.SearchItems' \n
```

上記を、諸々の処理を経て、下記のような感じのランダムな文字列（ハッシュ値）にします。
これが署名の本体となります。

```
&5d672d79c15b13162d9279b0855cfba6789a8edb4c82c400e06b5924a6f2b5d7
```

ヘッダ情報の文字列に、さらにシークレットキー・リクエストボディ・メソッド形式・リクエストするホスト・パス・リージョン・日時・利用するサービス名・ヘッダ情報の構成などを加えて、SHA-256  (256ビット) のハッシュにしています。

:::note info
ハッシュ化とは不可逆な変換で、暗号化（可逆的な変換）よりセキュリティ的に強固とされています。リクエストを受ける Amazon 側 (AWS) でも同様にハッシュ値の生成を行い、ユーザが送ったハッシュ値と一致するかどうかで認証しています。ハッシュは、ユーザと Amazon しか知らないシークレットキーを用いて作られるため、正しいハッシュは両者しか知りえません。
:::

[AWS の公式ドキュメント](https://docs.aws.amazon.com/ja_jp/general/latest/gr/sigv4_signing.html)には、手順について更に詳細な解説があります。興味がある方は覗いてみてください。

ハッシュ値（署名）ができたら、`Authorization: AWS4-HMAC-SHA256 Credential=[アクセスキー]/[YYYYMMDD]/[リージョン]/[サービス名]/aws4_request, SignedHeaders=[署名に含まれるヘッダの内容], Signature=[署名本体。AWS署名バージョン4];`  の `Signature` に当てはめます。あとはリクエストヘッダに含めて送信するだけです。

## あとがき
Python に慣らしつつ書いていたので、Pythoner (パイソナー) 的にはアレな書き方があるかもしれません。 getter / setter は不要論や `@property` にすべきというのも把握していたのですが、規模の大きなコードでもないし自分にとっての見通しやすさを選びました。

次の記事もよかったら御覧ください。

https://qiita.com/lspd/items/b90f3a7da813e276d3c2