Skip to content

Commit 12d98f9

Browse files
hhaenselhhaenselclaude
authored
Add support for multipart/mixed content type (#1291)
* Add support for multipart/mixed content type This commit adds comprehensive support for multipart/mixed content, commonly used for batch API requests (e.g., SharePoint, GraphQL). Core functionality: - Added `type` field to Form struct to support :formdata and :mixed - Form constructor accepts type parameter (defaults to :formdata) - Form(Vector{Multipart}) constructor for multipart/mixed - Updated content_type() to return correct MIME type based on form type Parsing enhancements: - Modified parsing to optionally skip Content-Disposition headers (not required for multipart/mixed per RFC2046) - Added parse_multipart() as generic parser for any multipart type - Added parse_multipart_mixed() convenience function - Support for type filtering in parsing functions API additions: - Batch() convenience function for semantic batch request creation - Exported: parse_multipart, parse_multipart_mixed, Batch Testing: - Comprehensive test suite in test/http_multipart_mixed.jl - 41 tests covering creation, parsing, round-trip, and edge cases The implementation is adapted from the original work in commit 573fb62 but updated to work with HTTP v2 architecture where multipart functionality is consolidated in http_forms.jl. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * add multipart/mixed docs * add more tests to improve code coverage * add more multipart tests * replace `@assert` by if block, fix writemultipartheader(io, ...) for different types * adapt tests to the replacement of `@assert` * declare `HTTP.Batch` public * apply standard pattern for public declaration of `Batch` * Add Response support to multipart parsing functions Extends parse_multipart_form(), parse_multipart(), and parse_multipart_mixed() to accept Union{Request,Response}, enabling parsing of multipart responses from external APIs (e.g., SharePoint batch operations, GraphQL batch queries). Changes: - Refactor to use Union{Request,Response} pattern (matches http_core.jl style) - Unify body extraction into _message_body_bytes() helper - Add comprehensive test coverage for Response parsing - Include batch server example demonstrating multipart/mixed usage - Update docstrings with jldoctest examples - Fully backward compatible with existing Request API All tests pass. * fix public declaration of `Batch` --------- Co-authored-by: hhaensel <helmut.haensel@gmx.com> Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 72612ad commit 12d98f9

6 files changed

Lines changed: 1052 additions & 55 deletions

File tree

docs/src/api/core.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,8 +77,11 @@ HTTP.setcookies!
7777
HTTP.addcookie!
7878
HTTP.Form
7979
HTTP.Multipart
80+
HTTP.Batch
8081
HTTP.content_type
8182
HTTP.parse_multipart_form
83+
HTTP.parse_multipart
84+
HTTP.parse_multipart_mixed
8285
```
8386

8487
## Proxy Configuration

examples/batch_server_example.jl

Lines changed: 298 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
1+
"""
2+
# Multipart/Mixed Batch Request Example
3+
4+
This example demonstrates a simple batch API server and client using multipart/mixed
5+
content type. The server accepts multiple HTTP requests in a single batch and returns
6+
multiple responses.
7+
8+
## Running the Example
9+
10+
```julia
11+
include("examples/batch_server_example.jl")
12+
```
13+
14+
This will:
15+
1. Start a local batch server on port 8081
16+
2. Send a batch request with 3 sub-requests
17+
3. Parse the batch response
18+
4. Display the results
19+
5. Shutdown the server
20+
"""
21+
22+
using HTTP
23+
24+
# Simple in-memory data store for the example
25+
const DATA_STORE = Dict{String, Any}(
26+
"1" => Dict("id" => "1", "name" => "Alice", "role" => "Developer"),
27+
"2" => Dict("id" => "2", "name" => "Bob", "role" => "Designer"),
28+
"3" => Dict("id" => "3", "name" => "Charlie", "role" => "Manager"),
29+
)
30+
31+
"""
32+
Parse a raw HTTP request string into method, path, headers, and body
33+
"""
34+
function parse_http_request(request_str::String)
35+
lines = split(request_str, "\r\n")
36+
37+
# Parse request line (e.g., "GET /api/users/1 HTTP/1.1")
38+
request_line = split(lines[1], " ")
39+
method = request_line[1]
40+
path = request_line[2]
41+
42+
# Find headers and body separator
43+
separator_idx = findfirst(==(""), lines)
44+
45+
# Parse headers
46+
headers = HTTP.Headers()
47+
if separator_idx !== nothing
48+
for line in lines[2:separator_idx-1]
49+
if occursin(":", line)
50+
key, value = split(line, ":", limit=2)
51+
push!(headers, strip(key) => strip(value))
52+
end
53+
end
54+
55+
# Parse body
56+
body = join(lines[separator_idx+1:end], "\r\n")
57+
else
58+
body = ""
59+
end
60+
61+
return method, path, headers, body
62+
end
63+
64+
"""
65+
Handle individual API requests
66+
"""
67+
function handle_api_request(method::AbstractString, path::AbstractString, body::AbstractString)
68+
# Extract user ID from path like /api/users/1
69+
user_id_match = match(r"/api/users/(\d+)", path)
70+
71+
if user_id_match === nothing
72+
return HTTP.Response(404, "Not Found")
73+
end
74+
75+
user_id = user_id_match.captures[1]
76+
77+
if method == "GET"
78+
if haskey(DATA_STORE, user_id)
79+
# Simple JSON serialization
80+
user = DATA_STORE[user_id]
81+
json_str = "{\"id\":\"$(user["id"])\",\"name\":\"$(user["name"])\",\"role\":\"$(user["role"])\"}"
82+
return HTTP.Response(200,
83+
["Content-Type" => "application/json"],
84+
json_str)
85+
else
86+
return HTTP.Response(404, "User not found")
87+
end
88+
elseif method == "DELETE"
89+
if haskey(DATA_STORE, user_id)
90+
delete!(DATA_STORE, user_id)
91+
return HTTP.Response(204, "")
92+
else
93+
return HTTP.Response(404, "User not found")
94+
end
95+
else
96+
return HTTP.Response(405, "Method Not Allowed")
97+
end
98+
end
99+
100+
"""
101+
Batch request handler - accepts multipart/mixed batch requests
102+
"""
103+
function batch_handler(req::HTTP.Request)
104+
105+
# Check if this is a batch request
106+
content_type = HTTP.header(req, "Content-Type", "")
107+
108+
if !startswith(content_type, "multipart/mixed")
109+
return HTTP.Response(400, "Expected multipart/mixed content type")
110+
end
111+
112+
# Manually read the request body if it's not buffered
113+
# In HTTP 2.0 server, bodies default to EmptyBody for streaming
114+
req = if req.body isa HTTP.EmptyBody || req.body isa HTTP.AbstractBody
115+
HTTP._buffer_server_request(req)
116+
else
117+
req
118+
end
119+
120+
# Parse the batch request
121+
parts = HTTP.parse_multipart_mixed(req)
122+
123+
if parts === nothing
124+
return HTTP.Response(400, "Failed to parse batch request")
125+
end
126+
127+
println("Received batch request with $(length(parts)) parts\n")
128+
129+
# Process each sub-request
130+
response_parts = HTTP.Multipart[]
131+
132+
try
133+
for (i, part) in enumerate(parts)
134+
local response # Declare response in loop scope
135+
try
136+
# Read the sub-request
137+
request_str = String(read(part))
138+
println("Processing sub-request $i...")
139+
140+
# Parse the sub-request
141+
method, path, sub_headers, sub_body = parse_http_request(request_str)
142+
143+
# Handle the sub-request
144+
response = handle_api_request(method, path, sub_body)
145+
println("$method $path: $(response.status)")
146+
catch e
147+
println(" ERROR: $e")
148+
# Create an error response
149+
response = HTTP.Response(500, "Internal Server Error: $e")
150+
end
151+
152+
# Format the response as an HTTP message
153+
# Simple status text mapping
154+
status_text = response.status == 200 ? "OK" :
155+
response.status == 204 ? "No Content" :
156+
response.status == 404 ? "Not Found" :
157+
response.status == 405 ? "Method Not Allowed" :
158+
response.status == 500 ? "Internal Server Error" : "Unknown"
159+
response_str = "HTTP/1.1 $(response.status) $status_text\r\n"
160+
for (key, value) in response.headers
161+
response_str *= "$key: $value\r\n"
162+
end
163+
response_str *= "\r\n"
164+
if !isempty(response.body)
165+
response_str *= String(response.body)
166+
end
167+
168+
# Create a multipart for the response
169+
push!(response_parts, HTTP.Multipart(
170+
nothing,
171+
IOBuffer(response_str),
172+
"application/http",
173+
"binary",
174+
""
175+
))
176+
end
177+
178+
# Create the batch response
179+
batch_response = HTTP.Batch(response_parts)
180+
response_body = read(batch_response)
181+
182+
println("\nBatch response created with $(length(response_parts)) parts ($(length(response_body)) bytes)\n")
183+
184+
return HTTP.Response(200,
185+
["Content-Type" => HTTP.content_type(batch_response)],
186+
response_body)
187+
catch e
188+
println("FATAL ERROR: $e")
189+
return HTTP.Response(500, "Server error: $e")
190+
end
191+
end
192+
193+
"""
194+
Start the batch server
195+
"""
196+
function start_batch_server(port=8081)
197+
# Use a simple handler function instead of Router
198+
server = HTTP.serve!(batch_handler, "127.0.0.1", port; reuseaddr=true)
199+
println("Batch server started on http://127.0.0.1:$port")
200+
println("Endpoint: POST http://127.0.0.1:$port/\$batch")
201+
202+
return server
203+
end
204+
205+
"""
206+
Send a batch request to the server
207+
"""
208+
function send_batch_request(url="http://127.0.0.1:8081/\$batch")
209+
println("\n" * "="^60)
210+
println("Sending batch request with 3 sub-requests")
211+
println("="^60)
212+
213+
# Create sub-requests as HTTP messages
214+
subrequest1 = """GET /api/users/1 HTTP/1.1\r
215+
Host: example.com\r
216+
\r
217+
"""
218+
219+
subrequest2 = """GET /api/users/2 HTTP/1.1\r
220+
Host: example.com\r
221+
\r
222+
"""
223+
224+
subrequest3 = """DELETE /api/users/3 HTTP/1.1\r
225+
Host: example.com\r
226+
\r
227+
"""
228+
229+
# Create multipart parts for each sub-request
230+
parts = [
231+
HTTP.Multipart(nothing, IOBuffer(subrequest1), "application/http", "binary", ""),
232+
HTTP.Multipart(nothing, IOBuffer(subrequest2), "application/http", "binary", ""),
233+
HTTP.Multipart(nothing, IOBuffer(subrequest3), "application/http", "binary", ""),
234+
]
235+
236+
# Create the batch request body using Batch()
237+
batch = HTTP.Batch(parts)
238+
batch_content_type = HTTP.content_type(batch) # Get this before reading
239+
batch_body = read(batch) # Read the body into bytes
240+
241+
println("Batch body size: $(length(batch_body)) bytes")
242+
243+
# Send the batch request
244+
response = HTTP.post(url,
245+
["Content-Type" => batch_content_type,
246+
"Content-Length" => string(length(batch_body))],
247+
batch_body)
248+
249+
println("\n" * "="^60)
250+
println("Received batch response: $(response.status)")
251+
println("="^60)
252+
253+
# Parse the batch response
254+
response_parts = HTTP.parse_multipart_mixed(response)
255+
256+
if response_parts === nothing
257+
println("Failed to parse batch response")
258+
return
259+
end
260+
261+
println("\nParsed $(length(response_parts)) responses:\n")
262+
263+
# Display each sub-response
264+
for (i, part) in enumerate(response_parts)
265+
response_str = String(read(part))
266+
println("Sub-response $i:")
267+
println("-" ^ 40)
268+
println(response_str)
269+
println()
270+
end
271+
end
272+
273+
"""
274+
Run the complete example
275+
"""
276+
function run_example()
277+
# Start the server
278+
server = start_batch_server(8081)
279+
280+
# Give the server a moment to start
281+
sleep(1)
282+
283+
try
284+
# Send a batch request
285+
send_batch_request()
286+
finally
287+
# Shutdown the server
288+
println("\n" * "="^60)
289+
println("Shutting down server...")
290+
println("="^60)
291+
close(server)
292+
end
293+
end
294+
295+
# Run the example if this file is executed directly
296+
if abspath(PROGRAM_FILE) == @__FILE__
297+
run_example()
298+
end

0 commit comments

Comments
 (0)