Python + Zeep にて、SOAPのswaRef でファイルを送信する

以前、SOAP with Attachments (SwA) にて、SOAPでファイルを送信してみました。
Python + Zeep にて、SOAP with Attachment (SwA) でファイルを送信する - メモ的な思考的な

 
今回は、SwAに似た swaRefという仕様でファイルを送信してみます。
仕様書:SOAP Messages with Attachments

 
なお、今回扱うswaRefについてですが、SOAPエンベロープは、

<soap-env:Envelope xmlns:soap-env="http://schemas.xmlsoap.org/soap/envelope/">
  <soap-env:Body>
    <ns0:RequestInterface xmlns:ns0="http://example.com/HelloWorld">
      <ns0:image href="cid:spam"/>
    </ns0:RequestInterface>
  </soap-env:Body>
</soap-env:Envelope>

のように <ns0:image href="cid:spam"/> と、 href 属性を持っているため、WSI:swaRefとは異なります*1

また、動作確認は SOAP UI を使っているため、もしかしたらそれ以外の環境では動作しないかもしれません。

 
目次

 

環境

 

実装するもの

です。

Transportについては、SwAの実装を流用できますので、今回は省略します。

後述のGitHubにはTransportも実装してありますので、そちらを参照してください。

 

WSDLの実装

今回は image elementに href 属性を追加します。

そのため、

  • 名前空間の追加
  • ref属性を使って、要素に href 属性を追加

をします。

<!-- myという名前空間を追加 -->
<wsdl:definitions
...
        xmlns:my="http://example.com/HelloWorld"
        targetNamespace="http://example.com/HelloWorld">

    <wsdl:types>
        <xsd:schema elementFormDefault="qualified" targetNamespace="http://example.com/HelloWorld">
            <xsd:element name="RequestInterface">
                <xsd:complexType>
                    <xsd:sequence>
                        <!-- swaRef用の引数を用意:実体は別のところで定義 -->
                        <xsd:element ref="my:image"/>
                    </xsd:sequence>
                </xsd:complexType>
            </xsd:element>

            <!-- href属性を持つimage elementを用意 -->
            <xsd:element name="image">
                <xsd:complexType>
                    <xsd:attribute name="href" type="xsd:string" />
                </xsd:complexType>
            </xsd:element>

 

Zeepを実行するスクリプト

こちらも SwA のものを流用します。

変更は1点で、

response = client.service.requestMessage(image={'href': f'cid:{attachment_content_id}'})

と、imageタグの href 属性に値を設定するようにしています。

 

動作確認

SwAと同様、SOAP UIを使って動作を確認します。

SOAP UIをセットアップ後に実行すると、以下の結果となりました。 (量が多いため、バイナリのまま送信したもののみ記載)

$ python swa_ref_runner.py 
----------------------------------------
添付ファイルはバイナリのまま送信
----------------------------------------
b'--boundary_1a4cde05b2ab465a8b838ea3e15614d3\r\n
Content-Type: text/xml; charset=utf-8\r\n
Content-Transfer-Encoding: 8bit\r\n
Content-ID: start_237f3846a0214dc4ac767e5722f31eaa\r\n\r\n

<?xml version=\'1.0\' encoding=\'utf-8\'?>\n
<soap-env:Envelope xmlns:soap-env="http://schemas.xmlsoap.org/soap/envelope/">
  <soap-env:Body>
    <ns0:RequestInterface xmlns:ns0="http://example.com/HelloWorld">
      <ns0:image href="cid:ham"/>
    </ns0:RequestInterface>
  </soap-env:Body>
</soap-env:Envelope>\r\n
--boundary_1a4cde05b2ab465a8b838ea3e15614d3\r\n
Content-Transfer-Encoding: binary\r\n
Content-Type: image/png; name="shinanogold.png"\r\n
Content-ID: <ham>\r\n
Content-Disposition: attachment; name="shinanogold.png"; filename="shinanogold.png"\r\n\r\n

\x89PNG\r\n\...\x00\x00IEND\xaeB`\x82\r\n
--boundary_1a4cde05b2ab465a8b838ea3e15614d3--'
--- history ---
{'envelope': <Element {http://schemas.xmlsoap.org/soap/envelope/}Envelope at 0x10ad2cc88>, 
 'http_headers': {
   'SOAPAction': '"http://example.com/HelloWorld/requestMessage"', 
   'Content-Type': 'multipart/related; boundary="boundary_1a4cde05b2ab465a8b838ea3e15614d3"; 
                   type="text/xml"; start="start_237f3846a0214dc4ac767e5722f31eaa"; charset=utf-8',
   'Content-Length': '6321'}}
?
--- envelope ---
<soap-env:Envelope xmlns:soap-env="http://schemas.xmlsoap.org/soap/envelope/">
  <soap-env:Body>
    <ns0:RequestInterface xmlns:ns0="http://example.com/HelloWorld">
      <ns0:image href="cid:ham"/>
    </ns0:RequestInterface>
  </soap-env:Body>
</soap-env:Envelope>

 
SOAP UIのログを見ると、2エントリが追加されていました。

内容は以下の通りです。

f:id:thinkAmi:20190102151032p:plain:w300

 
また、ファイルをエクスポートしても、送信したファイル shinanogold.png を取得できました。

 

参考

 

ソースコード

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

*1:SwA系についてはいろいろな種類があり、正直良く分かってないので、どこかに一覧でまとまっているサイト/本があれば、教えていただけるとありがたいです

WSDLのelementにattributeを追加し、Python + ZeepでSOAPのエンベロープを作成する

swaRefの仕様書を眺めていたところ、

<?xml version='1.0' ?>
<SOAP-ENV:Envelope
        xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/">
    <SOAP-ENV:Body>
        <claim:insurance_claim_auto id="insurance_claim_document_id"
            xmlns:claim="http://schemas.risky-stuff.com/Auto-Claim">
            <theSignedForm href="cid:claim061400a.tiff@claiming-it.com"/>
            <theCrashPhoto href="cid:claim061400a.jpeg@claiming-it.com"/>
            <!-- ... more claim details go here... -->
        </claim:insurance_claim_auto>
    </SOAP-ENV:Body>
</SOAP-ENV:Envelope>

となっていました。

気になったのは

<theSignedForm href="cid:claim061400a.tiff@claiming-it.com"/>

のように、 element theSignedForm に、attribute href があったことです。

 
elementにattributeのあるWSDLを書いたことがないため、今回

  • WSDLで element に attribute を追加する方法
  • Zeepで、 attributeに値を設定する方法

をそれぞれ調べてみました。

 
目次

 

環境

 

WSDLのelementにattributeを追加する方法

WSDLXMLなので、XMLでの追加方法を調べてみました。

属性を追加する場合は、xsd:attribute要素を使用します。

複数要素を定義する4つの基本形を覚えよう:SEのためのXML Schema入門(2) - @IT

とのことなので、WSDL内の型定義を

<xsd:element name="RequestInterface">
    <xsd:complexType>
        <xsd:sequence>
            <xsd:element minOccurs="0" name="image"/>
        </xsd:sequence>

        <!-- 追加 -->
        <xsd:attribute name="href" type="xsd:string" />
    </xsd:complexType>
</xsd:element>

として、 image elementに href を追加できないか試してみました。

 
結果は、

<soap-env:Envelope xmlns:soap-env="http://schemas.xmlsoap.org/soap/envelope/">
  <soap-env:Body>
    <ns0:RequestInterface xmlns:ns0="http://example.com/HelloWorld" href="ham_spam"/>
  </soap-env:Body>
</soap-env:Envelope>

と、その親 RequestInterface elementに付きました。

 
RequestInterfaceの子の image element に追加できないかを調べたところ、同じページに

ref属性を記述した場合、要素の構造については別の場所で宣言します。そして、ref属性の値に記されている要素を参照します。

とありました。

 
そのため、WSDL

<!-- 名前空間 my (任意の名前で可)を追加 -->
<wsdl:definitions
...
        xmlns:my="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="RequestInterface">
                <xsd:complexType>
                    <xsd:sequence>
                        <!-- このelementにattributeを設定するため、refで別の型を参照させる -->
                        <!-- SOAP UIで動作させるため、名前空間(my)を付与して、参照先を明確にする -->
                        <xsd:element minOccurs="0" ref="my:image"/>
                    </xsd:sequence>
                </xsd:complexType>
            </xsd:element>

            <!-- attributeを付けたい型の定義-->
            <xsd:element name="image">
                <xsd:complexType>
                    <xsd:attribute name="href" type="xsd:string" />
                </xsd:complexType>
            </xsd:element>

として試してみます。

 
なお、WSDL中のコメントにも記載しましたが、 ref 属性を使うときは、

  • wsdl:definitions に、my という名前空間の定義を追加
  • ref="my:image" のように名前空間付きで定義

を行います。名前空間がない場合、SOAP UIでWSDLをimportする際、エラーとなってしまいます。

参考:xml - What does the ref attribute on an element in an XSD do? - Stack Overflow

 
実行結果は

<soap-env:Envelope xmlns:soap-env="http://schemas.xmlsoap.org/soap/envelope/">
  <soap-env:Body>
    <ns0:RequestInterface xmlns:ns0="http://example.com/HelloWorld">
      <ns0:image href="foo_bar"/>
    </ns0:RequestInterface>
  </soap-env:Body>
</soap-env:Envelope>

と、image elementに href attribute が追加されました。

 
ちなみに、

<xsd:element name="RequestInterface">
    <xsd:complexType>
        <xsd:sequence>
            <xsd:element minOccurs="0" name="image"/>

            <!-- sequenceの中にattributeを追加 -->
            <xsd:attribute name="href" type="xsd:string" />
        </xsd:sequence>
    </xsd:complexType>
</xsd:element>

と、 <xsd:sequence> の中に <xsd:attribute> を入れると、以下のエラーになります。

zeep.exceptions.XMLParseError: Unexpected element {http://www.w3.org/2001/XMLSchema}attribute in xsd:sequence

 

WSDLのattributeに、Zeepから値を設定する

WSDLでelementにattributeを付けられたものの、どうすればZeepから値を与えられるのかが分かりませんでした。

調べてみたところ、Stack Overflowに回答があったため、それを参考に実装してみます。
Python Zeep - how to set attributes for element - Stack Overflow

 

親要素のattributeを設定する

client.service.requestMessage(href='ham_spam') と、attribute名の引数に対し、設定したい値を渡します。

history_plugin = HistoryPlugin()
child_wsdl = BASE_PATH.joinpath('root_attribute.wsdl')
client = Client(str(child_wsdl), plugins=[history_plugin])

# Zeepと同様、requests_mockを使って、POSTをMockする
# https://github.com/mvantellingen/python-zeep/blob/3.2.0/tests/integration/test_http_post.py#L15
with requests_mock.mock() as m:
    m.post('http://localhost:9500/attributeBindingSoap11', text='<root>mocked!</root>')

    # requestMessage()の結果がWSDLの内容と異なるため、常にXMLSyntaxErrorが出る
    # 今回は送信したSOAPエンベロープの値を見たいので、例外は無視する
    try:
        response = client.service.requestMessage(href='ham_spam')
    except XMLSyntaxError:
        pass

    print(etree.tostring(history_plugin.last_sent['envelope'],
                         pretty_print=True, encoding='unicode'))

 
実行すると、親要素の RequestInterfacehref に値が設定されました。

<soap-env:Envelope xmlns:soap-env="http://schemas.xmlsoap.org/soap/envelope/">
  <soap-env:Body>
    <ns0:RequestInterface xmlns:ns0="http://example.com/HelloWorld" href="ham_spam"/>
  </soap-env:Body>
</soap-env:Envelope>

 

子要素のattributeを設定する

親要素との変更点は、 client.service.requestMessage(image={'href': 'foo_bar'}) と、element名の引数に対し、属性名のdictを渡すことだけです。

history_plugin = HistoryPlugin()
child_wsdl = BASE_PATH.joinpath('child_attribute.wsdl')
client = Client(str(child_wsdl), plugins=[history_plugin])

with requests_mock.mock() as m:
    m.post('http://localhost:9501/attributeBindingSoap11', text='<root>mocked!</root>')
    try:

        # image elementの要素をdictで渡す
        response = client.service.requestMessage(image={'href': 'foo_bar'})

    except XMLSyntaxError:
        pass

    print(etree.tostring(history_plugin.last_sent['envelope'],
                         pretty_print=True, encoding='unicode'))

 
実行すると、子要素の imagehref に値が設定されました。

<soap-env:Envelope xmlns:soap-env="http://schemas.xmlsoap.org/soap/envelope/">
  <soap-env:Body>
    <ns0:RequestInterface xmlns:ns0="http://example.com/HelloWorld">
      <ns0:image href="foo_bar"/>
    </ns0:RequestInterface>
  </soap-env:Body>
</soap-env:Envelope>

 

ソースコード

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

Python + Zeep にて、SOAP with Attachment (SwA) でファイルを送信する

前回、inline (Base64エンコード) にて、SOAPでファイルを送信してみました。
Python + Zeepにて、SOAPのinline(Base64エンコード)でファイルを送信する - メモ的な思考的な

 
今回は、SOAP with attachments (以降、SwA) でファイルを送信してみます。実装に誤りがあれば、ご指摘いただけるとありがたいです。

 
目次

 

SwAの種類

SwAの定義は

このSwAの内容は、要するに上記のようなMIMEマルチパートにおいて最初のパーツに本文となるSOAPメッセージを配置し、2つ目以降のパーツにはSOAPメッセージに添付されるデータを配置するための決まりごとだ。

http://www.atmarkit.co.jp/fdotnet/special/wse04/wse04_01.html

です。

ただ、調べた中だと、SwAに該当するものは3つありました。

いずれも MIMEマルチパートを使っていますが、細かな点で実装が異なりました。

 
以降、個人的な理解です。

 

swaRefの実装

swaRefは、SOAPエンベロープ

<soap-env:Envelope xmlns:soap-env="http://schemas.xmlsoap.org/soap/envelope/">
  <soap-env:Body>
    <ns0:RequestInterface xmlns:ns0="http://example.com/HelloWorld">
      <ns0:image href="cid:spam"/>
    </ns0:RequestInterface>
  </soap-env:Body>
</soap-env:Envelope>

の中にある <ns0:image href="cid:spam"/> のような、

  • href 属性を用意
  • href属性に、添付ファイルパートのContent-IDと同じ値を指定

という方式です。

 

wsi:swaRefの実装

wsi:swaRefは、WSDL

<xsd:schema elementFormDefault="qualified" targetNamespace="http://example.com/HelloWorld">
     <xsd:element name="RequestInterface">
         <xsd:complexType>
             <xsd:sequence>
                 <!-- WSI:swaRefのため、swaRef型の引数を用意 -->
                 <xsd:element minOccurs="0" name="image" type="ref:swaRef" />
             </xsd:sequence>
         </xsd:complexType>
     </xsd:element>

のような、

  • WSDLswaRef 型で定義
  • そのelement (上記例だとimage)に、添付ファイルパートのContent-IDと同じ値を指定

という方式です。

 

SwAの実装

SwAは、添付ファイルパートのContent-IDが一意であれば良く、SOAPエンベロープには何も指定しません。

 
ということで、今回は一番簡単そうなSwAについて実装してみます。

なお、今回のSwAで送信するファイルは1つのみです。仕様的には複数添付することも可能なようですので、一部実装を変更すれば動作すると思います。

 

環境

 

WSDLの実装

SwAでは送信するファイルはSOAPエンベロープの外側にあるため、リクエストパラメータは必要としません。

そのため、今回はリクエストパラメータ無しのWSDLを用意します。

swa.wsdl

<?xml version="1.0" encoding="UTF-8"?>
<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>
        <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">
        <wsdl:part name="parameters" element="ns0:ResponseInterface" />
    </wsdl:message>

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

    <wsdl:binding name="SwABindingSoap11" type="ns0:SwAPort">
        <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 name="SwAService">
        <wsdl:port name="SwAServicePort" binding="ns0:SwABindingSoap11">
            <soap11:address location="http://localhost:9401/SwABindingSoap11"/>
        </wsdl:port>
    </wsdl:service>
</wsdl:definitions>

 

Zeepでの実装方法を検討

今回はMTOMのpull requestを参考にして実装してみます。
https://github.com/mvantellingen/python-zeep/pull/314/files

その中では

  • Zeep本体のパッチ
  • Transportの差し替え

にて、MTOMを実装していました。

Zeep本体のパッチは行いたくないため、今回はTransportの差し替えのみで動作するよう、実装してみます。

 
既存のTransportクラスのソースコードを見ると、 post_xml() にコメントが書かれていました。

Post the envelope xml element to the given address with the headers. This method is intended to be overriden if you want to customize the serialization of the xml element. By default the body is formatted and encoded as utf-8. See zeep.wsdl.utils.etree_to_string.

https://github.com/mvantellingen/python-zeep/blob/3.2.0/src/zeep/transports.py#L86

そのため、post_xml()をオーバーライドして実装するのが正しいやり方っぽいです。

 

SwAを使った時のHTTPヘッダ・ボディについて

SwAを使った時のHTTPヘッダ・ボディの公式的な仕様を調べてみましたが、それらしい仕様書が見当たりませんでした*1

そのため、もしSwAが必要になるのであれば、リクエスト/レスポンスの仕様書が渡されると思うので、それを参考に実装するしかないのかなと感じました。

 
そのため、この記事では、SOAP UIで動作した時のHTTPヘッダ・ボディについて書いていきます。

他の環境では動作しないかもしれませんが、気にしないことにします。

 

HTTPヘッダ

HTTPヘッダには、以下のようなエントリがあれば良さそうです。

'http_headers': {
    'SOAPAction': '"http://example.com/HelloWorld/requestMessage"',
    'Content-Type': 'multipart/related; boundary="boundary_ec6c66dd25d44f84b27510babf3f1be0"; type="text/xml"; start="start_e876dcbdba0b4671bc3168ec12685c98"; charset=utf-8',
    'Content-Length': '8056'}

この中で、自分で実装すべきものは

  • Content-Typeヘッダ
  • Content-Lengthヘッダ

の2つです。

詳細は以下の通りです。

ヘッダ 項目 備考
Content-Type メディアタイプ Multipart/Related 1
boundary 任意の値 2
type text/xml
start SOAPメッセージパートの Content-ID の値と同じ値(任意の値)
charset utf-8
Content-Length リクエストボディの長さ

 
備考1. メディアタイプについて

XMLペイロードからバイナリ情報を取り除き、その情報をmultipart/related MIMEコンテンツとして、直接HTTPリクエストに格納すると言うことです。

https://www.ibm.com/developerworks/jp/xml/library/x-tippass/index.html

 
備考2. Content-Typeのboundaryパラメータについて

パート間の区切りを示すための文字列で、 --バウンダリ文字列 を設定します。

また、パート全体が終了する場合は、 --バウンダリ文字列-- を設定します。

参考:MIME(Multipurpose Internet Mail Extensions)~後編:インターネット・プロトコル詳説(4) - @IT

 

HTTPリクエストボディ

shinanogold.png ファイルを

  • ファイル形式は、バイナリ
  • boundaryパラメータは、 boundary_ec6c66dd25d44f84b27510babf3f1be0
  • startパラメータは、 start_e876dcbdba0b4671bc3168ec12685c98

として送信する時のリクエストボディ例は、以下となります。

なお、下記例は、実際のログに

  • iVB...rkJggg== の部分は実際のpngファイルなものの、長いため途中を省略
  • 見やすくするよう、「SOAPメッセージ部分」「送信ファイル部分」のコメントを追加

と手を加えていることに注意します。

<!-- SOAPメッセージ部分 -->
--boundary_ec6c66dd25d44f84b27510babf3f1be0
Content-Type: text/xml; charset=utf-8
Content-Transfer-Encoding: 8bit
Content-ID: start_e876dcbdba0b4671bc3168ec12685c98

<?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>
<!-- 送信ファイル部分 -->
--boundary_ec6c66dd25d44f84b27510babf3f1be0
Content-Transfer-Encoding: binary
Content-Type: image/png; name="shinanogold.png"
Content-ID: <ham>
Content-Disposition: attachment; name="shinanogold.png";filename="shinanogold.png"

iVB...rkJggg==
--boundary_ec6c66dd25d44f84b27510babf3f1be0--

 
コード中で設定すべき内容は以下です。

パート 項目
SOAPメッセージ Content-ID HTTPヘッダの start パラメータと合わせる
添付ファイル Content-Transfer-Encoding バイナリの場合 binaryBase64エンコードの場合 base64
Content-Type - メディアタイプ 送信するファイルに合わせる (今回はpngのため、 image/png )
Content-Type - name パラメータ 送信ファイル名
Content-ID 添付ファイルごとに一意になる値、内容は任意
Content-Disposition namefilename パラメータとも、送信ファイル名

 

Transportを実装

既存の zeep.transports.Transport クラスを拡張して、Transportを実装します。

MIMEデータであれば、Pythonの標準モジュール email を使うことで、実装量が減るかもしれません。
19.1. email — 電子メールと MIME 処理のためのパッケージ — Python 3.6.5 ドキュメント

ただ、今回は理解を深めるため、自分で実装していきます。

 

__init__() 部分

元々の __init__() をオーバーライドし、SwAで必要なものを受け取れるようにします。

また、クラス定数 NEW_LINE_FOR_SWA_BODY を bytes型の改行コードとして定義します。

class SwATransport(Transport):
    """ SOAP with Attachment (SwA、非swaRef)用のTransport

    添付ファイルは、binary/Base64エンコードのどちらも可 (引数is_base64izeで切り替え)
    なお、Type Hints は、自作部分のみ記載(Zeepのものは不明/差し替わる可能性があるため)
    """
    NEW_LINE_FOR_SWA_BODY = b'\r\n'

    def __init__(self, file: pathlib.Path, attachment_content_id: str, is_base64ize: bool = False,
                 cache=None, timeout=300, operation_timeout=None, session=None) -> None:
        self.file = file
        self.attachment_content_id = attachment_content_id
        self.is_base64ize = is_base64ize

        # boundaryとSOAP部分のContentIDは任意の値で良いため、内部で生成する
        self.boundary = f'boundary_{self._generate_id_without_hyphen()}'
        self.soap_message_content_id = f'start_{self._generate_id_without_hyphen()}'

        super().__init__(cache=cache, timeout=timeout, operation_timeout=operation_timeout,
                         session=session)

 

改行コードをbytes型で定義した理由

メソッド post_xml() で使う引数 message は、最終的にはrequestsライブラリのSessionクラスにあるメソッド post() の引数 data となります。

 
post()のソースコードを見ると、コメントとして

(optional) Dictionary, list of tuples, bytes, or file-like object to send in the body of the :class:Request.

https://github.com/requests/requests/blob/v2.21.0/requests/sessions.py#L574

とありました。

 
Zeepではどの型を渡しているかというと、

# https://github.com/mvantellingen/python-zeep/blob/3.2.0/src/zeep/transports.py#L94
message = etree_to_string(envelope)
return self.post(address, message, headers)

とあり、 etree_to_string() の結果となります。

 
etree_to_string()は、 lxml.etree.tostring() になるようです。

# https://github.com/mvantellingen/python-zeep/blob/3.2.0/src/zeep/wsdl/utils.py#L24
def etree_to_string(node):
    return etree.tostring(
        node, pretty_print=False, xml_declaration=True, encoding='utf-8')

 
lxmlのドキュメントを読むと、

Defaults to ASCII encoding without XML declaration. This behaviour can be configured with the keyword arguments 'encoding' (string) and 'xml_declaration' (bool). Note that changing the encoding to a non UTF-8 compatible encoding will enable a declaration by default.

You can also serialise to a Unicode string without declaration by passing the unicode function as encoding (or str in Py3), or the name 'unicode'. This changes the return value from a byte string to an unencoded unicode string.

https://lxml.de/api/lxml.etree-module.html#tostring

とあり、bytes型が返ってくるようです。

これより、 etree_to_string(envelope) は、SOAPエンベロープXML tree を bytes型へ変換します。よって、requestsにもbytes型で渡すことになります。

requestsには、MIMEメッセージヘッダとSOAPエンベロープを改行コードで連結したものを渡します。SOAPエンベロープがbytes型であることから、改行コードもbytes型にしました。

また、改行コードをbytes型にしたのに合わせ、SwATransportクラスの文字リテラルもbytes型で定義しました。

 
ちなみに、 __init__() では、引数 attachment_content_id はstr型を想定しています。そのため、bytes型リテラルに attachment_content_id を埋め込む時は %演算子 + %a を使います。 *2

 

ユーティリティメソッド

ユーティリティメソッドを2つ用意します。

def _generate_id_without_hyphen(self) -> str:
    """ uuid4()でIDを生成する

    uuid4()だとハイフンも含まれるため、削除しておく
    """
    return str(uuid.uuid4()).replace("-", "")

def _format_bytes(self, target: bytes, values: Iterable[str]) -> bytes:
    """ bytes型にstr型を埋め込む

    埋め込むと、シングルクォート(')まで含まれてしまうため、削除しておく
    """
    return (target % values).replace(b"'", b"")

前者は、任意の値でよかった

  • boundaryパラメータ
  • startパラメータ

向けのID生成メソッドとして使用します。

後者はコメント通りです。今回のリテラルではシングルクォートが存在しなかったため、問題ないと判断しました。

 

残りを実装

あとは、SOAPメッセージ部分を生成するメソッドと

def _create_soap_part(self, envelope) -> bytes:
    """ レスポンスボディのうち、SOAP部分を作成する """
    mime_message_header = self.NEW_LINE_FOR_SWA_BODY.join([
        self._format_bytes(b'--%a', (self.boundary,)),
        b'Content-Type: text/xml; charset=utf-8',
        b'Content-Transfer-Encoding: 8bit',
        self._format_bytes(b'Content-ID: %a', (self.soap_message_content_id,)),
        b'',
    ])

    # etree_to_string()にて、envelopeをbytes型の送信データへ変換する
    return self.NEW_LINE_FOR_SWA_BODY.join([
        mime_message_header,
        etree_to_string(envelope),
    ])

 
SwAで添付するファイル部分を生成するメソッドを用意します。

def _create_attachment_part(self) -> bytes:
    """ レスオンスボディのうち、添付ファイル部分を作成する """
    if not self.file:
        return b''

    transfer_encoding = 'base64' if self.is_base64ize else 'binary'

    # ファイル名からMIMEタイプを推測するが、戻り値はタプルであることに注意
    # Content-Type向けのは1つ目の要素
    # https://docs.python.jp/3/library/mimetypes.html#mimetypes.guess_type
    content_type, _ = mimetypes.guess_type(str(self.file))

    mime_part_header = self.NEW_LINE_FOR_SWA_BODY.join([
        self._format_bytes(b'--%a', (self.boundary,)),
        self._format_bytes(b'Content-Transfer-Encoding: %a', (transfer_encoding,)),
        self._format_bytes(b'Content-Type: %a; name="%a"', (content_type, self.file.name)),
        self._format_bytes(b'Content-ID: <%a>', (self.attachment_content_id,)),
        self._format_bytes(b'Content-Disposition: attachment; name="%a"; filename="%a"',
                           (self.file.name, self.file.name)),
        b'',
    ])

    # 添付ファイルはバイナリ(bytes型)であることに注意
    with self.file.open(mode='rb') as f:
        attachment_data = f.read()

    if transfer_encoding == 'base64':
        attachment_data = base64.b64encode(attachment_data)

    return self.NEW_LINE_FOR_SWA_BODY.join([
        mime_part_header,
        attachment_data,
    ])

 
そして、SOAPメッセージ部分と添付ファイル部分を連結するメソッドを用意します。

def _create_message(self, envelope) -> bytes:
    """ SwA用のリクエストボディを作成する

    requests.post() で送信するため、bytes型で返すこと
    """

    # SOAP部分の作成
    soap_part = self._create_soap_part(envelope)
    # 添付ファイル部分の作成
    attachment_part = self._create_attachment_part()

    return self.NEW_LINE_FOR_SWA_BODY.join([
        soap_part,
        attachment_part,
        self._format_bytes(b'--%a--', (self.boundary,)),
    ])

 
最後に、HTTPリクエストヘッダに追加します。

def _add_swa_header(self, headers: dict, message: bytes):
    """ SwA用のヘッダを追加する """

    headers["Content-Type"] = "; ".join([
        "multipart/related",
        'boundary="{}"'.format(self.boundary),
        'type="text/xml"',
        'start="{}"'.format(self.soap_message_content_id),
        "charset=utf-8"
    ])
    headers["Content-Length"] = str(len(message))

 
上記で各メソッドがそろったため、 post_xml() でそれらを呼び出せば完成です。

def post_xml(self, address, envelope, headers):
    """ オーバーライドして、SwAデータをPOSTできるようにする """

    message = self._create_message(envelope)
    self._add_swa_header(headers, message)

    # SwAの添付ファイル部分はSOAP envelopeの外側にあり、HistoryPluginでは表示されないため、
    # 送信時のmessageを見るためにprint()しておく
    print(message)

    # etree_to_string()はすでに実行済のため、元々のpost_xml()にあった、self.post()だけ実装する
    return self.post(address, message, headers)

 

作成したSwATransportを使用してリクエストを送信

作成した SwATransport クラスは、Zeep Clientを生成する時に使います。

以下は、WSDLに書かれたエンドポイントに requestMessage() を使ってファイルを送信する例です。

# swa_runner.py
def run(attachment_content_id, is_base64ize=False):
    session = Session()
    transport = SwATransport(ATTACHMENT, attachment_content_id=attachment_content_id,
                             is_base64ize=is_base64ize, session=session)

    history_plugin = HistoryPlugin()
    client = Client(str(WSDL), transport=transport, plugins=[history_plugin])
    response = client.service.requestMessage()

    print('--- history ---')
    print('-- header --')
    print(history_plugin.last_sent)
    print('-- SOAP envelope --')
    print(etree.tostring(history_plugin.last_sent['envelope'],
                         pretty_print=True, encoding='unicode'))
    print('--- response ---')
    print(response)


if __name__ == '__main__':
    print('-' * 40)
    print('添付ファイルはバイナリのまま送信')
    print('-' * 40)
    run(attachment_content_id='ham', is_base64ize=False)

    print('-' * 40)
    print('添付ファイルはBASE64化して送信')
    print('-' * 40)
    run(attachment_content_id='spam', is_base64ize=True)

 
ちなみに、Clientを生成した後でも、 transport 属性に設定すれば差し替えられます。

transport = SwATransport(...)
client.transport = transport

 

動作確認

SOAP UIの MockService を使って動作を確認します。

以下の内容を指定して作成後、MockServiceを起動し、 running on port 9401 と表示されればOKです。

項目
initial WSDL 上記で指定した swa.wsdl
Path WSDLで指定したエンドポイント /SwABindingSoap11
Port WSDLで指定したポート 9401

 
また、送信するファイルとして、 shinanogold.png を用意します。

f:id:thinkAmi:20190101165407p:plain:w100

 
次に上記で作成した swa_runner.py を実行します。

実行結果は以下です。見づらかったため、一部を改行しています。

$ python swa_runner.py 
----------------------------------------
添付ファイルはバイナリのまま送信
----------------------------------------
b'--boundary_80e8b8b01b1c47a7870a71622ec74c0b\r\n
Content-Type: text/xml; charset=utf-8\r\n
Content-Transfer-Encoding: 8bit\r\nContent-ID: start_e4e758dda9fc4671a4232b48e4a53360\r\n\r\n

<?xml version=\'1.0\' encoding=\'utf-8\'?>\n
<soap-env:Envelope xmlns:soap-env="http://schemas.xmlsoap.org/soap/envelope/">
  <soap-env:Body/>
</soap-env:Envelope>\r\n
--boundary_80e8b8b01b1c47a7870a71622ec74c0b\r\n
Content-Transfer-Encoding: binary\r\n
Content-Type: image/png; name="shinanogold.png"\r\n
Content-ID: <ham>\r\n
Content-Disposition: attachment; name="shinanogold.png"; filename="shinanogold.png"\r\n\r\n\

x89PNG...\x00IEND\xaeB`\x82\r\n
--boundary_80e8b8b01b1c47a7870a71622ec74c0b--'
--- history ---
-- header --
{'envelope': <Element {http://schemas.xmlsoap.org/soap/envelope/}Envelope at 0x10ff59cc8>, 
'http_headers': {
  'SOAPAction': '"http://example.com/HelloWorld/requestMessage"', 
  'Content-Type': 'multipart/related; boundary="boundary_80e8b8b01b1c47a7870a71622ec74c0b"; type="text/xml"; start="start_e4e758dda9fc4671a4232b48e4a53360"; charset=utf-8', 
  'Content-Length': '6192'}}
-- SOAP envelope --
<soap-env:Envelope xmlns:soap-env="http://schemas.xmlsoap.org/soap/envelope/">
  <soap-env:Body/>
</soap-env:Envelope>

--- response ---
?
----------------------------------------
添付ファイルはBASE64化して送信
----------------------------------------
b'--boundary_ddb04f0720aa4685ab41072a28818d2a\r\n
Content-Type: text/xml; charset=utf-8\r\n
Content-Transfer-Encoding: 8bit\r\n
Content-ID: start_e5e8c71ed781455a9ed66f214da1a5a7\r\n\r\n

<?xml version=\'1.0\' encoding=\'utf-8\'?>\n
<soap-env:Envelope xmlns:soap-env="http://schemas.xmlsoap.org/soap/envelope/">
  <soap-env:Body/>
</soap-env:Envelope>\r\n
--boundary_ddb04f0720aa4685ab41072a28818d2a\r\n
Content-Transfer-Encoding: base64\r\n
Content-Type: image/png; name="shinanogold.png"\r\n
Content-ID: <spam>\r\n
Content-Disposition: attachment; name="shinanogold.png"; filename="shinanogold.png"\r\n\r\n

iVBOR...AAAABJRU5ErkJggg==\r\n
--boundary_ddb04f0720aa4685ab41072a28818d2a--'
--- history ---
-- header --
{'envelope': <Element {http://schemas.xmlsoap.org/soap/envelope/}Envelope at 0x11004d388>, 
'http_headers': {
  'SOAPAction': '"http://example.com/HelloWorld/requestMessage"', 
  'Content-Type': 'multipart/related; boundary="boundary_ddb04f0720aa4685ab41072a28818d2a"; type="text/xml"; start="start_e5e8c71ed781455a9ed66f214da1a5a7"; charset=utf-8',
  'Content-Length': '8057'}}
-- SOAP envelope --
<soap-env:Envelope xmlns:soap-env="http://schemas.xmlsoap.org/soap/envelope/">
  <soap-env:Body/>
</soap-env:Envelope>

--- response ---
?

 
SOAP UIのログを見ると、2つエントリが追加されています。

前者がバイナリ形式での送信、後者がBase64エンコードでの送信のため、それぞれのログを確認します。

 

バイナリ送信の結果

SOAP UIの Headers タブでリクエストヘッダを確認します。

f:id:thinkAmi:20190101165028p:plain:w300

 
Attachments タブで、ファイルが送信されてきたことが確認できました。

また、Type列が MIME となっています。このページの解説より、SOAP UIでもSwAのファイルとして認識されているようです。

f:id:thinkAmi:20190101165127p:plain:w300

 
ファイルを選択し、 Exports the selected attachment to a file でファイルをエクスポートして確認します。

f:id:thinkAmi:20190101165221p:plain:w150

送信したファイルを取得できました。

 

Base64エンコード送信の結果

こちらも、SOAP UI のHeadersタブの結果です。binaryで送信するよりもContent-Lengthが増加しています。

f:id:thinkAmi:20190101165449p:plain:w300

 
Attachmentsタブでも、ファイルが送信されています。Content-IDも、Base64エンコードスクリプトで指定した <spam> になっています。

f:id:thinkAmi:20190101165552p:plain:w300

 
エクスポートした結果も同じです。

f:id:thinkAmi:20190101165543p:plain:w150

 

参考文献

MIMEまわりの定義を知るために読みました。
O'Reilly Japan - 電子メールプロトコル

古い書籍のため、 multipart/related については記載されていませんでしたが、それ以外については参考になりました。

 

ソースコード

GitHubに上げました。 file_attachments/swa 以下が今回のファイルです。
https://github.com/thinkAmi-sandbox/python_zeep-sample

*1:swaRefであれば、W3Cにありました:https://www.w3.org/TR/SOAP-attachments#HTTPBinding

*2:このあたりは以前書きました:Python3で、bytes型の文字列にstr型の文字列を埋め込むため、%演算子 + %a を使う - メモ的な思考的な / http://thinkami.hatenablog.com/entry/2018/12/30/181438

Python + Zeepにて、SOAPのinline(Base64エンコード)でファイルを送信する

SOAPでファイルを送信する方法を調べたところ、5つほど見つかりました。

  1. inline (Base64 エンコード)
  2. SOAP with Attachments (SwA)
  3. swaRef
  4. wsi:swaRef
  5. MTOM

参考:SOAP Attachments and Files | SoapUI

 
Zeepではどれが実装されているかを調べたところ、elementに直接送信ファイルを埋め込む形であるinlineはあるものの、他は実装されていなさそうでした。

ただ、MTOMについては、issueやPRがありました。

また、以下のcommitが取り込まれているため、将来的にはサポートされるかもしれません。
https://github.com/mvantellingen/python-zeep/commit/e346e91d6d8b0a37a84ab2bd9b423c25ddfd88f1#diff-40cce0d06cb2448df518c74200e882ae

 
そこで今回は、inline (Base64エンコード) でのファイル送信を試してみました。

なお、前提は以下です。

 
目次

 

環境

 

WSDLでの実装

inlineでデータを送信する場合、型(type)として xsd:base64Binary を使った定義を用意します。

<!-- xmlns:xsd="http://www.w3.org/2001/XMLSchema" -->
<xsd:element name="RequestInterface">
    <xsd:complexType>
        <xsd:sequence>
            <xsd:element minOccurs="0" name="image" type="xsd:base64Binary" />
        </xsd:sequence>
    </xsd:complexType>
</xsd:element>

 
残りの部分は、以前作成したものと同じです。
Python + Zeep + SOAP UI + 自作WSDLで、SOAPのリクエストとレスポンスを試してみた - メモ的な思考的な

全体像はこんな感じです。

inline.wsdl

<?xml version="1.0" encoding="UTF-8"?>
<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>
        <xsd:schema elementFormDefault="qualified" targetNamespace="http://example.com/HelloWorld">
            <xsd:element name="RequestInterface">
                <xsd:complexType>
                    <xsd:sequence>
                        <xsd:element minOccurs="0" name="image" type="xsd:base64Binary" />
                    </xsd:sequence>
                </xsd:complexType>
            </xsd:element>
            <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:part name="parameters" element="ns0:RequestInterface" />
    </wsdl:message>
    <wsdl:message name="messageOut">
        <wsdl:part name="parameters" element="ns0:ResponseInterface" />
    </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 name="InlineAttachmentBindingSoap11" 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 name="HelloService">
        <wsdl:port name="InlineAttachmentServicePort" binding="ns0:InlineAttachmentBindingSoap11">
            <soap11:address location="http://localhost:9400/inlineAttachmentBindingSoap11"/>
        </wsdl:port>
    </wsdl:service>
</wsdl:definitions>

 

Zeepでの実装

Zeepでは、以下の流れで実装すれば良さそうです。

コードだとこんな感じです。

# バイナリ形式でファイルを読み込み
with ATTACHMENT.open(mode='rb') as f:
    attachment_data = f.read()

# ファイルをBase64エンコード
encoded_data = base64.b64encode(attachment_data)

client = Client(str(WSDL))

# ZeepクライアントにBase64エンコードしたデータを渡す
# この場合、上記で定義した `image` elementに渡している
response = client.service.requestMessage(image=encoded_data)

 

動作確認

SOAP UIにて動作確認します。

そこで、SOAP UIのMockServiceをSOAPサーバとして動作させます。

  • SOAP UIにて、上記の inline.wsdl を指定してプロジェクトを作成します。
  • エンドポイント inlineAttachmentBindingSoap11 を右クリックし、 Generate SOAP Mock Service を選択
  • Generate MockService ダイアログでは、以下を指定
    • Pathに、WSDLと同じ /inlineAttachmentBindingSoap11
    • Portに、WSDLと同じ 9400
    • Starts the MockService immediately にチェックを入れ、MockServiceを起動
  • Generate SOAP MockService ダイアログでは、以下を指定
    • Specify name of MockService to createは、デフォルト値
  • MockServiceダイアログが表示されるため、実行ボタンを押す
    • 右側に run on port 9400 が表示されればOK

 
あとは、Zeepで作成したSOAPクライアントを実行します。

なお、送信時のSOAPエンベロープの内容を確認するため、History pluginを使います。
Python + Zeep + History pluginで、SOAPのリクエストとレスポンスを確認してみた - メモ的な思考的な

 
全体像は以下の通りです。

import base64
import pathlib

from lxml import etree
from zeep import Client
from zeep.plugins import HistoryPlugin

BASE_PATH = pathlib.Path(__file__).resolve().parents[0]
WSDL = BASE_PATH.joinpath('inline.wsdl')
ATTACHMENT = BASE_PATH.joinpath('shinanogold.png')


def run():
    with ATTACHMENT.open(mode='rb') as f:
        attachment_data = f.read()
    encoded_data = base64.b64encode(attachment_data)

    history_plugin = HistoryPlugin()
    client = Client(str(WSDL), plugins=[history_plugin])
    response = client.service.requestMessage(image=encoded_data)

    print('--- request body ---')
    print(etree.tostring(history_plugin.last_sent['envelope'], pretty_print=True, encoding='unicode'))

    print('--- response ---')
    print(response)


if __name__ == '__main__':
    run()

 
上記のPythonスクリプトを実行したところ、image要素に画像のBase64エンコードデータが含まれて送信されました(スペースの関係上、途中は省略しました)。

responseで ? が返ってきていますが、SOAP UIで何も設定していないため、デフォルトのレスポンス値の ? となっています。

$ python inline_runner.py 
--- request body ---
<soap-env:Envelope xmlns:soap-env="http://schemas.xmlsoap.org/soap/envelope/">
  <soap-env:Body>
    <ns0:RequestInterface xmlns:ns0="http://example.com/HelloWorld">
    
      <!-- Base64エンコードした画像データ(途中省略) -->
      <ns0:image>aVZ...PT0=</ns0:image>
      
    </ns0:RequestInterface>
  </soap-env:Body>
</soap-env:Envelope>

--- response ---
?

 
MockServiceダイアログを見ると、アクセスした時のログが追加されているため、ダブルクリックで表示します。

Request Messageタブを見ると、Zeepからリクエストした時のデータが表示されていました。

f:id:thinkAmi:20190101115607p:plain:w300

上記の内容を以下に転記しますが、History pluginの内容と一致しているため、うまく送信できたと考えられました。

<?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:image>aVZ...PT0=</ns0:image></ns0:RequestInterface></soap-env:Body></soap-env:Envelope>

 
本当に一致しているかはBase64デコードして確認する必要がありますが、今回は省略します。

 

ソースコード

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

2018年の振り返りと2019年の目標

例年通り、2018年の振り返りと2019年の目標っぽいものを書いてみます。

 

2018年の振り返り

2017年の振り返りと2018年の目標 - メモ的な思考的な で立てた目標を振り返ってみます。

 

Pythonを通じて、いろいろな分野の基礎固め

Blogを振り返ってみると、いろいろな分野に手を出していたようです。

 

その他
DjangoCongress JP 2018での発表

Django/WSGIミドルウェア入門 というタイトルで発表をしました。

 
発表を通じていろいろな発見があったので、とてもためになりました。ありがとうございました。

2019年もCfPを出せるといいな...

 

Blogでのアウトプット

2018年はインプットが多い一方、Blogでのアウトプットが減りました。

「確か昔やったような気がする」という時は、このBlogを検索することが多いです。なので、「将来の自分のために」という点でも良くなかったです。

ただ、そのことに気づいた12月は、小さなアウトプットの継続を心がけられました。

 

GitHub

仕事でも使っているので、プライベートでのcommitのみにしました。こちらもアウトプットが減ってますね...

f:id:thinkAmi:20190101072959p:plain:w300

 

イベント

2018年は、前半を中心にイベントに参加していました。

  1. 「SQLアンチパターン」読書会スペシャルに参加しました #nseg #glnagano #sqlap - メモ的な思考的な
  2. デブサミ2018の二日目に参加しました #devsumi - メモ的な思考的な
  3. #stapy #glnagano みんなのPython勉強会 in 長野#2に参加しました - メモ的な思考的な
  4. Recap of TensorFlow Dev Summit 2018 in 信州 に参加しました & LTしました #tfug #GDG信州 - メモ的な思考的な
  5. 「OSSライセンス」勉強会に参加しました #nseg #glnagano - メモ的な思考的な
  6. 技術書典4に行ってきました #技術書典 - メモ的な思考的な
  7. DjangoCongress JP 2018 に参加 & 発表しました #djangocongress - メモ的な思考的な
  8. #pyconjp PyCon JP 2018に参加しました - メモ的な思考的な

 
また、記録していませんでしたが、ギークラボ長野で開催されている読書会に参加したり、技術書典5にも参加していました。

 

資格まわり

社内Slackのビッグウェーブに乗りました。
Python 3 エンジニア認定基礎試験に合格しました - メモ的な思考的な

あと、

合格のコツは「合格するまで受験する」です。

という言葉をいただいたので、IPA試験の受験を継続しました。

昨年SAの論文で落ちてたため、そのステップとしてPMも受験した結果、PMとSAの両方に合格しました。

 

筋トレ

10月あたりから筋トレを始めて継続しています。

書籍「プリズナートレーニング」を参考に、週2回とても軽い負荷で、ゆるゆるとやっています。

プリズナートレーニング 圧倒的な強さを手に入れる究極の自重筋トレ

プリズナートレーニング 圧倒的な強さを手に入れる究極の自重筋トレ

 

時の流れ

2018年は

  • 地域イベントへの参加が増加
  • 介護の玉突きで余力が減少
  • 親戚の田んぼが、はぜ掛けからコンバインへ

など、時の流れを感じることが多かったです。

 
2019年も時の流れは変わらないため、計画を立てづらい & イベント参加が減りそうな感じなので、残念です。

 

2019年の目標っぽいもの

時の流れもあることから、自分の内部で閉じられる

  • Pythonをベースに、いろいろな分野の素振り
  • 筋トレの継続
  • 何らかのテストを受験
  • 小さなことでも良いので、2018年よりアウトプットを増やす

を目標っぽいものにします。

これ以外にも取り組みますが、目標倒れはつらいので外しておきます(弱気)。

 
というところで、今年もよろしくお願いします。

Python3で、bytes型の文字列にstr型の文字列を埋め込むため、%演算子 + %a を使う

Python3では、文字列表現として

  • str型
  • bytes型

の2つの型があります。

通常はstr型を使うのですが、データ通信などではbytes型を使ったりします。

 
両者は別モノなので、

$ python
Python 3.7.1 (default, Dec 19 2018, 21:58:12) 
[Clang 10.0.0 (clang-1000.11.45.5)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> 'shinano gold' == 'shinano gold'
True
>>> b'shinano gold' == b'shinano gold'
True
>>> 'shinano gold' == b'shinano gold'
False

となります。

 
そんな中、bytes型の文字列にstr型の文字列を埋め込みたいことがあったため、調べたことをメモします。

 
目次

 

環境

 

bytes型では、f-stringsが使えない (PEP-0498より)

Python3.6からはf-stringsを使うことで埋め込みが楽になりましたが、bytes型には使えません。

 
試しに実行してみると、syntaxエラーになりました。

>>> ringo = 'shinano gold'
>>> fb'I love {ringo}'
  File "<stdin>", line 1
    fb'I love {ringo}'
                     ^
SyntaxError: invalid syntax

 

bytes型では .format() が使えない (PEP-0460がobsolete)

PEP-0460がobsoleteのため、bytes型には .format() がありません。
PEP 460 -- Add binary interpolation and formatting | Python.org

 
試しに実行してみると、AttributeErrorになりました。

>>> ringo = 'shinano gold'
>>> b'I love {}'.format(ringo)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'bytes' object has no attribute 'format'

 

%演算子で変換型 %a を使う (Python 3.5〜、PEP-0461)

他の方法を探したところ、Python 3.5より % 演算子%a を使うことにより、埋め込めるようになりました。

 
試しに実行してみます。

>>> b'I love %a' % 'shinano gold'
b"I love 'shinano gold'"

' が含まれるものの、埋め込みできました。

 
' が不要な場合は、replace します。

# ワンライナーの場合
>>> (b'I love %a' % 'shinano gold').replace(b"'", b"")
b'I love shinano gold'

# 変数を使う場合
>>> message = b'I love %a' % 'shinano gold'
>>> message.replace(b"'", b"")
b'I love shinano gold'

 
なお、 %r も使えますが、上記の公式ドキュメントページには

'r' は 'a' の別名です。Python 2/3 の両方を対象としたコードでのみ使用すべきです。

と記載されています。

 

bytes型にbytes型を埋め込む時は、 %演算子 + %b を使う

>>> b'I love %b' % b'shinano gold'
b'I love shinano gold'

 
こちらも、 %s が使えますが、

's' は 'b' の別名です。Python 2/3 の両方を対象としたコードでのみ使用すべきです。

とのことです。

GCP Cloud Functions + Python + Slack Outgoing WebHooks App + Zaim APIで、SlackからZaimへ登録する

最近、 Zaim を使って家計簿をつけています。
https://zaim.net/

ただ、時々入力を忘れたり、重複入力してしまうことがありました。

そこで、

  • 日頃Slackを使っている
  • ZaimにはWeb APIがある

ということから、SlackからZaimのデータを登録する仕組みを作りましたので、メモを残します。

なお、今回の範囲では影響ありませんでしたが、Zaimの金融連携データはAPI経由では取得できないようです。プレミアムプラン契約をしている時は取得できるようになってくれるとありがたいです。

 
目次

 

環境

 

作ったもの

長いメモなので、こんな感じのものを作ったというのを書いておきます。

 

SlackからZaimへ登録する

決まったフォーマット (日付(yyyy/mm/dd or mm/dd) ジャンル名 金額 コメント) をSlackにポストすると、Zaimに反映します。

なお、登録する時に西暦を入力するのが手間だったため、西暦が省略された場合は、実行日の西暦を渡すようにしています。

Slackの様子

Zaimに登録できたら、OKなリアクションをします。

f:id:thinkAmi:20181228215056p:plain:w200

Zaimの様子

Zaimにも登録できています。

f:id:thinkAmi:20181228215250p:plain:w300

 
一方、Zaimに登録できない場合は、NGなリアクションと、NGになった理由をスレッドで返信します。

f:id:thinkAmi:20181228215652p:plain:w250

 
他にも、使い方を忘れたときのために、以下の2機能を作りました。

 

Zaimの登録可能なジャンルを知る

ジャンル とポストすると、Zaimで登録可能なジャンルの一覧をスレッドで返信します。

f:id:thinkAmi:20181228215826p:plain:w250

 

Zaimへ登録する際のポストの書式を知る

書式 もしくは フォーマット とポストすると、登録するポストの書式をスレッドで返信します。

f:id:thinkAmi:20181228220003p:plain:w250

 
以降は、これらを作った時のメモになります。

 

事前調査

SlackとZaimをつなぐ方法

SlackとZaimにはAPIがあるので、それらを使えばつなぐことができそうでした。ただ、メンテナンスの手間をかけたくないため、サーバレスで作ろうと考えました。

一般的な構成を調べると、AWS Lambda + API Gateway が多かったです。せっかくなので、今回は別の構成で作ることにしました。

 
GCPで同じ機能がないかを調べたところ、 Cloud Functions がありました。Betaながら、Python3.7.1もサポートしていました。
Google Cloud Functions documentation  |  Cloud Functions  |  Google Cloud

また、 HTTP Functions として作ることで、HTTP(S)のアクセスを直接受け付けるようでした。
HTTP Functions  |  Cloud Functions Documentation  |  Google Cloud

価格表を見ても無料枠があり、ある程度の運用ができそうでした。
Pricing  |  Cloud Functions Documentation  |  Google Cloud

 
そのため、GCP Cloud Functions を使った構成で進めることにしました。

 

Slackのポストをフックする手段

Slackのポストをフックする手段としては以下がありました。

他にも Slash Commands があったものの、Slack上で会話的にやりとりしたかったため、今回は使わないことにしました。
https://api.slack.com/slash-commands

 
まず、 Legacy Outgoing Webhooks を調べたところ、Web上にいろいろな知見がありました。

ただ、Slackのドキュメントには

You're reading this because you're looking for info on legacy custom integrations - an outdated way for teams to integrate with Slack. These integrations lack newer features and they will be deprecated and possibly removed in the future. We do not recommend their use.

https://api.slack.com/custom-integrations/outgoing-webhooks

と書かれていたため、今回は使うのをやめました。

 
次に Events API を見てみました。

その中の message.channels イベントを使えば、Slackへのポストをフックできそうでした。
https://api.slack.com/events/message.channels

しかし、実際に試してみると、全チャンネルの全ポストをフックし、Cloud Functionsにリクエストが来ていました。

Cloud Functionsに無料枠があるとはいえ、全部フックされるのはつらいので、今回は使うのをやめました。

 
最後に Outgoing WebHooks App を見てみました。

調べてみたところ、チャンネル限定でポストをフックできそうでした。

そのため、今回は Outgoing WebHooks App を使ってフックすることにしました。

 

Zaim APIについて

ZaimのAPIドキュメントを見たところ、APIでデータを登録できそうでした *1

また、APIのアクセスレベル(読込/書込/両方)も制御できました。

 

Zaim APIの category と genre について

Zaim APIでは

  • category
  • genre

の2つを指定し、APIを呼ぶ必要がありました。

 
それらが何を指すのか調べたところ、

のようでした。

 
ただ、画面上では両方の名称は確認できるものの、Zaim APIに渡すためのIDが不明でした。

 
Zaim APIの公式ドキュメントを見たところ、 GET /v2/genre を使うことで、categoryとgenreの両IDが取得できそうでした。

 

存在しないジャンルを用いた Zaim APIでの登録について

存在しないジャンルIDを用意して、Zaim APIにて登録してみたところ、エラーになることなく登録できました。

 
そのため、Zaim APIで登録する前に、ポストされたジャンルは正しいかという検証が必要そうでした。

 
以上で、事前調査が終わりました。続いて、実際のアプリケーションを作っていきます。

 

Zaimの設定

新しいアプリケーションの追加

Zaim Developers Centerへアクセスし、新しいアプリケーションを追加します。
Zaim Developers Center

 

アクセストークン系の取得

Zaimにアプリケーションを追加しただけではアクセストークン系が取得できません。

そのため、以下の記事を参考に、アクセストークンを取得するPythonスクリプトを作成します。
requestsを使ったOAuth認証 例題:Flickr - Qiita

なお、今回はターミナルで動かすスクリプトなため、 OAuth1.0aの oauth_callback は、RFC5849に従い oob (out-of-band:帯域外) を指定しました。
https://tools.ietf.org/html/rfc5849#section-2.1

class ZaimClient:
    def __init__(self):
        # 後述しますが、secret.json ファイルに、各種認証情報を設定してある前提
        with pathlib.Path(__file__).parents[1].joinpath('secret.json').open(mode='r') as f:
            secrets = json.load(f)
        self.tokens = secrets['Zaim']

    def print_access_token(self):
        """ Zaimのアクセストークンを取得・表示する

        OAuth1.0aの認証方法については、以下の記事を参考に実装
        https://qiita.com/kosystem/items/7728e57c70fa2fbfe47c
        """
        request_token = self._get_request_token()
        access_token = self._get_access_token(request_token)

        # ターミナル上にアクセストークンを表示する。形式は以下の通り
        # {'oauth_token': 'xxx', 'oauth_token_secret': 'yyy'}
        # oauth_token == ACCESS_TOKEN, oauth_token_secret == ACCESS_TOKEN_SECRET
        print(access_token)

    def _get_request_token(self):
        auth = OAuth1(
            self.tokens['CONSUMER_KEY'],
            self.tokens['CONSUMER_SECRET'],
            # CLIからの認証なので、RFC5849のsection-2.1より、 `oob` を指定しておく
            # https://tools.ietf.org/html/rfc5849#section-2.1
            callback_uri='oob')

        r = requests.post(self.tokens['REQUEST_TOKEN_URL'], auth=auth)
        return dict(urllib.parse.parse_qsl(r.text))

    def _get_access_token(self, request_token):
        # ブラウザを起動してOAuth認証確認画面を表示する
        # ユーザーが許可すると、「認証が完了」のメッセージとともにコードが表示される
        webbrowser.open(
            f'{self.tokens["AUTHORIZE_URL"]}?oauth_token={request_token["oauth_token"]}&perms=delete')

        # ターミナル上で、コードの入力を待つ(コード入力後、後続処理が行われる)
        oauth_verifier = input('コードを入力してください: ')

        auth = OAuth1(
            self.tokens['CONSUMER_KEY'],
            self.tokens['CONSUMER_SECRET'],
            request_token['oauth_token'],
            request_token['oauth_token_secret'],
            verifier=oauth_verifier)
        r = requests.post(self.tokens['ACCESS_TOKEN_URL'], auth=auth)

        access_token = dict(urllib.parse.parse_qsl(r.text))
        return access_token

 
認証情報の入った secrets.json はこんな感じです。各項目はZaimにアプリケーション登録した時に表示される値となります。

{
  "Zaim":
  {
    "REQUEST_TOKEN_URL": "https://your_url",
    "AUTHORIZE_URL": "https://your_url",
    "ACCESS_TOKEN_URL": "https://your_url",
    "CONSUMER_KEY": "xxx",
    "CONSUMER_SECRET": "xxx",
    "ACCESS_TOKEN": "",
    "ACCESS_TOKEN_SECRET": ""
  }
}

 
このスクリプトを実行し、

  • ブラウザが起動するのでログイン
  • 画面にトークンが表示
  • ターミナルにトークンを入力

とすると、ターミナルに以下の形式のアクセストークン系が表示されます。

{'oauth_token': 'xxx', 'oauth_token_secret': 'yyy'}

 
oauth_token (= ACCESS_TOKEN)、oauth_token_secret (= ACCESS_TOKEN_SECRET) を、 secrets.json に反映します。

 
ここまででZaimの設定は完了です。

 

Slackの設定

Slack Appの作成

以下のページより、今回のSlackアプリ (Slackのポストをフックして Cloud Functions を呼び出すアプリ) を作成します。
Slack API: Applications | Slack

 

OAuth Access Token の取得

Slack Appからポストできるようにするため、サイドバーの OAuth & Permissions から OAuth Access Token を取得しておきます。

この値を、前述の secrets.json にも追記しておきます。

 

Scopeの設定

今回、SlackからZaimへ登録した時の結果として、

  • 成功時:Slackポストに絵文字のリアクションを付ける
  • 失敗時:Slackポストに絵文字のリアクションを付けるとともに、ポストのスレッドにエラーメッセージを記載

を行いたいです。

 
そのため、Slack Appに以下の3つの権限を付与しました。

  • channels:history (Access user’s public channels)
  • chat:write:bot (Send messages as )
  • reactions:write (Add or remove emoji reactions for user)

 

Outgoing WebHooks Appの追加

Outgoing WebHooks Appのページから、対象のSlackスペースへアプリをインストールします。
Outgoing WebHooks | Slack App Directory

 

インテグレーションの設定

Outgoing WebHooks Appの設定を追加します。

今回、Slackのポストをフックして

  • Zaimへのデータ登録
  • Zaimへデータ登録する時のジャンルを表示
  • Zaimへデータ登録する時のフォーマットを表示

を実現したいです。

今回は Slash Commands として作成しないことから、

  • 1つの Cloud Functions 関数として作成
  • ポスト内容により、どの機能を動作させるのか振り分ける

ことにしました。

 
そのため、インテグレーションの設定は以下となりました。

項目
チャンネル Zaimへポストするためのチャンネルを専用で用意・指定
引き金となる言葉 空白 (全ての言葉に反応させるため)
URL 現時点では空白 (Cloud Functions側で設定したら、ここも設定)

 
以上がSlackの設定となります。

 

GCP Cloud Functions の設定

Googleアカウントまわりの設定

Cloud Functions を使うためにはクレジットカード情報などを登録する必要があります。

そのため、不正アクセスなどがされにくい & された時に気づきやすい点を考慮し、以下の設定を行いました。

  • 請求先アカウントのチュートリアルから作業する
  • 新しくGmailアカウントを作成
  • 2段階認証を設定
  • GCPでプロジェクトの作成
    • 任意の名前をつける
  • Cloud Functions APIを有効化
  • Cloud Functions APIのページへ移動
  • 無料トライアルに登録
    • 情報入力
      • アカウントの種類:個人
      • 名前と住所:入力
      • お支払い方法:カード情報の入力
  • 予算とアラートを追加
  • Cloud Functions に新しい関数を作成
    • リージョン: asia-northeast1
    • secret.json へ設定した内容を環境変数としても設定
    • トリガータブにエンドポイントURLがあり、これが Slack インテグレーション設定のURLとなる
      • コピペして、Slack の Outgoing WebHooks App の設定へと反映する

 

Cloud Functions の実装で悩んだところ

続いて Cloud Functions の関数を作成しますが、いくつか悩んだところがありました。  

名前について

むやみに外部からアクセスされても困るため、分かりづらい名前を付けることにしました。

そこで、Pythonuuid モジュールを使って、ランダムなuuidを生成し、名前として使うことにしました。
21.20. uuid — UUID objects according to RFC 4122 — Python 3.6.5 ドキュメント

# PythonのREPLを起動
$ python

# uuidモジュールを使って名前を出力
>>> import uuid
>>> print(str(uuid.uuid4()))

 

Slackへのレスポンス3秒ルールへの対応

Outgoing WebHooks Appでは関係ないかもしれませんが、ドキュメントに記載されていないので、念のための考慮となります。

SlackのEvent APIなどでは、Slackからの通知に対して3秒以内にレスポンスする必要があります。

Your app should respond to the event request with an HTTP 2xx within three seconds. If it does not, we'll consider the event delivery attempt failed. After a failure, we'll retry three times, backing off exponentially.

Maintain a response success rate of at least 5% of events per 60 minutes to prevent automatic disabling.

Respond to events with a HTTP 200 OK as soon as you can. Avoid actually processing and reacting to events within the same process. Implement a queue to handle inbound events after they are received.

What you do with events depends on what your application or service does.

https://api.slack.com/events-api#responding_to_events

 
しかし、今回は

  • Outgoing WebHooks Appから通知を受け取る
  • Zaim APIを使って、Zaimへ登録
  • Slack Web APIを使って、Slackへ返信

を行うため、3秒を超過する可能性があります。

 
他のSlackアプリはどうしているのか調べたところ、

  • Slackへは、すぐにHTTP200の返信を行う
  • 実際の処理は、遅延実行する

をしていました。

ただ、ほとんどの記事がAWSで実装されたものであり、Cloud Functionsのものは見当たりませんでした。

 
Cloud Functionsでできる方法を調べたところ、Pythonthreading.Thread が使えそうでした。

 
上記のstackoverflowの回答はHeroku上のものでしたが、Cloud Functionsでも問題なく動作しました。

# background()関数を用意し、そちらで Zaim APIを呼ぶなどの処理を実装する
t = Thread(target=background, kwargs={'request_data': request_data})
t.start()
return ''

 
ただ、本当に使っても大丈夫なのかは公式ドキュメントには見当たらなかったため、自己責任で...

 

Zaimのジャンル情報の保持

Zaim APIでデータを登録する際、ジャンル情報を渡す必要があります。

ただ、渡すジャンル情報はジャンル名ではなく、カテゴリIDとジャンルIDを設定する必要があります。

 
単純に考えると、Zaim APIで登録する前に、Zaim APIでジャンル情報を取得すれば良さそうでした。

ただ、1件登録するのに2回APIを呼ぶのはあまり良くない気がしました。

また、自分の運用を考えたところ、一度カテゴリやジャンルを決めてしまったら、その後は大きく変更していません。

 
そのため、都度 Zaim APIでジャンル情報を取得するのではなく、GCP側でジャンル情報を保持しておくことにしました。

もしジャンル情報を変更した場合は、保持している内容を書き換えるという運用としました。

 
次に保持する場所を考えました。ただ、これだけの目的でDBを使いたくなかったため、環境変数に設定できないかを考えました。

公式ドキュメントで環境変数のサイズを調べてみると、

Size limits

The total number of bytes used by environment variable names and values for an individual function is limited to 32KiB. However, there are no specific limits on individual keys or values within this overall capacity.

https://cloud.google.com/functions/docs/env-var#size_limits

とありました。

自分のジャンルの量からすると、トークンなどを考慮しても、32KiBで収まりそうでした。

 
以上より、環境変数JSON文字列としてジャンル情報をセットしておき、Zaim APIを呼ぶ時にジャンル名からIDへと変換するようにしました。

環境変数に設定する形式は

{"食料品": {"category_id": nnn, "genre_id": nnn}, "カフェ": {"category_id": nnn, "genre_id": nnn}, ... }

な感じとしました。

 

ロギング方法

公式ドキュメントに従い、 logging モジュールを使いました。
Writing, Viewing, and Responding to Logs  |  Cloud Functions Documentation  |  Google Cloud

なお、 logging.debug は動作しませんでした。見た感じだと無視されるようです。

 

Outgoing Webhooks Appからのアクセス判定

Slack Events API や Legacy Outgoing Webhooksでは、 X-Slack-Signature HTTPヘッダを検証することで、Slackからのリクエストかどうかが分かります。
Verifying requests from Slack | Slack

 
ただ、 Types of requests that support signed secrets を見ても、Outgoing Webhooks Appが記載されていません。 https://api.slack.com/docs/verifying-requests-from-slack#types_of_events

 
Outgoing Webhooks Appの設定を見ると、トークンという欄に

このトークンは発信ペイロードに送信されます。Slack チームから来たリクエストを確認する際にそのトークンを使用できます。

と記載されていました。

 
Cloud Functions上でSlackからのリクエストボディを確認すると

# print(request.form)の結果

ImmutableMultiDict([('token', 'xxx'), ('team_id', 'xxx'), ('team_domain', 'xxx'), ...])

でした。

 
そのため、環境変数にOutgoing Webhooks Appのトークンを設定しておくことで、

# 毎回 reqest.formと書くのが手間なので、変数に入れておく (以降の例も同様)
request_data = request.form

if request_data.get('token') != os.environ['SLACK_OUTGOING_WEBHOOKS_TOKEN']:
    # Slack以外のリクエストの処理
    ...

な形で判定できそうでした。

 
なお、Outgoing WebHooks AppからはPOSTしかされない前提のため、今回は request.form を使っています。

クエリストリングとリクエストボディの両方を取得したい場合は、 request.values になります。
http://werkzeug.pocoo.org/docs/0.14/wrappers/#werkzeug.wrappers.BaseRequest.values

 

Slack Web APIからの投稿かどうかの判定

今回、登録エラーとなった場合は、Slackのスレッドでエラーメッセージをポストします。

しかし、何も制御しないと、

  • Slack Web APIを使って、エラーメッセージを投稿
  • エラーメッセージの投稿がOutgoing WebHooks Appでフックされ、Cloud Functionsに送信
  • 同じくエラーが出るため、エラーメッセージを投稿
  • (以降繰り返し)

と、無限ループする可能性があります。

 
Events APIを使ってフックする場合は、 sub_type を見ることで Slack Web APIでの投稿かどうかが分かりそうです。

 
しかし、Outgoing WebHooks Appを使った場合、渡されてくるのは

  • token
  • team_id
  • team_domain
  • channel_id
  • channel_name
  • timestamp
  • user_id
  • user_name
  • text
  • trigger_word

だけでした。

 
そこで、自作のSlack App にはBotを作成しない状態で Slack Web APIchat.postMessage を使ってみたところ、 user_name に slackbot という値が渡されてきました。

 
そのため、Slack Web APIの投稿かどうかは、

if request_data.get('user_name') == 'slackbot':
    logging.info(f'bot access data:{request_data.get("text")}')
    return ''

と判定することにしました。

 

空データのアクセス判定

以上を実装した後にログを眺めていたところ、Outgoing WebHooks Appでは、本来のアプリからのアクセスの他に、1,2回アクセスが発生していることが分かりました。

また、この場合、リクエストデータが空っぽでした。

 
そのため、リクエストデータが空っぽの場合は、不明なアクセスと考えて、処理しないようにしました。

if request_data.get('token') != os.environ['SLACK_OUTGOING_WEBHOOKS_TOKEN']:
    logging.warning(f'not slack access, data: {request_data}')
    return ''

 

Cloud Functionsに登録した関数の内容

今までの内容をもとに作成した関数は以下の通りです。

""" GCP Cloud Functions を使って、SlackからZaimへデータをPostするためのスクリプト """

import json
import logging
import os
import unicodedata
from datetime import datetime
from threading import Thread

import zaim
from slackclient import SlackClient


def background(request_data):
    """ Cloud Functionsの別スレッドで動作する関数 """

    # 登録可能なジャンルを知りたい場合
    has_genre_response = response_all_genre(request_data)
    if has_genre_response:
        return

    # Zaimへ登録するためのフォーマットを知りたい場合
    has_format_response = response_format(request_data)
    if has_format_response:
        return

    error_msg, zaim_data = create_zaim_data(request_data)

    if zaim_data:
        error_msg = post_zaim(zaim_data)

    client = SlackClient(os.environ['SLACK_TOKEN'])
    if error_msg:
        # 念のため、ログにもエラーメッセージを出力しておく
        logging.debug(error_msg)

        # エラーの場合、NGリアクションとスレッドにエラーメッセージをポスト
        client.api_call(
            'reactions.add',
            name='man-gesturing-no',
            channel=request_data['channel_id'],
            timestamp=request_data['timestamp'],
        )
        client.api_call(
            'chat.postMessage',
            channel=request_data['channel_id'],
            thread_ts=request_data['timestamp'],
            text=error_msg,
        )
    else:
        client.api_call(
            'reactions.add',
            name='man-gesturing-ok',
            channel=request_data['channel_id'],
            timestamp=request_data['timestamp'],
        )


def response_all_genre(request_data):
    """ ジャンルを知りたい場合は、環境変数にあるジャンル一覧をスレッドとして返信する """
    text = request_data.get('text')
    if text != 'ジャンル':
        return False

    genre = load_genre()
    all_genre = ', '.join(genre.keys())

    client = SlackClient(os.environ['SLACK_TOKEN'])

    client.api_call(
        'reactions.add',
        name='book',
        channel=request_data['channel_id'],
        timestamp=request_data['timestamp'],
    )

    client.api_call(
        'chat.postMessage',
        channel=request_data['channel_id'],
        thread_ts=request_data['timestamp'],
        text=all_genre,
    )
    return True


def response_format(request_data):
    """ Zaimへ投稿するフォーマットを知りたい場合は、環境変数にあるジャンル一覧をスレッドとして返信する """
    text = request_data.get('text')
    if text not in ('書式', 'フォーマット'):
        return False

    text = '日付(yyyy/mm/dd or mm/dd) ジャンル名 金額 コメント ' \
           '(4項目は順不同、区切りはスペース(全角/半角どちらでも可))'

    client = SlackClient(os.environ['SLACK_TOKEN'])

    client.api_call(
        'reactions.add',
        name='memo',
        channel=request_data['channel_id'],
        timestamp=request_data['timestamp'],
    )

    client.api_call(
        'chat.postMessage',
        channel=request_data['channel_id'],
        thread_ts=request_data['timestamp'],
        text=text,
    )
    return True


def create_zaim_data(request_data):
    """ Zaimデータを作成する

    :param request_data: リクエストされたデータ
    :return: エラーメッセージ, Zaimデータ
    :rtype: str, dict
    """
    text = request_data.get('text')
    if not text:
        return '登録データがありません', None

    zaim_data = parse_zaim_data(text)
    if len(zaim_data.keys()) != 5:
        return f'登録するための項目が不足しています :{zaim_data}', None

    return None, zaim_data


def parse_zaim_data(text):
    """ SlackのポストをZaimデータにparseする

    :param text: Slackのポスト
    :return: Zaimデータ
    :rtype: dict
    """

    # Slackポストのフォーマット
    # 項目:日付、ジャンル名、金額、コメント (順不同、区切りはスペース(全角/半角どちらでも可))
    # 各項目や区切り文字は全角/半角のどちらでも可とするため、内部では正規化して処理する
    text_normalized = unicodedata.normalize('NFKC', text)

    # 入力項目ごとに区切る
    words = text_normalized.split()

    results = {}
    genre = load_genre()
    for word in words:
        if '/' in word:
            results['date'] = get_date(word)
        elif word.isdigit():
            results['amount'] = int(word)
        elif word in genre:
            category_id, genre_id = get_ids(word)
            if category_id and genre_id:
                results['category_id'], results['genre_id'] = category_id, genre_id
        else:
            today = datetime.today()
            results['comment'] = f'{word} (Slack登録: {today.year}/{today.month}/{today.day})'

    return results


def get_ids(word):
    """ カテゴリID、ジャンルIDを取得する

    :param word: ジャンルっぽい文字列
    :return: カテゴリID, ジャンルID (存在しない場合: None, None)
    :rtype: str, str
    """
    genres = load_genre()
    genre = genres.get(word)
    if genre:
        return genre['category_id'], genre['genre_id']
    return None, None


def load_genre():
    """ 環境変数からジャンルを取得し、Pythonオブジェクト化する """
    genre = os.environ.get('ZAIM_GENRE')
    if not genre:
        return

    return json.loads(genre)


def get_date(date_text):
    """ スラッシュ区切りで日付を指定

    01/01 -> 同年の1/1
    1/1 -> 同上
    2018/1/1 -> 年数も考慮
    それ以外 -> 判断つかないので、本日とする
    """
    date_list = date_text.split('/')
    if len(date_list) == 2:  # 月日のみ
        str_date = f'{datetime.today().year}{date_list[0].zfill(2)}{date_list[1].zfill(2)}'
        return datetime.strptime(str_date, '%Y%m%d')

    if len(date_list) == 3:  # 年月日
        str_date = f'{date_list[0]}{date_list[1].zfill(2)}{date_list[2].zfill(2)}'
        return datetime.strptime(str_date, '%Y%m%d')

    return datetime.today()


def post_zaim(zaim_data):
    """ Zaim APIにポストする

    :param zaim_data: Zaimにポストするためのデータ
    :return: 正常の場合はNone、エラーがある場合はエラーメッセージ
    :rtype: None or str
    """
    try:
        api = zaim.Api(consumer_key=os.environ['CONSUMER_KEY'],
                       consumer_secret=os.environ['CONSUMER_SECRET'],
                       access_token=os.environ['OAUTH_TOKEN'],
                       access_token_secret=os.environ['OAUTH_TOKEN_SECRET'])
        api.verify()

        # api.payment()の戻り値は以下の通り。そのため、戻り値を使って何かする、ということは無い
        # 正常:レスポンスのJSON内容が返ってくる
        # エラー:例外が出る
        api.payment(
            # 存在しないcategory_idでPOSTすると、「振替」だけれど変なデータができてしまうが、OKで通る
            # ただし、数字のところに文字列を入れると、例外が発生する
            # category_id="101xx",
            category_id=zaim_data['category_id'],
            genre_id=zaim_data['genre_id'],
            amount=zaim_data['amount'],
            date=zaim_data['date'],
            comment=zaim_data['comment']
        )
        return None
    except Exception as e:
        return str(e)


def main(request):
    """ Cloud Functions 呼ばれるメインの関数 """
    # Outgoing WebHooks App からは、POSTしかデータ送信されない前提なので、.formを使う
    # クエリストリングも同時に取得したい場合は .values を使う
    # 毎回 reqest.formと書くのが手間なので、変数に入れておく
    request_data = request.form

    # Outgoing Webhooks アプリだと、本来のアプリの他に、1,2回アクセスがある
    # この場合、request.formは空になっている
    if not request_data:
        logging.info(f'empty form data:{request_data}')
        return ''

    # Outgoing WebHooks アプリからの送信かどうかのバリデーション
    if request_data.get('token') != os.environ['SLACK_OUTGOING_WEBHOOKS_TOKEN']:
        logging.warning(f'not slack access, data: {request_data}')
        return ''

    # Botの場合に返信してしまうと、無限ループになるため除外する
    if request_data.get('user_name') == 'slackbot':
        logging.info(f'bot access data:{request_data.get("text")}')
        return ''

    # Slackの3秒ルールがあるため、リクエストが届いたということを通知するために
    # メイン処理は別スレッドに流して、ここは HTTP 200 をすぐに返す
    t = Thread(target=background, kwargs={'request_data': request_data})
    t.start()
    return ''

 
あとはこれを稼働させることで、冒頭のSlackやZaimのスクリーンショットの内容が実現できました。

 

ソースコード

GitHubに上げました。
https://github.com/thinkAmi/slack2zaim

*1:ZaimにログインしないとAPIドキュメントが読めないので、URLを貼るのはやめておきます