Skip to content

Commit 5dca805

Browse files
committed
feat: implement Browser Fetch API compatibility with Response properties and methods
BREAKING CHANGE: Response struct now includes Browser Fetch API fields Added Browser Fetch API compatibility (~85% parity): - Response properties: status_text, ok, body_used, redirected, type - Response methods: clone(), arrayBuffer(), blob() - HTTP.StatusText module for status code mapping - HTTP.Blob module for binary data with metadata - Response.new() constructor for consistent field population Breaking changes: - Response struct has 5 new fields requiring pattern match updates - All Response construction now uses Response.new() internally Features: - Full Browser Fetch API Response interface - Clone responses for multiple reads - Read response as ArrayBuffer or Blob - Automatic status text and ok field calculation - 41 comprehensive tests covering all new features Documentation: - Browser Fetch API compatibility section in README - Complete CHANGELOG with migration guide - Documented Elixir-specific differences (immutability) Ref: v0.7.0
1 parent 4e9883e commit 5dca805

File tree

8 files changed

+950
-31
lines changed

8 files changed

+950
-31
lines changed

CHANGELOG.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,46 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.7.0] - 2025-01-XX
9+
10+
### Added - Browser Fetch API Compatibility (~85% API Parity)
11+
- **HTTP.StatusText module** - Maps HTTP status codes to standard status text messages (60+ codes)
12+
- **Response.status_text** - Status message property (e.g., "OK", "Not Found")
13+
- **Response.ok** - Boolean property indicating success (true for 200-299 status codes)
14+
- **Response.body_used** - Tracks body consumption (field exists for API compatibility)
15+
- **Response.redirected** - Indicates if response was redirected
16+
- **Response.type** - Response type (`:basic`, `:cors`, `:error`, `:opaque`)
17+
- **Response.new/1** - Constructor helper that auto-populates Browser API fields
18+
- **Response.clone/1** - Clone response for multiple reads (buffers streaming responses)
19+
- **Response.arrayBuffer/1** - Read body as binary (ArrayBuffer equivalent)
20+
- **Response.array_buffer/1** - Snake_case alias for arrayBuffer
21+
- **HTTP.Blob module** - Blob struct with `data`, `type`, and `size` fields
22+
- **Response.blob/1** - Read body as Blob with metadata (extracts MIME type from headers)
23+
- Comprehensive Browser API compatibility documentation in README
24+
- 41 new tests covering all Browser Fetch API features
25+
26+
### Changed - Breaking Changes for Browser API Compatibility
27+
- **Response struct fields added**: `status_text`, `ok`, `body_used`, `redirected`, `type`
28+
- **Migration**: Update pattern matches to ignore new fields or use variable binding
29+
- Example: `%Response{status: 200} = response` (ignores new fields)
30+
- **Response construction**: All internal Response creation now uses `Response.new/1`
31+
- Ensures consistent Browser API field population
32+
- **Migration**: Use `Response.new/1` instead of direct `%Response{}` for consistency
33+
- **body_used field**: Present for Browser API compatibility but doesn't enforce in Elixir
34+
- Due to Elixir's immutability, multiple reads of the same response value work
35+
- Use `clone/1` for clarity when reading multiple times
36+
37+
### Documentation
38+
- Added "Browser Fetch API Compatibility" section to README with examples
39+
- Documented Elixir-specific differences (immutability, synchronous returns, streams)
40+
- Updated all Response documentation to reflect new Browser API properties
41+
- Added comprehensive examples for `clone/1`, `arrayBuffer/1`, and `blob/1`
42+
43+
### Notes
44+
- This release prioritizes Browser Fetch API compatibility over Elixir-specific patterns
45+
- The `body_used` field exists for API compatibility but cannot prevent multiple reads due to immutability
46+
- All critical Browser Fetch API Response properties and methods are now supported
47+
848
## [0.5.0] - 2025-08-01
949

1050
### Added

README.md

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,57 @@ A modern HTTP client library for Elixir that provides a fetch API similar to web
2121
- **Automatic JSON parsing**: Built-in JSON response handling
2222
- **Zero dependencies**: Uses only Erlang/OTP built-in modules
2323

24+
## Browser Fetch API Compatibility
25+
26+
This library implements the **Browser Fetch API** standard for Elixir with ~85% compatibility. All critical Response properties and methods from the JavaScript Fetch API are supported.
27+
28+
### Response Properties
29+
30+
```elixir
31+
response = HTTP.fetch("https://api.example.com/data") |> HTTP.Promise.await()
32+
33+
# Standard Browser Fetch API properties
34+
response.status # 200
35+
response.status_text # "OK"
36+
response.ok # true (for 200-299 status codes)
37+
response.headers # HTTP.Headers struct
38+
response.body # Response body binary
39+
response.body_used # false (tracks consumption, but doesn't prevent reads in Elixir)
40+
response.redirected # false (true if response was redirected)
41+
response.type # :basic
42+
response.url # URI struct
43+
```
44+
45+
### Response Methods
46+
47+
```elixir
48+
# Read as JSON
49+
{:ok, data} = HTTP.Response.json(response)
50+
51+
# Read as text
52+
text = HTTP.Response.text(response)
53+
54+
# Read as binary (ArrayBuffer equivalent)
55+
binary = HTTP.Response.arrayBuffer(response)
56+
57+
# Read as Blob with metadata
58+
blob = HTTP.Response.blob(response)
59+
IO.puts "Type: #{blob.type}, Size: #{blob.size} bytes"
60+
61+
# Clone for multiple reads
62+
clone = HTTP.Response.clone(response)
63+
json = HTTP.Response.json(response)
64+
text = HTTP.Response.text(clone) # Read clone independently
65+
```
66+
67+
### Elixir-Specific Differences
68+
69+
**Immutability**: Unlike JavaScript, Elixir responses are immutable. The `body_used` field exists for API compatibility but doesn't prevent multiple reads of the same response value. Use `clone/1` for clarity when reading multiple times.
70+
71+
**Synchronous Returns**: Methods like `json()` and `text()` return values directly instead of Promises, following Elixir conventions.
72+
73+
**Stream Handling**: Large responses use Elixir processes for streaming instead of ReadableStream.
74+
2475
## Quick Start
2576

2677
```elixir
@@ -29,8 +80,9 @@ A modern HTTP client library for Elixir that provides a fetch API similar to web
2980
HTTP.fetch("https://jsonplaceholder.typicode.com/posts/1")
3081
|> HTTP.Promise.await()
3182

32-
# Get response data
33-
IO.puts("Status: #{response.status}")
83+
# Use Browser-like API
84+
IO.puts("Status: #{response.status} #{response.status_text}")
85+
IO.puts("Success: #{response.ok}")
3486
text = HTTP.Response.text(response)
3587
{:ok, json} = HTTP.Response.json(response)
3688

lib/http.ex

Lines changed: 20 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -314,13 +314,13 @@ defmodule HTTP do
314314
content_length_int = parse_content_length(content_length)
315315
{:ok, stream_pid} = start_streaming_handler(request_id, start_time, content_length_int)
316316

317-
%Response{
317+
Response.new(
318318
status: status,
319319
headers: response_headers,
320320
body: nil,
321321
url: url,
322322
stream: stream_pid
323-
}
323+
)
324324
else
325325
# Collect body chunks for non-streaming
326326
collect_body_for_stream_start(request_id, url, status, response_headers, <<>>)
@@ -345,13 +345,13 @@ defmodule HTTP do
345345
content_length_int = parse_content_length(content_length)
346346
{:ok, stream_pid} = start_streaming_handler(request_id, start_time, content_length_int)
347347

348-
%Response{
348+
Response.new(
349349
status: 200,
350350
headers: response_headers,
351351
body: nil,
352352
url: url,
353353
stream: stream_pid
354-
}
354+
)
355355
else
356356
# Small response or complete body - collect remaining
357357
collect_stream_body(request_id, url, 200, response_headers, body)
@@ -364,13 +364,13 @@ defmodule HTTP do
364364
|> Enum.map(fn {key, val} -> {to_string(key), to_string(val)} end)
365365
|> HTTP.Headers.new()
366366

367-
%Response{
367+
Response.new(
368368
status: 200,
369369
headers: response_headers,
370370
body: <<>>,
371371
url: url,
372372
stream: nil
373-
}
373+
)
374374

375375
_other ->
376376
throw(:unexpected_streaming_message)
@@ -477,13 +477,13 @@ defmodule HTTP do
477477
content_length_int = parse_content_length(content_length)
478478
{:ok, stream_pid} = start_streaming_handler(request_id, start_time, content_length_int)
479479

480-
%Response{
480+
Response.new(
481481
status: status,
482482
headers: response_headers,
483483
body: nil,
484484
url: url,
485485
stream: stream_pid
486-
}
486+
)
487487
else
488488
# Buffer the response
489489
collect_body_chunks(request_id, url, status, response_headers, <<>>)
@@ -507,23 +507,23 @@ defmodule HTTP do
507507
collect_stream_body(request_id, url, status, headers, body_acc <> chunk)
508508

509509
{:http, {^request_id, :stream_end}} ->
510-
%Response{
510+
Response.new(
511511
status: status,
512512
headers: headers,
513513
body: body_acc,
514514
url: url,
515515
stream: nil
516-
}
516+
)
517517

518518
{:http, {^request_id, :stream_end, _headers}} ->
519519
# stream_end can include headers, but we already have them
520-
%Response{
520+
Response.new(
521521
status: status,
522522
headers: headers,
523523
body: body_acc,
524524
url: url,
525525
stream: nil
526-
}
526+
)
527527

528528
{:http, {^request_id, {:http_error, reason}}} ->
529529
throw(reason)
@@ -544,13 +544,13 @@ defmodule HTTP do
544544

545545
{:http, {^request_id, :stream_end}} ->
546546
# End of stream
547-
%Response{
547+
Response.new(
548548
status: status,
549549
headers: headers,
550550
body: body_acc,
551551
url: url,
552552
stream: nil
553-
}
553+
)
554554

555555
{:http, {^request_id, {:http_error, reason}}} ->
556556
throw(reason)
@@ -573,23 +573,23 @@ defmodule HTTP do
573573
# Complete body received
574574
final_body = body_acc <> body
575575

576-
%Response{
576+
Response.new(
577577
status: status,
578578
headers: headers,
579579
body: final_body,
580580
url: url,
581581
stream: nil
582-
}
582+
)
583583

584584
{:http, {^request_id, :stream_end}} ->
585585
# End of chunked transfer
586-
%Response{
586+
Response.new(
587587
status: status,
588588
headers: headers,
589589
body: body_acc,
590590
url: url,
591591
stream: nil
592-
}
592+
)
593593

594594
{:http, {^request_id, {:http_error, reason}}} ->
595595
throw(reason)
@@ -683,13 +683,13 @@ defmodule HTTP do
683683
body
684684
end
685685

686-
%Response{
686+
Response.new(
687687
status: status,
688688
headers: response_headers,
689689
body: binary_body,
690690
url: url,
691691
stream: nil
692-
}
692+
)
693693

694694
{:error, reason} ->
695695
throw(reason)

lib/http/blob.ex

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
defmodule HTTP.Blob do
2+
@moduledoc """
3+
Represents a blob of binary data, similar to JavaScript's Blob.
4+
5+
A Blob contains raw data along with metadata about its MIME type and size.
6+
This module implements the Browser Fetch API Blob interface for Elixir.
7+
8+
## Examples
9+
10+
# Create a blob
11+
blob = HTTP.Blob.new(<<1, 2, 3, 4>>, "application/octet-stream")
12+
13+
# Access properties
14+
HTTP.Blob.size(blob) # 4
15+
HTTP.Blob.type(blob) # "application/octet-stream"
16+
17+
# Convert to binary
18+
data = HTTP.Blob.to_binary(blob)
19+
"""
20+
21+
defstruct data: <<>>,
22+
type: "application/octet-stream",
23+
size: 0
24+
25+
@type t :: %__MODULE__{
26+
data: binary(),
27+
type: String.t(),
28+
size: non_neg_integer()
29+
}
30+
31+
@doc """
32+
Creates a new Blob from binary data with a specified MIME type.
33+
34+
## Parameters
35+
- `data` - Binary data to store in the blob
36+
- `type` - MIME type string (default: "application/octet-stream")
37+
38+
## Examples
39+
40+
iex> blob = HTTP.Blob.new(<<1, 2, 3>>, "image/png")
41+
iex> blob.type
42+
"image/png"
43+
iex> blob.size
44+
3
45+
"""
46+
@spec new(binary(), String.t()) :: t()
47+
def new(data, type \\ "application/octet-stream") when is_binary(data) do
48+
%__MODULE__{
49+
data: data,
50+
type: type,
51+
size: byte_size(data)
52+
}
53+
end
54+
55+
@doc """
56+
Converts the Blob to a binary, extracting the raw data.
57+
58+
## Examples
59+
60+
iex> blob = HTTP.Blob.new(<<1, 2, 3>>, "application/octet-stream")
61+
iex> HTTP.Blob.to_binary(blob)
62+
<<1, 2, 3>>
63+
"""
64+
@spec to_binary(t()) :: binary()
65+
def to_binary(%__MODULE__{data: data}), do: data
66+
67+
@doc """
68+
Returns the Blob's MIME type.
69+
70+
## Examples
71+
72+
iex> blob = HTTP.Blob.new(<<>>, "text/plain")
73+
iex> HTTP.Blob.type(blob)
74+
"text/plain"
75+
"""
76+
@spec type(t()) :: String.t()
77+
def type(%__MODULE__{type: type}), do: type
78+
79+
@doc """
80+
Returns the Blob's size in bytes.
81+
82+
## Examples
83+
84+
iex> blob = HTTP.Blob.new(<<1, 2, 3, 4, 5>>, "application/octet-stream")
85+
iex> HTTP.Blob.size(blob)
86+
5
87+
"""
88+
@spec size(t()) :: non_neg_integer()
89+
def size(%__MODULE__{size: size}), do: size
90+
end

0 commit comments

Comments
 (0)