Skip to content

Commit 64045f0

Browse files
committed
feat: Add write_to/2 method to HTTP.Response for file writing
- Add HTTP.Response.write_to/2 method to write response bodies to files - Handle both streaming and non-streaming responses - Create directories automatically if they don't exist - Add comprehensive tests for file writing functionality - Fix streaming threshold for large files The write_to/2 method provides a convenient way to save HTTP responses to files with proper error handling and directory management.
1 parent 33e9f12 commit 64045f0

File tree

3 files changed

+185
-2
lines changed

3 files changed

+185
-2
lines changed

lib/http.ex

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -306,9 +306,9 @@ defmodule HTTP do
306306
end
307307

308308
defp should_use_streaming?(content_length) do
309-
# Stream responses larger than 100KB or when content-length is unknown
309+
# Stream responses larger than 5MB to avoid issues with large files
310310
case Integer.parse(content_length || "") do
311-
{size, _} when size > 100_000 -> true
311+
{size, _} when size > 5_000_000 -> true
312312
# Stream when size is unknown
313313
_ -> content_length == nil
314314
end
@@ -373,6 +373,12 @@ defmodule HTTP do
373373
{:http, {^request_id, {:http_body, body}}} ->
374374
send(caller, {:stream_chunk, self(), to_string(body)})
375375
send(caller, {:stream_end, self()})
376+
377+
{:http, {^request_id, {_status_line, _headers, body}}} ->
378+
# Handle complete response (non-streaming case)
379+
binary_body = if is_list(body), do: IO.iodata_to_binary(body), else: body
380+
send(caller, {:stream_chunk, self(), binary_body})
381+
send(caller, {:stream_end, self()})
376382
after
377383
60_000 ->
378384
send(caller, {:stream_error, self(), :timeout})

lib/http/response.ex

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,4 +152,66 @@ defmodule HTTP.Response do
152152
content_type -> HTTP.Headers.parse_content_type(content_type)
153153
end
154154
end
155+
156+
@doc """
157+
Writes the response body to a file.
158+
159+
For streaming responses, this will read the entire stream and write it to the file.
160+
For non-streaming responses, it will write the existing body directly.
161+
162+
## Parameters
163+
- `response`: The HTTP response to write
164+
- `file_path`: The path to write the file to
165+
166+
## Returns
167+
- `:ok` on success
168+
- `{:error, reason}` on failure
169+
170+
## Examples
171+
iex> response = %HTTP.Response{body: "file content", stream: nil}
172+
iex> HTTP.Response.write_to(response, "/tmp/test.txt")
173+
:ok
174+
"""
175+
@spec write_to(t(), String.t()) :: :ok | {:error, term()}
176+
def write_to(%__MODULE__{} = response, file_path) do
177+
try do
178+
# Ensure the directory exists
179+
file_path
180+
|> Path.dirname()
181+
|> File.mkdir_p!()
182+
183+
case response do
184+
%{body: body, stream: nil} when is_binary(body) or is_list(body) ->
185+
# Non-streaming response
186+
binary_body =
187+
if is_list(body), do: IO.iodata_to_binary(body), else: body
188+
File.write!(file_path, binary_body)
189+
:ok
190+
191+
%{body: _body, stream: stream} when is_pid(stream) ->
192+
# Streaming response - collect and write
193+
write_stream_to_file(response, file_path)
194+
195+
_ ->
196+
# Empty or nil body
197+
File.write!(file_path, "")
198+
:ok
199+
end
200+
rescue
201+
error -> {:error, error}
202+
end
203+
end
204+
205+
defp write_stream_to_file(response, file_path) do
206+
File.open!(file_path, [:write, :binary], fn file ->
207+
case response do
208+
%{body: _body, stream: stream} when is_pid(stream) ->
209+
# For streaming responses, use collect_stream to get all data
210+
body = read_all(response)
211+
IO.binwrite(file, body)
212+
_ ->
213+
:ok
214+
end
215+
end)
216+
end
155217
end

test/http/response_test.exs

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,5 +65,120 @@ defmodule HTTP.ResponseTest do
6565

6666
assert HTTP.Response.content_type(response) == {"application/json", %{"charset" => "utf-8"}}
6767
end
68+
69+
test "fetch and read data by `read_all/1`" do
70+
resp =
71+
HTTP.fetch("https://www.internic.net/domain/root.zone",
72+
headers: [{"user-agent", "Elixir http_fetch 0.4.1"}],
73+
timeout: 30_000,
74+
connect_timeout: 15_000
75+
)
76+
|> HTTP.Promise.await()
77+
78+
assert resp.status == 200
79+
80+
content_length = resp.headers |> HTTP.Headers.get("content-length") |> String.to_integer()
81+
82+
body = resp |> HTTP.Response.read_all()
83+
assert byte_size(body) == content_length
84+
end
85+
end
86+
87+
describe "write_to/2" do
88+
test "write non-streaming response to file" do
89+
temp_path = Path.join(System.tmp_dir!(), "http_response_test.txt")
90+
91+
response = %HTTP.Response{
92+
status: 200,
93+
headers: %HTTP.Headers{},
94+
body: "test content",
95+
url: "http://example.com",
96+
stream: nil
97+
}
98+
99+
assert :ok = HTTP.Response.write_to(response, temp_path)
100+
assert File.read!(temp_path) == "test content"
101+
102+
# Cleanup
103+
File.rm!(temp_path)
104+
end
105+
106+
test "write empty response to file" do
107+
temp_path = Path.join(System.tmp_dir!(), "empty_response_test.txt")
108+
109+
response = %HTTP.Response{
110+
status: 200,
111+
headers: %HTTP.Headers{},
112+
body: nil,
113+
url: "http://example.com",
114+
stream: nil
115+
}
116+
117+
assert :ok = HTTP.Response.write_to(response, temp_path)
118+
assert File.read!(temp_path) == ""
119+
120+
# Cleanup
121+
File.rm!(temp_path)
122+
end
123+
124+
test "write iodata response to file" do
125+
temp_path = Path.join(System.tmp_dir!(), "iodata_response_test.txt")
126+
127+
response = %HTTP.Response{
128+
status: 200,
129+
headers: %HTTP.Headers{},
130+
body: ["hello", " ", "world"],
131+
url: "http://example.com",
132+
stream: nil
133+
}
134+
135+
assert :ok = HTTP.Response.write_to(response, temp_path)
136+
assert File.read!(temp_path) == "hello world"
137+
138+
# Cleanup
139+
File.rm!(temp_path)
140+
end
141+
142+
test "write creates directory if needed" do
143+
temp_dir = Path.join(System.tmp_dir!(), "nested_test_dir")
144+
temp_path = Path.join(temp_dir, "test_file.txt")
145+
146+
response = %HTTP.Response{
147+
status: 200,
148+
headers: %HTTP.Headers{},
149+
body: "nested directory content",
150+
url: "http://example.com",
151+
stream: nil
152+
}
153+
154+
assert :ok = HTTP.Response.write_to(response, temp_path)
155+
assert File.read!(temp_path) == "nested directory content"
156+
157+
# Cleanup
158+
File.rm_rf!(temp_dir)
159+
end
160+
161+
test "write_to with actual HTTP response" do
162+
temp_path = Path.join(System.tmp_dir!(), "actual_response_test.txt")
163+
164+
resp =
165+
HTTP.fetch("https://httpbin.org/json",
166+
headers: [{"user-agent", "Elixir http_fetch 0.4.1"}],
167+
timeout: 30_000
168+
)
169+
|> HTTP.Promise.await()
170+
171+
assert resp.status == 200
172+
assert :ok = HTTP.Response.write_to(resp, temp_path)
173+
174+
# Verify file was written
175+
assert File.exists?(temp_path)
176+
content = File.read!(temp_path)
177+
assert byte_size(content) > 0
178+
assert content =~ "slideshow"
179+
180+
# Cleanup
181+
File.rm!(temp_path)
182+
end
68183
end
69184
end

0 commit comments

Comments
 (0)