Python + Zeep + SOAP UI + 自作WSDLで、SOAPのリクエストとレスポンスを試してみた

前回、Python + Zeepで、SOAP APIクライアントを作成しました。
Python + Zeep で SOAP API クライアントを作ってみた - メモ的な思考的な

 
そんな中、自分でもWSDLを書いてみたくなりました。

ただ、Zeepを使ってSOAP通信するには、WSDLの他にSOAPサーバが必要です。

何かいいものがないか考えたところ、同僚より SOAP UI を教わりました。SOAP UIにWSDLを食わせるとといろいろと自動生成してくれるとのことです。
The World's Most Popular Automated API Testing Tool | SoapUI

 
そのため、今回は、Python + Zeep + SOAP UI + 自作のWSDLで、SOAPのリクエストとレスポンスを試してみました。

 
目次

 

環境

 
SOAPWSDLとも他のバージョンがありましたが、Web上に資料の多い上記のバージョンとしました。

なお、Zeep・SOUP UI とも、上記のバージョンでも動作します。

 

リクエストパラメータなし・レスポンスデータありのSOAP通信

WSDLの作成

WSDLを書いたことがないため、以下の記事を参考に、ステップを追って書いてみることにしました。
WSDL:Webサービスのインターフェイス情報:Webサービスのキホン(4) - @IT

 
手始めに、まずはパラメータなしのリクエストを送ると Hello, world! が返ってくるWSDLを書くことにしました。

以降、ざっくりとしたメモ書きです。

 

wsdl:definitions要素

関係する要素のうち

  • xmlns:wsdl
  • xmlns:soap11
  • xmlns:http
  • xmlns:mime
  • xmlns:xsd

は定型のため、参考記事をそのまま書きました。

他に

  • xmlns:ns0
    • WSDL内で定義を参照するのに使う
  • targetNamespace

を追加しました。

なお、上記2つは適当な値で良さそうだったため、 http://example.com/HelloWorld をセットしました。

<wsdl:definitions
        xmlns:wsdl="http://schemas.xmlsoap.org/wsdl/"
        xmlns:soap11="http://schemas.xmlsoap.org/wsdl/soap/"
        xmlns:http="http://schemas.xmlsoap.org/wsdl/http/"
        xmlns:mime="http://schemas.xmlsoap.org/wsdl/mime/"
        xmlns:xsd="http://www.w3.org/2001/XMLSchema"
        xmlns:ns0="http://example.com/HelloWorld"
        targetNamespace="http://example.com/HelloWorld">

 

wsdl:types要素

基本のデータ型は xmlns:xsd に定義されています。

しかし、実際には、基本データ型を組み合わせることが多そうなので、今回は wsdl:types 要素を定義しました。

ResponseInterface型を用意し、その中にはstring型の returnMessage フィールドを用意しました。

なお、wsdl:types要素でも targetNamespace が必要になるため、wsdl:definitions要素と同じ値を設定しました。

<wsdl:types>
    <xsd:schema elementFormDefault="qualified" targetNamespace="http://example.com/HelloWorld">
        <xsd:element name="ResponseInterface">
            <xsd:complexType>
                <xsd:sequence>
                    <xsd:element minOccurs="0" name="returnMessage" type="xsd:string" />
                </xsd:sequence>
            </xsd:complexType>
        </xsd:element>
    </xsd:schema>
</wsdl:types>

 

wsdl:message 要素

ここではリクエスト・レスポンスに使われる型を定義します。

今回はレスポンスデータだけがあります。

そこで、messageOutと名付けたmessageに対し、element属性にて先ほど定義したwsdl:types要素を名前空間付きで ns0:ResponseInterface と指定しました。
(以降も、同じファイル内を参照する場合は、名前空間 ns0: を先頭に付けます)

なお、nameについては、慣例的に使われていそうな parameters としました。Zeepのソースコード上には表れない名前なので、何でも良さそうです。

<wsdl:message name="messageIn">
</wsdl:message>
<wsdl:message name="messageOut">
    <wsdl:part name="parameters" element="ns0:ResponseInterface" />
</wsdl:message>

 

wsdl:portType要素

ここではリクエスト時に使われるメソッド名と、その時のリクエスト・レスポンス型を指定するようです。

今回はリクエスト時のメソッド名を requestMessage としました。

message属性については、wsdl:message 要素を参照します。

<wsdl:portType name="HelloPort">
    <wsdl:operation name="requestMessage">
        <wsdl:input message="ns0:messageIn"/>
        <wsdl:output message="ns0:messageOut"/>
    </wsdl:operation>
</wsdl:portType>

 

wsdl:binding要素

今までの定義を取りまとめる要素のようです。今回は以下の内容で設定しました。

  • wsdl:bindingのtype属性にて、 wsdl:port 要素を参照
  • soap11:binding は定型
    • soapAction は適当で良さそう
    • styleは rpcdocument のどちらかだが、今回は wsdl:types 要素で定義した型を使ってリクエスト・レスポンスするため、 document を設定
      • 基本型だけであれば rpc なのかな...
  • wsdl:operation には、 wsdl:portTypeで定義したメソッド名 requestMessage を指定
    • 別の値にすると、リクエストがうまくいかない
  • wsdl:inputwsdl:output については、定型
<wsdl:binding name="HelloBindingSoap11" type="ns0:HelloPort">
    <soap11:binding transport="http://schemas.xmlsoap.org/soap/http" style="document"/>
    <wsdl:operation name="requestMessage">
        <soap11:operation soapAction="http://example.com/HelloWorld/requestMessage" />
        <wsdl:input>
            <soap11:body use="literal"/>
        </wsdl:input>
        <wsdl:output>
            <soap11:body use="literal"/>
        </wsdl:output>
    </wsdl:operation>
</wsdl:binding>

 

wsdl:service要素

実際にアクセスする時の情報をセットします。

  • binding 属性には、 wsdl:binding のname属性をセット
  • location 属性には、実際にアクセスするURL (今回の場合は、SOAP UIのモックURL) をセット
<wsdl:service name="HelloService">
    <wsdl:port name="HelloServicePort" binding="ns0:HelloBindingSoap11">
        <soap11:address location="http://localhost:8088/mockHelloBindingSoap11"/>
    </wsdl:port>
</wsdl:service>

 

WSDL全体

以上でWSDLが書けたため、全体を載せておきます。

<?xml version="1.0" encoding="UTF-8"?>
<!--
 ns0 は、WSDL内で定義を参照するのに使われる
 targetNamespaceは、とりあえず適当に設定しておく
 -->
<wsdl:definitions
        xmlns:wsdl="http://schemas.xmlsoap.org/wsdl/"
        xmlns:soap11="http://schemas.xmlsoap.org/wsdl/soap/"
        xmlns:http="http://schemas.xmlsoap.org/wsdl/http/"
        xmlns:mime="http://schemas.xmlsoap.org/wsdl/mime/"
        xmlns:xsd="http://www.w3.org/2001/XMLSchema"
        xmlns:ns0="http://example.com/HelloWorld"
        targetNamespace="http://example.com/HelloWorld">

    <wsdl:types>
        <!-- ここのtargetNamespaceも適当に設定(先ほどのと同じでもOK) -->
        <xsd:schema elementFormDefault="qualified" targetNamespace="http://example.com/HelloWorld">
            <xsd:element name="ResponseInterface">
                <xsd:complexType>
                    <xsd:sequence>
                        <xsd:element minOccurs="0" name="returnMessage" type="xsd:string" />
                    </xsd:sequence>
                </xsd:complexType>
            </xsd:element>
        </xsd:schema>
    </wsdl:types>

    <wsdl:message name="messageIn">
    </wsdl:message>
    <wsdl:message name="messageOut">
        <!--
        独自定義の型を使用するため、element属性にて上記のelementのname属性をセット
        name属性の値は、慣例的に parameters っぽい(他の名称にしても動作する)
        -->
        <wsdl:part name="parameters" element="ns0:ResponseInterface" />
    </wsdl:message>

    <wsdl:portType name="HelloPort">
        <wsdl:operation name="requestMessage">
            <!-- リクエスト(input)とレスポンス(output)の型を特定するため、上記messageのname属性をセット -->
            <wsdl:input message="ns0:messageIn"/>
            <wsdl:output message="ns0:messageOut"/>
        </wsdl:operation>
    </wsdl:portType>

    <!-- 上記のportTypeを使うため、type属性にはportTypeのname属性をセット -->
    <wsdl:binding name="HelloBindingSoap11" type="ns0:HelloPort">
        <!-- 独自の型定義を使っているため、styleには document をセット -->
        <soap11:binding transport="http://schemas.xmlsoap.org/soap/http" style="document"/>
        <!-- portTypeの中にあるoperationのnameと同じ値をセット(今回の場合、requestMessage) -->
        <wsdl:operation name="requestMessage">
            <!-- soapAction は適当な値で良さそう -->
            <soap11:operation soapAction="http://example.com/HelloWorld/requestMessage" />
            <wsdl:input>
                <soap11:body use="literal"/>
            </wsdl:input>
            <wsdl:output>
                <soap11:body use="literal"/>
            </wsdl:output>
        </wsdl:operation>
    </wsdl:binding>

    <wsdl:service name="HelloService">
        <!-- binding属性には、上記bindingのname属性をセット -->
        <wsdl:port name="HelloServicePort" binding="ns0:HelloBindingSoap11">
            <!-- 実際にアクセスするURL(今回はSOAP UI のモックURL)をセット -->
            <soap11:address location="http://localhost:8088/mockHelloBindingSoap11"/>
        </wsdl:port>
    </wsdl:service>
</wsdl:definitions>

 

SOAP UI によるモックの作成

モックの作り方については以下が参考になりました。ありがとうございます。
SOAP UIのMock Serviceを使った効率的なWebサービスのテスト - そごうソフトウェア研究所

 
現在の SOAP UIでは、プロジェクト作成時にはモックを作れないようですが、後でコンテキストメニューから追加しました。

 
モックとして返す内容は、以下の通り Hello, world! としました。

<soapenv:Envelope
   xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/"
   xmlns:hel="http://example.com/HelloWorld">
   <soapenv:Header/>
   <soapenv:Body>
      <hel:ResponseInterface>
         <!--Optional:-->
         <hel:returnMessage>Hello, world!</hel:returnMessage>
      </hel:ResponseInterface>
   </soapenv:Body>
</soapenv:Envelope>

 

Zeepによる実装

前回試した通り、Zeepで実装します。なお、 get_type() は使わない方の実装としました。

import pathlib

from zeep import Client

WSDL = pathlib.Path.cwd().joinpath('Hello.wsdl')


client = Client(str(WSDL))
response = client.service.requestMessage()

print(type(response))
print(response)

 

動作確認

Pythonスクリプトを実行すると、レスポンスがありました。

$ python run_hello.py
<class 'str'>
Hello, world!

 
SOAP UIの方にも、左側にリクエストデータが記録されていました。

f:id:thinkAmi:20181104174053p:plain:w350

全体が見えないため、内容を貼っておきます。

<?xml version='1.0' encoding='utf-8'?>
<soap-env:Envelope 
    xmlns:soap-env="http://schemas.xmlsoap.org/soap/envelope/">
  <soap-env:Body/>
</soap-env:Envelope>

 

リクエストパラメータあり・レスポンスデータありのSOAP通信

続いて、リクエストパラメータがあるバージョンも作ってみます。

ユーザ名を送信すると Hey, <ユーザ名> と返すものを作ってみます。

 

WSDLの作成

変更があるのは、 wsdl:typeswsdl:message です。

<wsdl:types>
    <xsd:schema elementFormDefault="qualified" targetNamespace="http://example.com/HelloWorld">
        <xsd:element name="RequestInterface">
            <xsd:complexType>
                <xsd:sequence>
                    <xsd:element minOccurs="0" name="userName" type="xsd:string" />
                </xsd:sequence>
            </xsd:complexType>
        </xsd:element>
        <xsd:element name="ResponseInterface">
        ...
</wsdl:types>

<wsdl:message name="messageIn">
    <wsdl:part name="parameters" element="ns0:RequestInterface" />
</wsdl:message>
<wsdl:message name="messageOut">
...
</wsdl:message>

あとは同様です。

 

SOAP UIのモックを修正

修正したWSDLを元にモックを作成します。

今回はリクエストパラメータを取得して返すので、

を行います。

方法については以下が参考になりました。

 
まずは、レスポンスのXMLHello, ${userName} を追加します。

<soapenv:Envelope
   xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/"
   xmlns:hel="http://example.com/HelloWorld">
   <soapenv:Header/>
   <soapenv:Body>
      <hel:ResponseInterface>
         <!--Optional:-->
         <hel:returnMessage>Hello, ${userName}</hel:returnMessage>
      </hel:ResponseInterface>
   </soapenv:Body>
</soapenv:Envelope>

 
また、Scriptにも以下のGroovyコードを追加します。

def holder = new com.eviware.soapui.support.XmlHolder( mockRequest.requestContent )
def userName = holder.getNodeValue("//*:userName")
requestContext.userName = userName

 

Zeepの実装

引数に userName を追加するだけです。今回は taro をリクエスト値として追加します。

import pathlib

from zeep import Client

WSDL = pathlib.Path.cwd().joinpath('RequestResponse.wsdl')


client = Client(str(WSDL))
response = client.service.requestMessage(userName='taro')

print(type(response))
print(response)

 

実行結果

リクエストとレスポンスが成功しました。

$ python run_request_response.py
<class 'str'>
Hello, taro

 
SOAP UI にもログが記録されていました。

<?xml version='1.0' encoding='utf-8'?>
<soap-env:Envelope xmlns:soap-env="http://schemas.xmlsoap.org/soap/envelope/">
  <soap-env:Body>
    <ns0:RequestInterface xmlns:ns0="http://example.com/HelloWorld">
      <ns0:userName>taro</ns0:userName>
    </ns0:RequestInterface>
  </soap-env:Body>
</soap-env:Envelope>

 

参考資料

 

ソースコード

GitHubに上げました。 wsdls_and_client ディレクトリ以下が今回のものです。
https://github.com/thinkAmi-sandbox/python_zeep-sample

Python + Zeep で SOAP API クライアントを作ってみた

Python + Zeep で SOAP API クライアントを作ってみたため、その時のメモを残します。

 

目次

 

環境

 
なお、WSDLがあるSOAP APIを使うことを想定しています。

WSDLのバージョンなどはWikipediaが参考になりました。
Web Services Description Language - Wikipedia

 

Zeepとは

公式ドキュメントによると

A fast and modern Python SOAP client Highlights:

  • Compatible with Python 2.7, 3.3, 3.4, 3.5, 3.6 and PyPy
  • Build on top of lxml and requests
  • Support for Soap 1.1, Soap 1.2 and HTTP bindings
  • Support for WS-Addressing headers
  • Support for WSSE (UserNameToken / x.509 signing)
  • Support for tornado async transport via gen.coroutine (Python 2.7+)
  • Support for asyncio via aiohttp (Python 3.5+)
  • Experimental support for XOP messages

https://python-zeep.readthedocs.io/en/master/

とのことで、いい感じのSOAP API クライアントが作れそうでした。

 

使えそうな SOAP APIを探す

SOAP API サーバを自作することも考えたのですが、ゼロから作るには先が長くなりそうでした。

 
そのため、使えそうな SOAP Web API を探してみました。

有名なところではFlickrAPIがありました。
https://www.flickr.com/services/api/

ただ、WSDLがなかったため、今回は見送りました。

 
他のAPIを探したところ、以下がありました。

 
今回はWSDLを使ってリクエスト/レスポンスができればよいため、デ辞蔵Webサービス - SOAPAPI を使います。

 

mzeepオプションにて、WSDLを解析する

WSDLXMLで書かれていますが、開発するために読み解くのは手間がかかります。

Zeepでは、 python -mzeep <WSDLのパス> で、WSDLファイルを解析できます。

今回のWSDLファイルは上記サイトに記載されていたため、ダウンロードして実行します。

$ python -mzeep SoapServiceV11.xml > wsdl.txt

 
wsdl.txtを開くと、

Prefixes:
     xsd: http://www.w3.org/2001/XMLSchema
...

Global elements:
     ns0:ArrayOfDicInfo(ns0:ArrayOfDicInfo)
...

Global types:
     xsd:anyType
     ns0:ArrayOfDicInfo(DicInfo: ns0:DicInfo[])
...
Bindings:
     Soap11Binding: {http://MyDictionary.jp/SOAPServiceV11}SOAPServiceV11Soap
...

Service: SOAPServiceV11
     Port: SOAPServiceV11Soap (Soap11Binding: {http://MyDictionary.jp/SOAPServiceV11}SOAPServiceV11Soap)
         Operations:
            GetDicItem(AuthTicket: xsd:string, DicID: ns1:guid, ItemID: xsd:string, LocID: xsd:string, ContentProfile: ns0:ContentProfile, QueryListForHighLight: ns0:ArrayOfQuery) -> GetDicItemResult: ns0:DicItem
...

と、各種情報が分かりやすくなりました。

 

型情報を見やすくする

mzeepオプションでだいぶ分かりやすくなりました。

ただ、型情報が1行で表示されるため、複数の項目を持つ型の場合、見づらいことに気づきました。

そのため、

import pathlib


read_file = pathlib.Path('./wsdl.txt')
with read_file.open(mode='r') as r:
    f = r.read()

formatted = f.split(',')

write_file = pathlib.Path('./formatted.txt')
with write_file.open(mode='w') as w:
    for f in formatted:
        w.write(f'{f.strip()}\n')

のようなスクリプトを作成し、フォーマットしてみました。

実行前

ns0:DicInfo(DicID: ns1:guid, FullName: xsd:string, ShortName: xsd:string, Publisher: xsd:string, Abbrev: xsd:string, StartItemID: xsd:string, ScopeList: ns0:ArrayOfScope, SearchOptionList: ns0:ArrayOfSearchOption, DefSearchOptionIndex: xsd:int, ItemMapList: ns0:ArrayOfString)

実行後

ns0:DicInfo(DicID: ns1:guid
FullName: xsd:string
ShortName: xsd:string
Publisher: xsd:string
Abbrev: xsd:string
StartItemID: xsd:string
ScopeList: ns0:ArrayOfScope
SearchOptionList: ns0:ArrayOfSearchOption
DefSearchOptionIndex: xsd:int
ItemMapList: ns0:ArrayOfString)

 
いろいろと手を加えたいところですが、今はこのくらいで十分なので、良しとします。

 

ZeepでAPIクライアントを作ってみる

辞書検索サービスの使い方にあるように、まずは

GetDicListで呼び出し可能な辞書の一覧を取得

をZeepを使って作ってみます。

 

clientの生成

公式ドキュメントの「A simple use-case」に従い、今回のSOAPクライアントを生成してみます。
https://python-zeep.readthedocs.io/en/master/#a-simple-use-case

import pathlib
from zeep import Client

WSDL = pathlib.Path.cwd().joinpath('SoapServiceV11.xml')

client = Client(str(WSDL))

 

SOAP APIのメソッドを呼び出す (client.get_type()を使う)

クライアントができたため、次は SOAP API のメソッドを呼び出してみます。

今回使う GetDicList メソッドでは、引数 AuthTicket を持つことが分かっています。

そのため、引数を渡すような実装方法を見たところ、公式ドキュメントの「Creating objects - Datastructures」に記載がありました。
https://python-zeep.readthedocs.io/en/master/datastructures.html#creating-objects

 
引数 AuthTicket の型は xsd:string ですので、 client.get_type('xsd:string') で型用のオブジェクトを生成しておきます。

xsd_string = client.get_type('xsd:string')

 
あとは、Zeepが client.serviceSOAP APIのメソッドをいい感じに生成・実行してくれます。

response = client.service.GetDicList(AuthTicket=xsd_string(''))
print(response)

 
ここまでのコード全体は以下です。

import pathlib
from zeep import Client

WSDL = pathlib.Path.cwd().joinpath('SoapServiceV11.xml')

client = Client(str(WSDL))
xsd_string = client.get_type('xsd:string')

response = client.service.GetDicList(AuthTicket=xsd_string(''))
print(response)

 
実行結果です。いい感じの結果が返ってきました。

$ python run_GetDicList.py 
[{
    'DicID': 'xxxx',
    'FullName': 'Edict和英辞典',
    'ShortName': 'Edict和英辞典',
...

 

SOAP APIのメソッドを呼び出す (client.get_type()を使わない)

上記で実装ができていましたが、型オブジェクトを生成するために

xsd_string = client.get_type('xsd:string')

としているのが手間でした。

 
よく見ると、公式ドキュメントの「Creating objects - Datastructures」には続きがあり、

However instead of creating an object from a type defined in the XSD you can also pass in a dictionary. Zeep will automatically convert this dict to the required object (and nested child objects) during the call.

とのことでした。

 
そのため、先ほどのコードは

client = Client(str(WSDL))

response = client.service.GetDicList(AuthTicket='')
print(response)

でもOKです。

 
実行結果も同じでした。

$ python run_GetDicList.py 
[{
    'DicID': 'xxxx',
    'FullName': 'Edict和英辞典',
    'ShortName': 'Edict和英辞典',
...

 

引数の型が複雑な SOAP API を呼び出す (get_type()を使う)

先ほどの GetDicList APIは、型が xsd:string と単純なものでした。

引数の型が複雑なAPIを探したところ、 SearchDicItem がありました。

 
そこで、まずは get_type() を使う書き方で実装してみます。

import pathlib
from zeep import Client

WSDL = pathlib.Path.cwd().joinpath('SoapServiceV11.xml')


def get_guid_list_from_api():
    client = Client(str(WSDL))

    response = client.service.GetDicList(AuthTicket='')
    return [response[0]['DicID'], response[1]['DicID']]


def call_api_with_get_type():
    def create_query(word):
        ns0_merge_option = client.get_type('ns0:MergeOption')
        ns0_match_option = client.get_type('ns0:MatchOption')

        query = client.get_type('ns0:Query')(
            Words=xsd_string(word),
            ScopeID=xsd_string('HEADWORD'),
            MatchOption=ns0_match_option('EXACT'),
            MergeOption=ns0_merge_option('OR')
        )
        return query

    client = Client(str(WSDL))
    xsd_string = client.get_type('xsd:string')
    xsd_unsigned_int = client.get_type('xsd:unsignedInt')
    ns1_guid = client.get_type('ns1:guid')

    guid_list = get_guid_list_from_api()
    guids = client.get_type('ns0:ArrayOfGuid')([
        ns1_guid(guid_list[0]),
        ns1_guid(guid_list[1]),
    ])
    queries = client.get_type('ns0:ArrayOfQuery')([
        create_query('apple'),
        create_query('america'),
    ])

    response = client.service.SearchDicItem(
        AuthTicket=xsd_string(''),
        DicIDList=guids,
        QueryList=queries,
        SortOrderID=xsd_string(''),
        ItemStartIndex=xsd_unsigned_int('0'),
        ItemCount=xsd_unsigned_int('2'),
        CompleteItemCount=xsd_unsigned_int('2'),
    )

    for r in response['ItemList']['DicItem']:
        print(r['Title']['_value_1'].text)
        print(dir(r['Title']['_value_1']))
        print('=' * 5)

 
ここで、ネスト & 配列を持つ引数 QueryList について取り上げてみます。

まず、配列の要素である ns0:Query 型のオブジェクトを作ります。

def create_query(word):
    ns0_merge_option = client.get_type('ns0:MergeOption')
    ns0_match_option = client.get_type('ns0:MatchOption')

    query = client.get_type('ns0:Query')(
        Words=xsd_string(word),
        ScopeID=xsd_string('HEADWORD'),
        MatchOption=ns0_match_option('EXACT'),
        MergeOption=ns0_merge_option('OR')
    )
    return query

 
次に、配列となる ns0:ArrayOfQuery を生成します。

queries = client.get_type('ns0:ArrayOfQuery')([
    create_query('apple'),
    create_query('america'),
])

 
最後に、SOAP APIの引数として渡します。

response = client.service.SearchDicItem(
    QueryList=queries,
    ...
)

 
実行結果は以下の通りです。

レスポンスが返ってきているので、WSDLでのやり取りは成功しているんだろうな程度にしておき、深入りはしないようにします。

$ python run_SearchDicItem.py 
apple
['__bool__', '__class__', '__contains__', ...]
=====
America
...

 

引数の型が複雑な SOAP API を呼び出す (get_type()を使わない)

上記の通り、 client.get_type() を使って実現できましたが、それぞれの型を get_type() で生成しておかなければならないため、いろいろと手間に感じました。

そこで、先ほどと同じように client.get_type() を使わないやり方で実装してみます。

# importや get_guid_list_from_api() は省略

def call_api_without_get_type():
    def create_query(word):
        return {
            'Words': word,
            'ScopeID': 'HEADWORD',
            'MatchOption': 'EXACT',
            'MergeOption': 'OR',
        }

    client = Client(str(WSDL))
    guids = {'guid': get_guid_list_from_api()}
    queries = {
        'Query': [
            create_query('apple'),
            create_query('america'),
        ]
    }

    response = client.service.SearchDicItem(
        AuthTicket='',
        DicIDList=guids,
        QueryList=queries,
        SortOrderID='',
        ItemStartIndex=0,
        ItemCount=2,
        CompleteItemCount=2,
    )

    for r in response['ItemList']['DicItem']:
        print(r['Title']['_value_1'].text)
        print(dir(r['Title']['_value_1']))
        print('=' * 5)

 
では、複雑な型を持つ引数 QueryList について、 get_type() を使う場合との差分を見ていきます。

def create_query(word):
    ns0_merge_option = client.get_type('ns0:MergeOption')
    ns0_match_option = client.get_type('ns0:MatchOption')

    query = client.get_type('ns0:Query')(
        Words=xsd_string(word),
        ScopeID=xsd_string('HEADWORD'),
        MatchOption=ns0_match_option('EXACT'),
        MergeOption=ns0_merge_option('OR')
    )
    return query

queries = client.get_type('ns0:ArrayOfQuery')([
    create_query('apple'),
    create_query('america'),
])

def create_query(word):
    return {
        'Words': word,
        'ScopeID': 'HEADWORD',
        'MatchOption': 'EXACT',
        'MergeOption': 'OR',
    }

queries = {
    'Query': [
        create_query('apple'),
        create_query('america'),
    ]
}

となりました。

ポイントは

  1. create_query() のような dict を用意 (これがArrayOfQueryの要素)
  2. keyに Query を、valueに 1. で作成した要素を持つdictを用意し、それを配列の要素とすることで、ArrayOfQuery になる

です。

get_type() を使うよりも、かなり簡潔になり、見やすくなりました。

 
以上より、Zeepによる SOAP APIのリクエスト/レスポンスができました。

 

ソースコード

GitHubに上げました。 dejizo_client ディレクトリ以下が今回のファイルです。
https://github.com/thinkAmi-sandbox/python_zeep-sample

なお、WSDLファイルをリポジトリに含めていません。含めてよいか分からなかったためです。

pytestのkオプションは、マーカー名でマッチしているものもテスト対象だった

pytestでテストを実行する時、今までは

  • -m オプションは、マーカー(@pytest.marker.xxx)に一致するもの
  • -k オプションは、テストクラス名やテストメソッド名でマッチするもの

を対象のテストコードとして実行するものと考えていました。

 
ただ、最近 -k オプションは、マーカー名でマッチするものもテスト対象になることを知ったため、挙動を調べてみました。

 
目次

 

環境

 

テストコード例

以下のテストコードに対し、 foo という文字列で -m オプションと -k オプションを実行してみます。

import pytest


@pytest.mark.foo
class TestHam:
    """クラスにマーカーあり"""
    def test_spam_method(self):
        assert True

    def test_egg_method(self):
        assert True


class TestHoge:
    @pytest.mark.foo
    def test_spam_method(self):
        """このテストメソッドだけマーカーあり"""
        assert True

    def test_egg_method(self):
        assert True


class TestHogefoo:
    """テストクラス名に foo が含まれる"""
    def test_spam_method(self):
        assert True

    def test_egg_method(self):
        assert True


@pytest.mark.foo
def test_bar_function():
    """テスト関数にマーカーあり"""
    assert True


def test_foo_function():
    """テスト関数に foo が含まれる"""
    assert True


def test_baz_function():
    assert True

 

mオプションの場合

mオプションの場合

  • マーカーがあるテストクラスのテストメソッド:2件
  • テストクラスのうち、マーカーがあるテストメソッド:1件
  • マーカーがあるテスト関数:1件

の合計4テストが実行されると考えていました。

 
実行すると、想定通り、対象は4テストでした。

$ pytest -m foo -v
...
collected 9 items / 5 deselected                                                                                                                                                  

test_k_option_marker.py::TestHam::test_spam_method PASSED
test_k_option_marker.py::TestHam::test_egg_method PASSED
test_k_option_marker.py::TestHoge::test_spam_method PASSED
test_k_option_marker.py::test_bar_function PASSED

==== 4 passed, 5 deselected in 0.03 seconds 

 

kオプションの場合

kオプションの場合、

  • foo が含まれるテストクラスのテストコード:2件
  • foo が含まれるテスト関数:1件

の合計3テストが実行されると考えていました。

 
実行すると、対象は7テストでした。

$ pytest -k foo -v
...
collected 9 items / 2 deselected                                                                                                                                                  

test_k_option_marker.py::TestHam::test_spam_method PASSED
test_k_option_marker.py::TestHam::test_egg_method PASSED
test_k_option_marker.py::TestHoge::test_spam_method PASSED
test_k_option_marker.py::TestHogefoo::test_spam_method PASSED
test_k_option_marker.py::TestHogefoo::test_egg_method PASSED
test_k_option_marker.py::test_bar_function PASSED
test_k_option_marker.py::test_foo_function PASSED

==== 7 passed, 2 deselected in 0.05 seconds

 
よく見ると、mオプションで対象だった

  • マーカーがあるテストクラスのテストメソッド:2件
    • test_k_option_marker.py::TestHam::test_spam_method
    • test_k_option_marker.py::TestHam::test_egg_method
  • テストクラスのうち、マーカーがあるテストメソッド:1件
    • test_k_option_marker.py::TestHoge::test_spam_method
  • マーカーがあるテスト関数:1件
    • test_k_option_marker.py::test_bar_function

も実行されていました。

 
なお、kオプションは部分一致なので、 -k oo としても同じ結果になりました。

$ pytest -k oo -v
...
==== 7 passed, 2 deselected

 
ドキュメントでは -k オプションがマーカーも対象にしているとは明示されていなかったため、新たな発見でした。

 

ソースコード

GitHubにあげました。 e.g._k_option_and_marker ディレクトリ以下が、今回のテストコードです。
https://github.com/thinkAmi-sandbox/python_pytest-sample

Werkzeugでレスポンスボディを後から差し替えてみた

Werkzeugでは werkzeug.wrappers.Response を使ってレスポンスオブジェクトを生成する際、 Response('Hello world') のようにコンストラクタの引数にレスポンスボディを渡します。

ただ、Responseオブジェクト生成後にレスポンスボディを修正する方法を知りたかったため、いろいろ試した時のメモです。

 
目次

 

環境

  • Python 3.6.6
  • Werkzeug 0.14.1

 

コンストラクタでレスポンスボディを指定

以下のWerkzeugアプリの中の index_handler() メソッドにて、コンストラクタでレスポンスボディを指定しています。

ルーティングにいろいろと書かれていますが、後から追加しますので、今は置いておきます。

from werkzeug.exceptions import HTTPException
from werkzeug.routing import Map, Rule
from werkzeug.wrappers import Request, Response


class Application:
    def __init__(self):
        self.url_map = Map([
            Rule('/', endpoint='index', methods=['GET']),
            Rule('/response', endpoint='response', methods=['GET']),
            Rule('/data', endpoint='data', methods=['GET']),
            Rule('/set_data', endpoint='set_data', methods=['GET']),
        ])

    def dispatch_request(self, request):
        adapter = self.url_map.bind_to_environ(request.environ)
        try:
            endpoint, values = adapter.match()
            return getattr(self, f'{endpoint}_handler')(request, **values)
        except HTTPException as e:
            return e

    def index_handler(self, request):
        return Response('Hello world')

    def wsgi_app(self, environ, start_response):
        request = Request(environ)
        response = self.dispatch_request(request)
        return response(environ, start_response)

    def __call__(self, environ, start_response):
        return self.wsgi_app(environ, start_response)


if __name__ == '__main__':
    from werkzeug.serving import run_simple
    app = Application()
    run_simple('0.0.0.0', 5000, app, use_debugger=True, use_reloader=True)

 
起動して localhost:5000/curlでアクセスすると

$ curl --include http://localhost:5000/
HTTP/1.0 200 OK
Content-Type: text/plain; charset=utf-8
Content-Length: 12
Server: Werkzeug/0.14.1 Python/3.6.6
Date: Mon, 15 Oct 2018 13:19:56 GMT

Hello world

Hello worldが返ってきます。

 

response属性で差し替えるのはNG

Werkzeugのドキュメントを見ると response 属性があります。
http://werkzeug.pocoo.org/docs/0.14/wrappers/#werkzeug.wrappers.BaseResponse.response

これを

def response_handler(self, request):
    response = Response('Hello world\n')
    response.response = 'update response body\n'
    return response

のようにして使えば、一見レスポンスを差し替えられそうです。

ただ、実際には

$ curl --include http://localhost:5000/response
HTTP/1.0 200 OK
Content-Type: text/plain; charset=utf-8
Content-Length: 12
Server: Werkzeug/0.14.1 Python/3.6.6
Date: Mon, 15 Oct 2018 13:22:44 GMT

update respo

と、Content-LengthヘッダがHello worldの時に計算した時のままなため、レスポンスボディが欠けています。

また、サーバのログを見ると

Warning: response iterable was set to a string.  This appears to work but means that the server will send the data to the client char, by char.  This is almost never intended behavior, use response.data to assign strings to the response object.
  _warn_if_string(self.response)

が残されており、response属性を使うのはダメそうです。

 

dataプロパティも不適切

次に、エラーメッセージにある data プロパティを使ってみます。

def data_handler(self, request):
    response = Response('Hello world\n')
    response.data = 'update response body\n'
    return response

 
curlでアクセスしてみます。

$ curl --include http://localhost:5000/data
HTTP/1.0 200 OK
Content-Type: text/plain; charset=utf-8
Content-Length: 21
Server: Werkzeug/0.14.1 Python/3.6.6
Date: Mon, 15 Oct 2018 13:25:52 GMT

update response body

Content-Lengthが再計算され、レスポンスボディの欠けもありません。

また、サーバのログにも先ほどのエラーは出力されていません。

 
ただ、ドキュメントを見ると、

A descriptor that calls get_data() and set_data(). This should not be used and will eventually get deprecated.

http://werkzeug.pocoo.org/docs/0.14/wrappers/#werkzeug.wrappers.BaseResponse.data

とあるため、dataプロパティを使うのもよくなさそうです。

 

set_data() メソッドを使うのが良さそう

最後に set_data() メソッドを使います。

ドキュメントにも

Sets a new string as response. The value set must either by a unicode or bytestring. If a unicode string is set it’s encoded automatically to the charset of the response (utf-8 by default).

http://werkzeug.pocoo.org/docs/0.14/wrappers/#werkzeug.wrappers.BaseResponse.set_data

と書かれており、これを使うのが良さそうです。

def set_data_handler(self, request):
    response = Response('Hello world\n')
    response.set_data('update response body\n')
    return response

と実装します。

curlでアクセスしてみます。

$ curl --include http://localhost:5000/set_data
HTTP/1.0 200 OK
Content-Type: text/plain; charset=utf-8
Content-Length: 21
Server: Werkzeug/0.14.1 Python/3.6.6
Date: Mon, 15 Oct 2018 13:30:48 GMT

update response body

とレスポンスが返り、サーバのログにもエラーが出ていませんでした。

 

ソースコード

GitHubに上げました。 response_body ディレクトリ以下が今回のコードです。
werkzeug-sample/response_body at master · thinkAmi-sandbox/werkzeug-sample

RedisをJSONに差し替えて、Werkzeugの公式チュートリアルを写経してみた

前回・前々回と、Werkzeugをさわってみました。

 
今回は、公式のチュートリアルを写経した時のメモを残します。

公式チュートリアル
Werkzeug Tutorial — Werkzeug Documentation (0.14)

 
目次

 

環境

  • Python 3.6.6
  • Werkzeug 0.14.1

 

感想

WerkzeugにはWSGIまわりのいろいろな機能がありますが、それを組み合わせてみようと思った時、どう組み合わせるのが良いか分かりませんでした。

そんな中、公式チュートリアルでは

などが一通り書かれていて、とても参考になりました。

 
ただ、少し敷居が高いと感じたのは、データストアとしてRedisを使っている部分でした。

Redisをインストールしてもよかったのですが、

と感じました。

 
チュートリアルでのRedisの使われ方を見ると、Pythonのdictでも代替できそうだったので

のどちらかで書こうと考えました。

そこで今回は、データがそのまま見えるJSONで書いてみました。

 
JSONを使った場合のソースコードは、最終的にはこんな感じになりました。

import json
import pathlib
import urllib.parse
from datetime import datetime

from jinja2 import Environment, FileSystemLoader
from werkzeug.exceptions import HTTPException, NotFound
from werkzeug.routing import Map, Rule
from werkzeug.utils import redirect
from werkzeug.wrappers import Request, Response
from werkzeug.wsgi import SharedDataMiddleware


class Shortly:
    """WSGIアプリケーション

    公式チュートリアルではRedisを使っていたが、ここではJSONで代用
    """
    JSON_PATH = pathlib.Path('./shortly.json')

    def __init__(self):
        template_path = pathlib.Path('./templates')
        self.jinja_env = Environment(loader=FileSystemLoader(str(template_path)),
                                     autoescape=True)

        # ルーティング
        self.url_map = Map([
            # endpointの方は、URLの逆引きの時に使う:チュートリアルでは取り扱わない
            Rule('/', endpoint='new_url'),
            Rule('/<short_id>', endpoint='follow_short_link'),
            Rule('/<short_id>+', endpoint='short_link_details'),
        ])

    def dispatch_request(self, request):
        adapter = self.url_map.bind_to_environ(request.environ)
        try:
            endpoint, values = adapter.match()
            # このチュートリアルでは、on_<endpoint>という名称のメソッドを呼び出す
            return getattr(self, f'on_{endpoint}')(request, **values)
        except HTTPException as e:
            # except HTTPException, e はPython2の古い書き方
            return e

    def render_template(self, template_name, **context):
        t = self.jinja_env.get_template(template_name)
        return Response(t.render(context), mimetype='text/html')

    def insert_url(self, url):
        json_data = {}
        if self.JSON_PATH.exists():
            with self.JSON_PATH.open('r') as f:
                json_data = json.load(f)
            short_id = json_data.get(f'reverse-url:{url}')
            if short_id is not None:
                return short_id

        now = datetime.now()
        stamp = datetime.strftime(now, '%Y%m%d%H%M%S')
        short_id = base36_encode(int(stamp))
        json_data[f'url-target:{short_id}'] = url
        json_data[f'reverse-url:{url}'] = short_id
        with self.JSON_PATH.open('w') as f:
            json.dump(json_data, f)
        return short_id

    def on_new_url(self, request):
        error = None
        url = ''
        if request.method == 'POST':
            url = request.form['url']
            if not is_valid_url(url):
                error = 'Please enter a valid URL'
            else:
                short_id = self.insert_url(url)
                return redirect(f'/{short_id}+')
        return self.render_template('new_url.html', error=error, url=url)

    def on_follow_short_link(self, request, short_id):
        with self.JSON_PATH.open('r') as f:
            json_data = json.load(f)
        link_target = json_data.get(f'url-target:{short_id}')
        if link_target is None:
            raise NotFound()
        return redirect(link_target)

    def on_short_link_details(self, request, short_id):
        with self.JSON_PATH.open('r') as f:
            json_data = json.load(f)
        link_target = json_data.get(f'url-target:{short_id}')
        if link_target is None:
            raise NotFound()
        return self.render_template('short_link_details.html',
                                    link_target=link_target,
                                    short_id=short_id,
                                    )

    def wsgi_app(self, environ, start_response):
        request = Request(environ)
        response = self.dispatch_request(request)
        return response(environ, start_response)

    def __call__(self, environ, start_response):
        """WSGIアプリを直接dispatchすることで、wsgi_app()をWSGIミドルウェアっぽく使える"""
        return self.wsgi_app(environ, start_response)


def is_valid_url(url):
    parts = urllib.parse.urlparse(url)
    return parts.scheme in ('http', 'https')


def base36_encode(number):
    assert number >= 0, 'positive integer required'
    if number == 0:
        return '0'
    base36 = []
    while number != 0:
        number, i = divmod(number, 36)
        base36.append('0123456789abcdefghijklmnopqrstuvwxyz'[i])
    return ''.join(reversed(base36))


def create_app(with_static=True):
    app = Shortly()

    if with_static:
        app.wsgi_app = SharedDataMiddleware(
            app.wsgi_app,
            {'/static': str(pathlib.Path('./static'))}
        )
    return app


if __name__ == '__main__':
    from werkzeug.serving import run_simple
    a = create_app()
    run_simple('127.0.0.1', 5000, a, use_debugger=True, use_reloader=True)

 

チュートリアル

Werkzeugのチュートリアルを写経した後ですが、GitHubリポジトリを見るといくつかサンプルが入っていました。
https://github.com/pallets/werkzeug/tree/master/examples

このあたりを眺めれば、他の使用方法がいろいろと分かりそうでした。

 

ソースコード

GitHubに上げました。以下のディレクトリの中に写経したときのものがいろいろと入っています。
syakyo/official_werkzeug at master · thinkAmi-sandbox/syakyo

WerkzeugでJSON等のいろいろなレスポンスを作ってみた

前回、Werkzeugでいろいろと試してみました。
Werkzeugでリクエスト・レスポンス・Cookieを試してみた - メモ的な思考的な

 
今回は、WerkzeugでいろいろなHTTPレスポンスを作ってみました。

 
目次

 

環境

  • Python 3.6.6
  • Werkzeug 0.14.1

 
なお、アプリの構成は前回作成した BaseStructure クラスをベースにしています。

 

特定のHTTPメソッドのみレスポンスを許可

Rule オブジェクトを生成する時の引数 methods にHTTPメソッドを指定することで、そのHTTPメソッドのみレスポンスを返せます。
http://werkzeug.pocoo.org/docs/0.14/routing/#werkzeug.routing.Rule

 

class Application:
    def __init__(self):
        self.url_map = Map([
            ...
            Rule('/get-only', endpoint='get_only', methods=['GET']),
            Rule('/post-only', endpoint='post_only', methods=['POST']),
        ])

    def get_only_handler(self, request):
        return Response('GET Only!\n')

    def post_only_handler(self, request):
        return Response(f'POST Only: {request.form.get("foo")}\n')

 
curlで動作確認します。

GETでアクセスした場合です。

# GETだけレスポンス可能なURL
$ curl --include localhost:5000/get-only
HTTP/1.0 200 OK
Content-Type: text/plain; charset=utf-8
...
GET Only!

# POSTだけレスポンス可能なURL
$ curl --include 'localhost:5000/post-only'
HTTP/1.0 405 METHOD NOT ALLOWED
Content-Type: text/html
Allow: POST

 
POSTでアクセスした場合です。

# GETだけレスポンス可能なURL
$ curl -w '\n' --include -X POST 'localhost:5000/get-only' --data 'foo=1'
HTTP/1.0 405 METHOD NOT ALLOWED
Content-Type: text/html
Allow: HEAD, GET

# POSTだけレスポンス可能なURL
$ curl -w '\n' --include -X POST 'localhost:5000/post-only' --data 'foo=1'
HTTP/1.0 200 OK
Content-Type: text/plain; charset=utf-8
...
POST Only: 1

 

JSONレスポンス

レスポンスオブジェクトの content_type引数に application/json をセットすることで、JSONをレスポンスできます。

class Application:
    def __init__(self):
        self.url_map = Map([
            Rule('/json', endpoint='json'),
        ])

    def json_handler(self, request):
        input_data = request.form.get('input')
        result = {
            'foo': 'abc',
            'bar': ['ham', 'spam', 'egg'],
            'result': input_data,
        }
        return Response(json.dumps(result), content_type='application/json')

 
では実際に、データをPOSTしてJSONAjaxで取得・表示するHTMLを用意して試してみます。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>JSON response</title>
</head>
<body>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<p>
    <label for="id_input">POST data: </label>
    <input id="id_input" type="text">
</p>
<button id="id_get_json">Go</button>
<p>foo: <span id="id_foo"></span></p>
bar:
<ul id="id_bar">
</ul>
<p>input data: <span id="id_input_result"></span></p>
<script>
    $(function () {
        $('#id_get_json').on('click', function () {
            $.ajax({
                url: '/json',
                type: 'POST',
                dataType: 'json',
                data: {'input': $('#id_input').val()}
            }).done((data) => {
                $('#id_foo').html(data['foo']);
                data['bar'].forEach((currentValue, index) => {
                    $('#id_bar').append(`<li>No. ${index}: ${currentValue} </li>`)
                });
                $('#id_input_result').html(data['result']);
            }).fail((data) => { console.log('fail!') })
        })
    })
</script>
</body>
</html>

 
ブラウザで見るとこのような感じです。

f:id:thinkAmi:20181002215447p:plain:w200

データを入力し、GoボタンでAjaxのPOSTを実行すると、以下のように表示が切り替わります。

f:id:thinkAmi:20181002215457p:plain:w200

 

ファイルをアップロード

ファイルをアップロードするHTMLフォームがあるとします。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>File Upload</title>
</head>
<body>
<form action="/upload" method="post" enctype="multipart/form-data">
    <p><input type="file" name="upload_file"></p>
    <p><input type="submit"></p>
</form>
</body>
</html>

 
Werkzeugでは、Requestオブジェクトの files を使うことで、アップロードされたファイルを取得できます。
http://werkzeug.pocoo.org/docs/0.14/wrappers/#werkzeug.wrappers.BaseRequest.files

以下のコードでは、アップロードされたファイルを uploads ディレクトリに保存します。

class Application:
    def __init__(self):
        self.url_map = Map([
            Rule('/upload', endpoint='upload'),
        ])

    def upload_handler(self, request):
        f = request.files.get('upload_file')
        f.save(str(pathlib.Path(f'./uploads/{f.filename}')))
        return Response('hello upload')

 

ファイルをダウンロード

ファイルをダウンロードするには、 Content-Disposition ヘッダを追加します。

以下ではURLにアクセスすると foo.csv がダウンロードされます。

class Application:
    def __init__(self):
        self.url_map = Map([
            Rule('/download', endpoint='download'),
        ])

    def download_handler(self, request):
        field_names = ['No', 'Name']
        contents = [
            {'No': 1, 'Name': 'Apple'},
            {'No': 2, 'Name': 'Mandarin'},
            {'No': 3, 'Name': 'Grape'},
        ]
        stream = StringIO()
        writer = csv.DictWriter(stream, fieldnames=field_names)

        # CSVヘッダの書込
        writer.writeheader()
        # CSVデータの書込
        writer.writerows(contents)

        # ストリームからデータを取得し、レスポンスとする
        data = stream.getvalue()

        headers = Headers()
        headers.add('Content-Disposition', 'attachment', filename='foo.csv')

        return Response(
            data,
            headers=headers,
        )

参考

 
ただし、上記の方法では日本語名のファイルはダウンロードできません。

日本語ファイルをダウンロードする場合は、

  • Content-Dispositionヘッダで、 filename*=アスタリスク付きでファイル名を指定する
  • ファイル名はパーセントエンコードする
    • RFC5987に基づく方法

が必要になります。

参考

 
なお、以前、Cookieを調べた時に、いろいろなパーセントエンコードについて知りました。
Pythonで、日本語をCookie値へ設定する方法を調べてみた - メモ的な思考的な

RFC5987でのパーセントエンコードを見たところ、

pct-encoded   = "%" HEXDIG HEXDIG
              ; see [RFC3986], Section 2.1

# https://tools.ietf.org/html/rfc5987#page-3

との記述がありました。

RFC3986についてはCookieの時に調べました。RFC3986でのパーセントエンコードPythonで実現するには、 urllib.parse.quote(character, safe='~') が良さそうでした。

 
そこで、

encoded_filename = quote(request.form['filename'], safe='~')
headers.add('Content-Disposition', f"attachment; filename*=UTF-8''{encoded_filename}")

として 一~二.csv というファイル名でダウンロードしてみたところ、

f:id:thinkAmi:20181002222338p:plain:w200

というファイル名でダウンロードされました。

~ という文字がファイル名に使えなかったため、 _ へと変換されたのでしょう。
ファイルをダウンロードさせたいときのいろんな方法(Servlet, Apacheなど) - Qiita

 

静的ファイルっぽく見せかけた時のレスポンス

これはどちらかというとルーティングについてです。

以下は拡張子 html 付きでアクセスした場合でも、動的なレスポンスを返すようなルーティングとなっています。

class Application:
    def __init__(self):
        self.url_map = Map([
            Rule('/extension.html', endpoint='extension'),
        ])

    def extension_handler(self, request):
        return Response('extension request')

 
curlで調べてみます。

$ curl localhost:5000/extension.html
extension request

 

ソースコード

GitHubに上げました。
thinkAmi-sandbox/werkzeug-sample

various_responses ディレクトリ以下が今回のものであり、Werkzeugアプリは various_response_app.py になります。

Werkzeugでリクエスト・レスポンス・Cookieを試してみた

Flaskでも使われているライブラリ Werkzeug でいろいろと試した時のメモです。

 
公式ドキュメントはこちらです。以下のサンプルでも公式ドキュメントへのリンクを記載しています。
http://werkzeug.pocoo.org/docs/0.14/

 
なお、curlでの動作検証時、以下が参考になりました。
curl コマンド 使い方メモ - Qiita

 
目次

 

環境

  • Python 3.6.6
  • Werkzeug 0.14.1

 

Werkzeugを使ったアプリの構成

今回作成するWerkzeugを使ったアプリは、Werkzeugのチュートリアル Step 2: The Base Structure をベースに、 app.py というファイルに追加していきます。
http://werkzeug.pocoo.org/docs/0.14/tutorial/#step-2-the-base-structure

import os

from werkzeug.wrappers import Request, Response
from werkzeug.wsgi import SharedDataMiddleware


class BaseStructure:
    """Werkzeugのチュートリアルにあった、Werkzeugアプリの基本的な構成
    http://werkzeug.pocoo.org/docs/0.14/tutorial/#step-2-the-base-structure
    """
    def dispatch_request(self, request):
        return Response('Hello world!')

    def wsgi_app(self, environ, start_response):
        request = Request(environ)
        response = self.dispatch_request(request)
        return response(environ, start_response)

    def __call__(self, environ, start_response):
        """WSGIアプリを直接dispatchすることで、wsgi_app()をWSGIミドルウェアっぽく使える"""
        return self.wsgi_app(environ, start_response)


def create_app(with_static=True):
    application = BaseStructure()

    if with_static:
        application.wsgi_app = SharedDataMiddleware(
            application.wsgi_app,
            {'/static': os.path.join(os.path.dirname(__file__), 'static')}
        )
    return application


if __name__ == '__main__':
    from werkzeug.serving import run_simple
    app = create_app()
    run_simple('127.0.0.1', 5000, app, use_debugger=True, use_reloader=True)

 
チュートリアルでは、dispatch_request() にルーティングを記載していました。

ただ、今回はルーティングについてはふれないものの、 dispatch_request() にいろいろと実装してきます。

 

HTTPリクエス

WSGIアプリケーションの引数 environwerkzeug.wrappers.Request に渡してインスタンス化することで、HTTPリクエストデータが扱いやすくなります。
http://werkzeug.pocoo.org/docs/0.14/wrappers/#werkzeug.wrappers.BaseRequest

request = Request(environ)

 

HTTPメソッド

request.method で取得します。
http://werkzeug.pocoo.org/docs/0.14/wrappers/#werkzeug.wrappers.BaseRequest.method

body.append(f'HTTP method: {request.method}')
# => GET

 

HTTPヘッダ

request.headers で取得します。
http://werkzeug.pocoo.org/docs/0.14/wrappers/#werkzeug.wrappers.BaseRequest.headers

for k, v in request.headers.items():
    body.append(f'Request header: key:{k} / value: {v}')
    # => Request header: key:Host / value: localhost:5000 ...

 

クライアントのIPアドレスなど

request.access_routerequest.remote_addr で取得します。

body.append(f'access_route: {request.access_route}')
# => access_route: ImmutableList(['127.0.0.1'])

body.append(f'remote_addr: {request.remote_addr}')
# => remote_addr: 127.0.0.1

 

クエリストリングの取得

request.argsrequest.values で取得します。

POSTの時にもクエリストリングの値を取得できます。

# request.argsを使う場合
body.append(f'Query String: {request.args}')
# => [GET] $ curl http://localhost:5000?foo=bar の場合
#    ImmutableMultiDict([('foo', 'bar')])
# => [POST] $ curl -w '\n' -X POST 'localhost:5000/?ham=spam' --data 'foo=1&bar=2' の場合
#    ImmutableMultiDict([('ham', 'spam')])

# request.valuesを使う場合
body.append(f'request.values: {request.values}')
# => [GET] $ curl http://localhost:5000?foo=bar の場合
#    CombinedMultiDict([ImmutableMultiDict([('foo', 'bar')]),
#                       ImmutableMultiDict([])

 
なお、POSTの時にもクエリストリングがあるのかどうかは、RFC的な規定は見つけられませんでした。ご存知の方は教えていただけますとありがたいです。
参考:http - Is it valid to combine a form POST with a query string? - Stack Overflow

 

POSTデータの取得

request.formrequest.values で取得します。

なお、GETの時は、request.formは ImmutableMultiDict([]) になるようです。

また、POSTの場合でクエリストリング & POSTデータがある場合は、CombinedMultiDict内のImmutableMultiDictに、両方のエントリが含まれます。

順番は、クエリストリング > POSTデータ、で良さそうです。

# request.formを使う場合
body.append(f'Form: {request.form}')
# => [GET] $ curl http://localhost:5000?foo=bar の場合
#    ImmutableMultiDict([])
# => [POST] $ curl -w '\n' -X POST 'localhost:5000/?ham=spam' --data 'foo=1&bar=2' の場合
#    ImmutableMultiDict([('foo', '1'), ('bar', '2')])

# request.valuesを使う場合
body.append(f'request.values: {request.values}')
# => [POST] $ curl -w '\n' -X POST 'localhost:5000/?ham=spam' --data 'foo=1&bar=2' の場合
#    CombinedMultiDict([ImmutableMultiDict([('ham', 'spam')]),
#                       ImmutableMultiDict([('foo', '1'), ('bar', '2')])
#                     ])

 

WSGI/CGI環境変数の取得

request.environ で取得します。
http://werkzeug.pocoo.org/docs/0.14/wrappers/#werkzeug.wrappers.BaseRequest.environ

結果は dict 型であり、この中に WSGI/CGI の両方の環境変数が含まれるようです。

body.append(f'environ: {type(request.environ)} / {request.environ}')
# => environ: <class 'dict'> / {'wsgi.version': (1, 0), 'wsgi.url_scheme': 'http', 'wsgi.input': <_io.BufferedReader name=6>, 'wsgi.errors': <_io.TextIOWrapper name='<stderr>' mode='w' encoding='UTF-8'>, 'wsgi.multithread': False, 'wsgi.multiprocess': False, 'wsgi.run_once': False, 'werkzeug.server.shutdown': <function WSGIRequestHandler.make_environ.<locals>.shutdown_server at 0x105f13c80>, 'SERVER_SOFTWARE': 'Werkzeug/0.14.1', 'REQUEST_METHOD': 'GET', 'SCRIPT_NAME': '', 'PATH_INFO': '/', 'QUERY_STRING': 'foo=bar', 'REMOTE_ADDR': '127.0.0.1', 'REMOTE_PORT': 55330, 'SERVER_NAME': '0.0.0.0', 'SERVER_PORT': '5000', 'SERVER_PROTOCOL': 'HTTP/1.1', 'HTTP_HOST': 'localhost:5000', 'HTTP_CONNECTION': 'keep-alive', 'HTTP_PRAGMA': 'no-cache', 'HTTP_CACHE_CONTROL': 'no-cache', 'HTTP_UPGRADE_INSECURE_REQUESTS': '1', 'HTTP_USER_AGENT': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/69.0.3497.100 Safari/537.36', 'HTTP_ACCEPT': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8', 'HTTP_ACCEPT_ENCODING': 'gzip, deflate, br', 'HTTP_ACCEPT_LANGUAGE': 'ja,en-US;q=0.9,en;q=0.8', 'HTTP_COOKIE': 'one_time=x; counter=8', 'werkzeug.request': <Request 'http://localhost:5000/?foo=bar' [GET]>}

 

HTTPレスポンス

werkzeug.wrappers.Response を使うことで、HTTPレスポンスデータとなります。
http://werkzeug.pocoo.org/docs/0.14/wrappers/#werkzeug.wrappers.BaseResponse

# msgは、HTTPレスポンスボディ
response = Response(msg)

 

Content-Typeをセット

response.content_type を使います。
http://werkzeug.pocoo.org/docs/0.14/wrappers/#werkzeug.wrappers.CommonRequestDescriptorsMixin.content_type

response.content_type = 'application/json'

 

独自HTTPヘッダをセット

独自HTTPヘッダをセットするには

の3つが使えます。

response.headers.add('X-headers-add', 'using add')
response.headers.add_header('X-headers-add_header', 'using add_header')
response.headers['X-headers-key'] = 'using key'
# => X-headers-add: using add
#    X-headers-add_header: using add_header
#    X-headers-key: using key

 

エラーページを表示

abort()raise InternalServerError() などが使えます。

 
なお、例外クラスを継承し、エラーページを差し替えることもできます。
http://werkzeug.pocoo.org/docs/0.14/exceptions/#custom-errors

以下の例では、InternalServerErrorを継承し、HTTP500エラー時の画面の表示を差し替えています。

class MyInternalServerError(InternalServerError):
    def get_body(self, environ=None):
        # werkzeug._compat.text_type()がprotectedなので、使っていいものか...
        return text_type(
            u'<!DOCTYPE html>'
            u'<title>My Internal Server Error</title>'
            u'<h1>Oh, my internal server error!</h1>'
        )

 

リダイレクト

werkzeug.utils.redirect() が使えます。
http://werkzeug.pocoo.org/docs/0.14/utils/#werkzeug.utils.redirect

return redirect('https://www.google.co.jp')

 
なお、URLに / を追加するリダイレクトのための werkzeug.utils.append_slash_redirect も用意されています。
http://werkzeug.pocoo.org/docs/0.14/utils/#werkzeug.utils.append_slash_redirect

 

Werkzeugではリクエスト時のCookieとレスポンス時のCookieが分かれています。

 

リクエスCookieの取得

request.cookies ですべてのCookieを取得します。
http://werkzeug.pocoo.org/docs/0.14/wrappers/#werkzeug.wrappers.BaseRequest.cookies

Cookieのキーを指定して値を取得するには、以下となります。

counter = request.cookies.get('counter', 0)

 

レスポンスCookieの設定

レスポンスオブジェクトを生成した後、 response.set_cookie() にてCookieをセットします。
http://werkzeug.pocoo.org/docs/0.14/wrappers/#werkzeug.wrappers.BaseResponse.set_cookie

response = Response(msg)
response.set_cookie('counter', str(int(counter) + 1))

 
なお、 response.delete_cookie()Cookieを削除できます。
http://werkzeug.pocoo.org/docs/0.14/wrappers/#werkzeug.wrappers.BaseResponse.delete_cookie

delete_cookie() を呼ぶと

Set-Cookie: one_time=; Expires=Thu, 01-Jan-1970 00:00:00 GMT; Max-Age=0; Path=/

のようなHTTPレスポンスヘッダが追加されます。

Cookieを削除しない限り、リクエストオブジェクトからレスポンスオブジェクトにCookieを移し替えなくても、次回のリクエスト時も同じキー・値でCookieがやってきます。

 

同じCookieキー名での設定に注意

例えば、

response.set_cookie('same_cookie', '1st', httponly=True)
response.set_cookie('same_cookie', '2nd', secure=True)

のように、同じCookieキー名 (same_cookie) でCookieをセットしたとします。

 
この場合、HTTPレスポンスヘッダは

Set-Cookie: same_cookie=1st; HttpOnly; Path=/
Set-Cookie: same_cookie=2nd; Secure; Path=/

となります。

ただし、リロードするなどして再度アクセスすると、HTTPリクエスト時のCookieヘッダは

Cookie: one_time=x; same_cookie=1st; counter=2

と、片方のものしかセットされていません。

 
CookieのRFC6265を見ると、

(原文)

Servers SHOULD NOT include more than one Set-Cookie header field in the same response with the same cookie-name. (See Section 5.2 for how user agents handle this case.)

https://tools.ietf.org/html/rfc6265#section-4.1

(日本語訳)

サーバは、同じ応答内に同じ cookie-name の複数の Set-Cookie ヘッダを内包するべきでない。 (UA がこの場合をどう扱うかについては、 5.2 節を見よ。)

https://triple-underscore.github.io/http-cookie-ja.html#section-4.1

とあります。

これらより、同じCookieキー名を設定すべきではなく、挙動もおかしくなるため、注意が必要です。

 

その他

開発WSGIサーバを外部アクセス可能にする

werkzeug.serving.run_simple() で開発WSGIサーバを起動できます。
http://werkzeug.pocoo.org/docs/0.14/serving/#werkzeug.serving.run_simple

なお、Flask同様、引数hostに 0.0.0.0 を指定することで、開発WSGIサーバを外部アクセス可能にします。
Flaskのサーバーはデフォルトだと公開されてない - Qiita

 

favicon.ico のリクエストを処理する

Cookie

counter = request.cookies.get('counter', 0)
response.set_cookie('counter', str(int(counter) + 1))

と実装し、ブラウザで表示した後、リロードしたところ、HTTPレスポンスヘッダが

Set-Cookie: counter=3; Path=/

となっていました。counter=2を想定していたのに、counter=3となっていました。

 
原因は、ブラウザでアクセスした時、1回で

の2リクエストが飛んでいたせいでした。

今回のアプリはルーティングがないため、favicon.icoのリクエストでも dispatch_request() が処理されてしまったようです。

 
対応としては、開発環境の場合は werkzeug.wsgi.SharedDataMiddleware が使えます *1

create_app()関数の中で、SharedDataMiddlewareを使います。

なお、Werkzeugのチュートリアルなどではディレクトリを指定していますが、以下のようにファイル自体を指定することも可能です。

def create_app(with_static=True):
    application = Application()

    # WSGIミドルウェアの設定ポイント
    if with_static:
        application.wsgi_app = SharedDataMiddleware(
            application.wsgi_app,
            {'/favicon.ico': str(pathlib.Path('./favicon.ico'))}
        )
    return application

 
次に、 app.py と同じディレクトリの中に favicon.ico を用意します。

以上で、favicon.icoへのアクセスはSharedDataMiddlewareにより処理され、dispatch_request()では処理されなくなります。

 

ソースコード

GitHubに上げました。base_structure ディレクトリ以下が今回のファイルです。
thinkAmi-sandbox/werkzeug-sample

*1:本番環境では何らかの形で静的ファイルを配信するはず