風柳メモ

ソフトウェア・プログラミング関連の覚書が中心

AmazonのProduct Advertising API認証プロキシ(REST版・GAE用)ソース

公開中の認証プロキシエンドポイントAPI

RESTを使用しているクライアントアプリケーションの場合、従来のAmazonアソシエイトWebサービスAPI(REST)で使用していた、

http://webservices.amazon.co.jp/onca/xml
http://ecs.amazonaws.jp/onca/xml
http://xml-jp.amznxslt.com/onca/xml

といったエンドポイントを、

http://honnomemo.appspot.com/paproxy
http://honnomemo.appspot.com/rpaproxy/jp/

に置き換えることで(クエリはそのまま)Product Advertising APIの認証処理を意識せずとも従来と同等に動作する……はず。


ご自分のGoogle App Engine上で動作させたい場合、以下のソースを参照のこと。


2009/07/09現在の最新の環境は→こちら(ZIP圧縮)

Amazon API認証プロキシ用のリバース・プロキシ作ってみた - 風柳メモ

のソースも入ってます。
そろそろGitとか使いはじめるべきかなぁ……。


ソースコード

app.yaml(以下の設定を追加)
- url: /paproxy.*
  script: paproxy.py
paproxy.py(プロキシ本体)
# -*- coding: utf-8 -*-
"""
paproxy.py for Google App Engine
  ■概要
    Product Advertising API(旧Amazon アソシエイト Web サービス)用認証プロキシ
  
  ■使用方法
    http://(設置ドメイン)/paproxy[/(locale)/]?(Product Advertising APIに渡す各種パラメータ)
"""

import datetime,urllib,hmac,hashlib,base64
import yaml,logging
import wsgiref.handlers
import re,random

from google.appengine.ext import webapp
from google.appengine.api import urlfetch
from google.appengine.ext import db

from urlparse import urlparse

# === リダイレクト設定
USE_REDIRECT=True  # True:Signature付きURLへリダイレクト  False:Signature付きURLの内容を取得して返す


# === アクセス制限
ACCESS_PERIOD_SECONDS=60               # 集計期間(秒)
#ACCESS_LIMIT=10*ACCESS_PERIOD_SECONDS  # 集計期間中のアクセス数上限 (0:制限なし)
ACCESS_LIMIT=0                         # 集計期間中のアクセス数上限 (0:制限なし)


# === 設定ファイル(paproxy.yaml)読込み
BASEPOINT=u'/paproxy'

try:
  paproxy_conf=yaml.load(open('paproxy.yaml').read().decode('utf-8'))
except:
  paproxy_conf={}

for key in paproxy_conf.keys():
  paproxy_conf[key]=unicode(paproxy_conf[key])

AWSAccessKeyId=paproxy_conf.get('AWSAccessKeyId',u'99999999999999999999')
SecretAccessKey=paproxy_conf.get('SecretAccessKey',u'1234567890123456789012345678901234567890')

DefaultValues = {
  'AssociateTag' : paproxy_conf.get('DefaultAssociateTag','furyutei-22'),
  'Service'      : 'AWSECommerceService',
  'Version'      : '2009-01-06',
}

IgnoreKeys = ['SubscriptionId','AWSAccessKeyId','Signature','Timestamp',]

RequestEndPoints = {
  'ca': 'ecs.amazonaws.ca',
  'de': 'ecs.amazonaws.de',
  'fr': 'ecs.amazonaws.fr',
  'jp': 'ecs.amazonaws.jp',
  'uk': 'ecs.amazonaws.co.uk',
  'us': 'ecs.amazonaws.com',
}

XsltEndPoints = {
  'ca': 'xml-ca.amznxslt.com',
  'de': 'xml-de.amznxslt.com',
  'fr': 'xml-fr.amznxslt.com',
  'jp': 'xml-jp.amznxslt.com',
  'uk': 'xml-uk.amznxslt.com',
  'us': 'xml-us.amznxslt.com',
}

RequestMethod = 'GET'
DefaultLocale = paproxy_conf.get('DefaultLocale','jp')
RequestEndPoint = paproxy_conf.get('RequestEndPoint',RequestEndPoints.get(DefaultLocale,RequestEndPoints['jp']))
XsltEndPoint = paproxy_conf.get('XsltEndPoint',XsltEndPoints.get(DefaultLocale,XsltEndPoints['jp']))
RequestPath = '/onca/xml'

DB_FETCH_LIMIT=1000


#{ // dbPapAccessInfo()
class dbPapAccessInfo(db.Model):
  date=db.DateTimeProperty(auto_now_add=True)
#} // end of dbPapAccessInfo()


#{ // checkBusy()
def checkBusy():
  if ACCESS_LIMIT==0: return False
  
  threshold=datetime.datetime.utcnow()-datetime.timedelta(seconds=ACCESS_PERIOD_SECONDS)
  
  try:
    while True:
      qdel=db.GqlQuery('SELECT * FROM dbPapAccessInfo WHERE date < :1',threshold)
      if qdel.count()<1: break
      vdels=qdel.fetch(DB_FETCH_LIMIT)
      if 0<len(vdels): db.delete(vdels)
  except:
    pass
  
  qinfo=db.GqlQuery('SELECT * FROM dbPapAccessInfo WHERE date >= :1',threshold)
  
  if ACCESS_LIMIT<1+qinfo.count():
    return True
  
  accessInfo=dbPapAccessInfo()
  for ci in range(3):
    try:
      db.put(accessInfo)
      break
    except:
      pass
  
  return False
#} // end of checkBusy()


#{ // paproxy()
class paproxy(webapp.RequestHandler):
  def get(self):
    (req,rsp) = (self.request,self.response)
    
    if checkBusy():
      rsp.set_status(503)
      status='503: Service Temporarily Unavailable'
      rsp.out.write('<html><head><title>%s - paproxy</title></head><body><h1>%s</h1><p>Please try again later.</p></body>' % (status,status))
      return
    
    use_redirect=USE_REDIRECT
    if not use_redirect:
      user_agent=req.headers.get('User-Agent',u'')
      if re.search(u'rpaproxy',user_agent,re.I):
        use_redirect=True # User-Agentがrpaproxyだったら強制的にリダイレクト使用
        logging.debug(u'Use Redirect (User-Agent:%s)' % (user_agent))
    
    args = {}
    for key in req.arguments():
      if key in IgnoreKeys:
        continue
      args[key] = req.get(key)
    
    for key in DefaultValues.keys():
      if not args.get(key):
        args[key] = DefaultValues[key]
    
    args['AWSAccessKeyId'] = AWSAccessKeyId
    args['Timestamp'] = datetime.datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ") # datetime.datetime.utcnow().isoformat()
    
    params = []
    for key in sorted(args.keys()):
      params.append('%s=%s' % (urllib.quote(key.encode('utf-8'),safe='~'),urllib.quote(args[key].encode('utf-8'),safe='~'))) # '~'はquoteしない、'/'はする
      # ・Product Advertising APIの仕様上、RFC3986の非予約文字("A-Z", "a-z", "0-9", "-", "_", ".", "~")はエンコードしてはいけない
      # ・urllib.quoteのデフォルトでは、"A-Z", "a-z", "0-9", "-", "_", ".", "/"をエンコードしない (デフォルトオプション:safe="/")
      # →[差分]エンコードする:"/"、エンコードしない:"~"
    
    param_str = '&'.join(params)
    
    locale='default'
    mrslt=re.search(BASEPOINT+u'/([a-z]{2})/',req.uri)
    if mrslt:
      locale=mrslt.group(1).lower()
    
    if args.get('Style'):
      _endpoint = XsltEndPoints.get(locale,XsltEndPoint)
    else:
      _endpoint = RequestEndPoints.get(locale,RequestEndPoint)
    
    prefixes ='\n'.join([RequestMethod,_endpoint,RequestPath]) # (HTTPVerb,ValueOfHostHeaderInLowercase,HTTPRequestURI)
    apiurl = 'http://%s%s' % (_endpoint,RequestPath)
    
    signature = base64.b64encode(hmac.new(SecretAccessKey, '\n'.join([prefixes,param_str]), hashlib.sha256).digest())

    url = u'%s?%s&Signature=%s' % (apiurl,param_str,urllib.quote(signature,safe='~'))
    logging.debug(url)
    
    if use_redirect:
      self.redirect(url)
    else:
      result = urlfetch.fetch(url=url,method=urlfetch.GET,allow_truncated=True,follow_redirects=True,deadline=10)
      statcode = result.status_code
      
      rsp.set_status(statcode)
      
      ctype = result.headers.get('Content-Type')
      if ctype:
        rsp.headers['Content-Type'] = ctype
      else:
        #rsp.headers['Content-Type'] = 'text/xml; charset=utf-8'
        rsp.headers['Content-Type'] = 'text/plain'
      
      try:
        rsp.out.write(result.content)
      except:
        pass
    
#} // end of paproxy()


def main():
  application = webapp.WSGIApplication([
    (BASEPOINT+u'.*', paproxy),
  ],debug=True)
  wsgiref.handlers.CGIHandler().run(application)

if __name__ == "__main__":
  main()
paproxy.yaml(設定ファイル・'paproxy.py'と同一ディレクトリに設置)

※AWSAccessKeyId、SecretAccessKeyを自分のものに変更すること。

#==============================================================================
# Mandatory Parameters
#==============================================================================
# === Your Access Key ID
AWSAccessKeyId: 99999999999999999999

# === Your Secret Access Key
SecretAccessKey: 1234567890123456789012345678901234567890


#==============================================================================
# Optional Parameters
#==============================================================================
DefaultAssociateTag: furyutei-22
DefaultLocale: jp

覚書とか

  1. Product Advertising APIの仕様("/"はエンコード要、"~"は不要)にあわせると、urllib.quoteの引数にsafe='~'が必要っぽい。
  2. XSLTを使用する場合(Styleオプション指定時)、http://webservices.amazon.co.jp/onca/xmlhttp://ecs.amazonaws.jp/onca/xmlで指定すると認証エラーに。専用のエンドポイント(http://xml-jp.amznxslt.com/onca/xml)の指定が必要らしい。*1

*1:Styleオプション無しのときはどれでもOKだったので、http://xml-jp.amznxslt.com/onca/xmlに統一してしまっても良さそうだが、一応わけてある。