diff --git a/.github/workflows/pythonapp.yml b/.github/workflows/pythonapp.yml index 37d08c0..1226b4c 100644 --- a/.github/workflows/pythonapp.yml +++ b/.github/workflows/pythonapp.yml @@ -12,6 +12,13 @@ jobs: matrix: os: [ ubuntu-latest, macos-latest, windows-latest ] python-version: [ 3.8, 3.12 ] + # Python 3.8 is not available anymore for ubuntu-latest (Ubuntu 26.04) + exclude: + - os: ubuntu-latest + python-version: 3.8 + include: + - os: ubuntu-24.04 + python-version: 3.8 steps: - uses: actions/checkout@v4 - name: Set up Python diff --git a/README.md b/README.md index a756bd6..27c2b47 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,36 @@ Post Request Test Status Should Be 201 ${resp} ``` +### 📎 Multipart form data upload + +`Create Multipart Data` builds a `multipart/form-data` body, the format used by HTML forms and most +APIs to upload files, to pass as `data` to `POST On Session`, `PUT On Session` or `PATCH On Session`. +Files are streamed from disk, so large files are not loaded in memory. +It needs an optional dependency: `pip install "robotframework-requests[multipart]"` + +- `fields`: dictionary of `name=value`, or list of `(name, value)` pairs to repeat a name. + Dictionary and list values are sent as JSON. +- `files`: dictionary of `name=path`, or list of `(name, path[, content_type[, filename]])`, + `(name, [paths])` or `{name, path, content_type, filename}` dictionaries. + The file name and content type are taken from the path when not given. +- `files_first=${True}` sends the files before the fields. +- The `Content-Type` header with its boundary is set for you, session headers such as an + `Authorization` token are kept. + +```robotframework +Upload Report Test + ${headers}= Create Dictionary Authorization=Bearer ${token} + Create Session api https://example.com headers=${headers} + ${settings}= Create Dictionary everything=${True} + ${fields}= Create Dictionary action=add settings=${settings} + ${files}= Create Dictionary document=${CURDIR}/report.pdf + ${multipart}= Create Multipart Data fields=${fields} files=${files} + ${resp}= POST On Session api /upload data=${multipart} +``` + +See the [Create Multipart Data](http://marketsquare.github.io/robotframework-requests/doc/RequestsLibrary.html#Create%20Multipart%20Data) +keyword documentation for all the formats and examples. + ### 📖 Keywords documentation Robotframework-requests offers a wide set of keywords which can be found in diff --git a/atests/test_post_multipart.robot b/atests/test_post_multipart.robot index 7b3b094..0ed0330 100644 --- a/atests/test_post_multipart.robot +++ b/atests/test_post_multipart.robot @@ -1,5 +1,6 @@ *** Settings *** Library RequestsLibrary +Library OperatingSystem *** Test Cases *** @@ -42,3 +43,126 @@ Test Post List On Session Multipart Should Contain ${resp.json()}[headers][Content-Length] 466 Should Contain ${resp.json()}[files] randombytes Length Should Be ${resp.json()}[files][randombytes] 2 + +Test Post Multipart Data With Fields And Files On Session + ${settings}= Create Dictionary everything=${True} + ${fields}= Create Dictionary action=add settings=${settings} + ${files}= Create Dictionary document=${CURDIR}${/}data.json + ${multipart}= Create Multipart Data fields=${fields} files=${files} + ${file}= Set Variable ${multipart.fields}[2][1][1] + + Should Not Be True ${file.closed} + + ${resp}= POST On Session ${GLOBAL_SESSION} /anything data=${multipart} + + Should Be True ${file.closed} + Should Be Equal ${resp.json()}[headers][Content-Type] ${multipart.content_type} + Should Be Equal ${resp.json()}[form][action] add + Should Be Equal ${resp.json()}[form][settings] {"everything": true} + # raw bytes: Get File would turn the CRLF line endings of a Windows checkout into LF + ${expected}= Get Binary File ${CURDIR}${/}data.json + Should Be Equal ${resp.json()}[files][document] ${expected.decode('utf-8')} + +Test Put Multipart Data With Repeated Fields And Files On Session + ${tag_1}= Create List tag one + ${tag_2}= Create List tag two + ${fields}= Create List ${tag_1} ${tag_2} + ${file_1}= Create List attachments ${CURDIR}${/}data.json + ${file_2}= Create List attachments ${CURDIR}${/}index.html text/html renamed.html + ${files}= Create List ${file_1} ${file_2} + ${multipart}= Create Multipart Data fields=${fields} files=${files} + + ${resp}= PUT On Session ${GLOBAL_SESSION} /anything data=${multipart} + + Should Contain ${resp.json()}[headers][Content-Type] multipart/form-data; boundary= + Should Be Equal ${resp.json()}[form][tag] ${{['one', 'two']}} + Length Should Be ${resp.json()}[files][attachments] 2 + +Test Post Large Multipart Data With Fields + ${large_file}= Set Variable ${OUTPUT_DIR}${/}large_multipart.bin + Create Binary File ${large_file} ${{os.urandom(50000)}} + ${fields}= Create Dictionary name=large + ${files}= Create Dictionary file=${large_file} + ${multipart}= Create Multipart Data fields=${fields} files=${files} + + ${resp}= POST ${HTTP_LOCAL_SERVER}/anything data=${multipart} + + Should Be Equal ${resp.json()}[form][name] large + Should Be True int($resp.json()['headers']['Content-Length']) > 50000 + [Teardown] Remove File ${large_file} + +Test Post Multipart Data Keeps Explicit Content Type + ${files}= Create Dictionary document=${CURDIR}${/}data.json + ${multipart}= Create Multipart Data files=${files} + ${headers}= Create Dictionary Content-Type=${multipart.content_type}; charset=utf-8 + + ${resp}= POST On Session ${GLOBAL_SESSION} /anything data=${multipart} headers=${headers} + + Should Be Equal ${resp.json()}[headers][Content-Type] ${multipart.content_type}; charset=utf-8 + +Test Post Multipart Data With Files First And A List Of Paths + ${fields}= Create Dictionary action=add + ${paths}= Create List ${CURDIR}${/}data.json ${CURDIR}${/}index.html + ${attachments}= Create List attachments ${paths} + ${files}= Create List ${attachments} + ${multipart}= Create Multipart Data fields=${fields} files=${files} files_first=${True} + + Should Be Equal ${multipart.fields}[0][0] attachments + Should Be Equal ${multipart.fields}[1][0] attachments + Should Be Equal ${multipart.fields}[2][0] action + + ${resp}= POST On Session ${GLOBAL_SESSION} /anything data=${multipart} + + Should Be Equal ${resp.json()}[form][action] add + Length Should Be ${resp.json()}[files][attachments] 2 + +Test Post Multipart Data Keeps Session Headers + ${session_headers}= Create Dictionary Authorization=Bearer some-token Content-Type=application/json + Create Session bearer_session ${HTTP_LOCAL_SERVER} headers=${session_headers} + ${files}= Create Dictionary document=${CURDIR}${/}data.json + ${multipart}= Create Multipart Data files=${files} + + ${resp}= POST On Session bearer_session /anything data=${multipart} + + Should Be Equal ${resp.json()}[headers][Authorization] Bearer some-token + Should Be Equal ${resp.json()}[headers][Content-Type] ${multipart.content_type} + +Test Post Multipart Data Is Sent Whole Again After A 307 Redirect + ${fields}= Create Dictionary action=add + ${files}= Create Dictionary document=${CURDIR}${/}data.json + ${multipart}= Create Multipart Data fields=${fields} files=${files} + + ${resp}= POST On Session ${GLOBAL_SESSION} /redirect-to params=url=/anything&status_code=307 data=${multipart} + + Should Be Equal As Strings ${resp.history[0].status_code} 307 + Should Be Equal ${resp.json()}[form][action] add + ${expected}= Get Binary File ${CURDIR}${/}data.json + Should Be Equal ${resp.json()}[files][document] ${expected.decode('utf-8')} + +Test Post Multipart Data With Files Described By Dictionaries + ${file}= Create Dictionary name=document path=${CURDIR}${/}data.json + ... filename=renamed.json content_type=application/json + ${files}= Create List ${file} + ${fields}= Create Dictionary count=${3} + ${multipart}= Create Multipart Data fields=${fields} files=${files} + + Should Be Equal ${multipart.fields}[1][1][0] renamed.json + Should Be Equal ${multipart.fields}[1][1][2] application/json + + ${resp}= POST On Session ${GLOBAL_SESSION} /anything data=${multipart} + + Should Be Equal ${resp.json()}[form][count] 3 + ${expected}= Get Binary File ${CURDIR}${/}data.json + Should Be Equal ${resp.json()}[files][document] ${expected.decode('utf-8')} + +Test Create Multipart Data Fails Clearly With Invalid Arguments + Run Keyword And Expect Error ValueError: Multipart fields must be a dictionary or a list of (name, value) pairs* + ... Create Multipart Data fields=${{["not a pair"]}} + Run Keyword And Expect Error ValueError: Multipart files must be (name, path* + ... Create Multipart Data files=${{[("document",)]}} + Run Keyword And Expect Error ValueError: Multipart file needs a name and a path* + ... Create Multipart Data files=${{[{"name": "document"}]}} + ${existing}= Create List document ${CURDIR}${/}data.json + ${missing}= Create List missing ${CURDIR}${/}does_not_exist.bin + Run Keyword And Expect Error FileNotFoundError: *does_not_exist.bin* + ... Create Multipart Data files=${{[$existing, $missing]}} diff --git a/doc/RequestsLibrary.html b/doc/RequestsLibrary.html index bd46f5a..ee2ab60 100644 --- a/doc/RequestsLibrary.html +++ b/doc/RequestsLibrary.html @@ -6,7 +6,7 @@ - +