Pythonでサードパーティーのライブラリを使わずにマルチパートフォームデータ送信で画像をアップロードする

以前の(それなりにJava7・Java8のAPIを使いつつSpring Bootでファイルアップロードの処理を書いてみた - 猫にWeb)で作成したサーバーサイドのコードに対して、画像をアップロードするクライアント側のPythonのコードです。
現場の制約でサードパーティーのライブラリを使うことができないので、自前でマルチパートフォームデータの構造を作って画像をアップロードしています。


マルチパートフォームデータとは、HTTPリクエストで複数のフォームデータ(マルチパート)を送るための形式です。大別するとHTTPヘッダ部、ボディ部、フッタ部で構成されています。さらに各データをバウンダリーという文字列で区切り、画像ファイルデータはバイナリデータにして送信します。
具体的には以下のような構成になります。バイナリデータ部分は割愛しています。
*1ブラウザのREST Client系の拡張機能では自動的に内部で構成して送信しています。

POST http://127.0.0.1:8080/upload HTTP/1.1
Host: 127.0.0.1:8080
Accept-Encoding: identity
content-type: multipart/form-data; boundary=PpjeR7HcV42hrmr8XhuRA4xgDZRBdA
content-length: 49753

--PpjeR7HcV42hrmr8XhuRA4xgDZRBdA
Content-Disposition: form-data; name="id"

sample.jpg
--PpjeR7HcV42hrmr8XhuRA4xgDZRBdA
Content-Disposition: form-data; name="image"; filename="sample.jpg"
Content-Type: image/jpeg

[binary data]

ソースコードはこんな感じです。

import http.client
import mimetypes
import string
import random

def post_multipart(host, port, selector, fields, files):
    content_type, body = encode_multipart_formdata(fields, files)

    if(selector.find('https') == 0):
        h = http.client.HTTPSConnection(host, port)
    else:
        h = http.client.HTTPConnection(host, port)

    h.putrequest('POST', selector)
    h.putheader('content-type', content_type)
    h.putheader('content-length', str(len(body)))
    h.endheaders()
    h.send(body)
    response = h.getresponse()
    return response.read()

def encode_multipart_formdata(fields, files):
    BOUNDARY_STR = get_random_str(30)
    CHAR_ENCODING = "utf-8"
    CRLF = bytes("\r\n", CHAR_ENCODING)
    L = []
    for (key, value) in fields.items():
        L.append(bytes("--" + BOUNDARY_STR, CHAR_ENCODING))
        L.append(bytes('Content-Disposition: form-data; name="%s"' % key, CHAR_ENCODING))
        L.append(b'')
        L.append(bytes(value, CHAR_ENCODING))
    for (key, value) in files.items():
        filename = value['filename']
        content = value['content']
        L.append(bytes('--' + BOUNDARY_STR, CHAR_ENCODING))
        L.append(bytes('Content-Disposition: form-data; name="%s"; filename="%s"' % (key, filename), CHAR_ENCODING))
        L.append(bytes('Content-Type: %s' % get_content_type(filename), CHAR_ENCODING))
        L.append(b'')
        L.append(content)
    L.append(bytes('--' + BOUNDARY_STR + '--', CHAR_ENCODING))
    L.append(b'')

    body = CRLF.join(L)
    content_type = 'multipart/form-data; boundary=' + BOUNDARY_STR
    return content_type, body

def get_content_type(filename):
    return mimetypes.guess_type(filename)[0] or 'application/octet-stream'

def get_random_str(length):
    return ''.join([random.choice(string.ascii_letters + string.digits) for i in range(length)])

使い方はこんな感じです。

if __name__ == '__main__':
    host = 'httpbin.org'
    selector = 'http://httpbin.org/post'
    port = '80'
    
    file_path = "sample.jpg"
    fields = {
              'id' : file_path
             }
    content = open(file_path, 'rb').read()
    files = {'image': {'filename': 'sample.jpg', 'content': content}}

    response = post_multipart(host, port, selector, fields, files)
    print(response.decode('utf-8'))

コードはここです。
https://github.com/necoyama3/file-upload-client-sample/blob/master/NonLibraryFileUpload.py

*1:ChromeではPostman等