前回、inline (Base64エンコード) にて、SOAPでファイルを送信してみました。
Python + Zeepにて、SOAPのinline(Base64エンコード)でファイルを送信する - メモ的な思考的な
今回は、SOAP with attachments (以降、SwA) でファイルを送信してみます。実装に誤りがあれば、ご指摘いただけるとありがたいです。
目次
- SwAの種類
- 環境
- WSDLの実装
- Zeepでの実装方法を検討
- SwAを使った時のHTTPヘッダ・ボディについて
- HTTPヘッダ
- HTTPリクエストボディ
- Transportを実装
- 作成したSwATransportを使用してリクエストを送信
- 動作確認
- 参考文献
- ソースコード
SwAの種類
SwAの定義は
このSwAの内容は、要するに上記のようなMIMEマルチパートにおいて最初のパーツに本文となるSOAPメッセージを配置し、2つ目以降のパーツにはSOAPメッセージに添付されるデータを配置するための決まりごとだ。
http://www.atmarkit.co.jp/fdotnet/special/wse04/wse04_01.html
です。
ただ、調べた中だと、SwAに該当するものは3つありました。
- SOAP with Attachments (SwA)
- 仕様書:見当たらず
- swaRef
- wsi:swaRef
いずれも MIMEマルチパートを使っていますが、細かな点で実装が異なりました。
以降、個人的な理解です。
swaRefの実装
<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>
のような、
- WSDLで
swaRef
型で定義 - その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
として送信する時のリクエストボディ例は、以下となります。
なお、下記例は、実際のログに
と手を加えていることに注意します。
<!-- 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 | バイナリの場合 binary 、Base64エンコードの場合 base64 |
〃 | Content-Type - メディアタイプ | 送信するファイルに合わせる (今回はpngのため、 image/png ) |
〃 | Content-Type - name パラメータ |
送信ファイル名 |
〃 | Content-ID | 添付ファイルごとに一意になる値、内容は任意 |
〃 | Content-Disposition | name ・ filename パラメータとも、送信ファイル名 |
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.
とあり、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
を用意します。
次に上記で作成した 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
タブでリクエストヘッダを確認します。
Attachments
タブで、ファイルが送信されてきたことが確認できました。
また、Type列が MIME
となっています。このページの解説より、SOAP UIでもSwAのファイルとして認識されているようです。
ファイルを選択し、 Exports the selected attachment to a file
でファイルをエクスポートして確認します。
送信したファイルを取得できました。
Base64エンコード送信の結果
こちらも、SOAP UI のHeadersタブの結果です。binaryで送信するよりもContent-Lengthが増加しています。
Attachmentsタブでも、ファイルが送信されています。Content-IDも、Base64エンコードのスクリプトで指定した <spam>
になっています。
エクスポートした結果も同じです。
参考文献
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