Skip to content
Draft
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ Bindings to Erlang's built in HTTP client, `httpc`.
```sh
gleam add gleam_httpc@5
```

```gleam
import gleam/http/request
import gleam/http/response
Expand Down Expand Up @@ -37,6 +38,49 @@ pub fn send_request() {
}
```

## Http streaming requests

`httpc` supports `stream:{self, once}` mode, which is a **pull-based** approach for
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's this stream:{self, once} syntax? I don't recognise it

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that's Erlang speak :-) Replaced with `Stream(#(Self, Once))

accepting streamed responses. In this mode, after receiving the `handler_pid`, from the
`StreamStart` message, the caller must explicitly request the next stream message
using `receive_next_stream_message/1`.

```gleam
import gleam/http.{Get}
import gleam/http/request
import gleam/httpc
import gleam/process

/// Receive a streamed response from Postman Echo. The number of
/// stream chunks we receive is 1, as we specfied in the endpoint
///
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove the reference to postman please

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed

pub fn stream_self_once() {
let req =
request.new()
|> request.set_method(Get)
|> request.set_host("postman-echo.com")
|> request.set_path("/stream/1")

// Send the streaming request to the server
let assert Ok(request_id) = httpc.send_stream_request(req)

// Configure the selector
let selector = process.new_selector() |> httpc.select_stream_messages(httpc.raw_stream_mapper())

let assert Ok(httpc.StreamStart(_request_id_, _headers, handler_pid)) =
process.selector_receive(selector, 1000)

let _nil = httpc.receive_next_stream_message(handler_pid)
let assert Ok(httpc.StreamChunk(_request_id_, _binary_part)) =
process.selector_receive(selector, 1000)

let _nil = httpc.receive_next_stream_message(handler_pid)
let assert Ok(httpc.StreamEnd(_request_id_, _headers)) =
process.selector_receive(selector, 1000)
}
}
```

## Use with Erlang/OTP versions older than 26.0

Older versions of HTTPC do not verify TLS connections by default, so with them
Expand Down
8 changes: 4 additions & 4 deletions manifest.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@
# You typically do not need to edit this file

packages = [
{ name = "gleam_erlang", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "7E6A5234F927C4B24F8054AB1E4572206C41F9E6D5C6C02273CB7531E7E5CED0" },
{ name = "gleam_http", version = "4.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "0A62451FC85B98062E0907659D92E6A89F5F3C0FBE4AB8046C99936BF6F91DBC" },
{ name = "gleam_stdlib", version = "0.60.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "621D600BB134BC239CB2537630899817B1A42E60A1D46C5E9F3FAE39F88C800B" },
{ name = "gleeunit", version = "1.5.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "D33B7736CF0766ED3065F64A1EBB351E72B2E8DE39BAFC8ADA0E35E92A6A934F" },
{ name = "gleam_erlang", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "1124AD3AA21143E5AF0FC5CF3D9529F6DB8CA03E43A55711B60B6B7B3874375C" },
{ name = "gleam_http", version = "4.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "FFE29C3832698AC3EF6202922EC534EE19540152D01A7C2D22CB97482E4AF211" },
{ name = "gleam_stdlib", version = "0.63.2", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "962B25C667DA07F4CAB32001F44D3C41C1A89E58E3BBA54F183B482CF6122150" },
{ name = "gleeunit", version = "1.6.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "FDC68A8C492B1E9B429249062CD9BAC9B5538C6FBF584817205D0998C42E1DAC" },
]

[requirements]
Expand Down
222 changes: 219 additions & 3 deletions src/gleam/httpc.gleam
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import gleam/bit_array

import gleam/dynamic.{type Dynamic}
import gleam/erlang/atom
import gleam/erlang/charlist.{type Charlist}
import gleam/erlang/process
import gleam/http.{type Method}
import gleam/http/request.{type Request}
import gleam/http/response.{type Response, Response}
Expand Down Expand Up @@ -38,9 +41,19 @@ type BodyFormat {
Binary
}

type Destination {
Self
}

type Mode {
Once
}

type ErlOption {
BodyFormat(BodyFormat)
SocketOpts(List(SocketOpt))
Sync(Bool)
Stream(#(Destination, Mode))
}

type SocketOpt {
Expand All @@ -59,6 +72,24 @@ type ErlVerifyOption {
VerifyNone
}

pub type HttpSocket
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this used anywhere?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch - removed.


pub type RequestIdentifier

pub type RawStreamMessage {
RawStreamStart(RequestIdentifier, List(#(Charlist, Charlist)), process.Pid)
RawStreamChunk(RequestIdentifier, BitArray)
RawStreamEnd(RequestIdentifier, List(#(Charlist, Charlist)))
RawStreamError(RequestIdentifier, HttpError)
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks like it is an implementation detail and should not be in part of the public interface?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it's an implementation detail. I've refactored the code to make it private, and, as a consequence, I was able to simplify the configuration of the selector that receives stream messages (See pub fn select_stream_messages() -> process.Selector(StreamMessage))

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How come we construct values of this type and then immediately convert to another type? Why not construct that final type and skip this intermediate one?


pub type StreamMessage {
StreamStart(RequestIdentifier, List(#(String, String)), process.Pid)
StreamChunk(RequestIdentifier, BitArray)
StreamEnd(RequestIdentifier, List(#(String, String)))
StreamError(RequestIdentifier, HttpError)
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you document this type and add labels please 🙏

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done


@external(erlang, "httpc", "request")
fn erl_request(
a: Method,
Expand All @@ -81,6 +112,22 @@ fn erl_request_no_body(
Dynamic,
)

@external(erlang, "httpc", "request")
fn erl_stream_request_no_body(
a: Method,
b: #(Charlist, List(#(Charlist, Charlist))),
c: List(ErlHttpOption),
d: List(ErlOption),
) -> Result(RequestIdentifier, Dynamic)

@external(erlang, "httpc", "request")
fn erl_stream_request(
a: Method,
b: #(Charlist, List(#(Charlist, Charlist)), Charlist, BitArray),
c: List(ErlHttpOption),
d: List(ErlOption),
) -> Result(RequestIdentifier, Dynamic)

fn string_header(header: #(Charlist, Charlist)) -> #(String, String) {
let #(k, v) = header
#(charlist.to_string(k), charlist.to_string(v))
Expand All @@ -98,6 +145,124 @@ pub fn send_bits(
|> dispatch_bits(req)
}

/// Send a HTTP stream request of binary data
///
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you finish this documentation please 🙏

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

pub fn dispatch_stream_bits(
config: Configuration,
req: Request(BitArray),
) -> Result(RequestIdentifier, HttpError) {
let erl_url =
req
|> request.to_uri
|> uri.to_string
|> charlist.from_string
let erl_headers = prepare_headers(req.headers)
let erl_http_options = [
Autoredirect(config.follow_redirects),
Timeout(config.timeout),
]
let erl_http_options = case config.verify_tls {
True -> erl_http_options
False -> [Ssl([Verify(VerifyNone)]), ..erl_http_options]
}
let erl_options = [
BodyFormat(Binary),
SocketOpts([Ipfamily(Inet6fb4)]),
Sync(False),
Stream(#(Self, Once)),
]
use request_id <- result.try(
case req.method {
http.Options | http.Head | http.Get -> {
let erl_req = #(erl_url, erl_headers)
erl_stream_request_no_body(
req.method,
erl_req,
erl_http_options,
erl_options,
)
}
_ -> {
let erl_content_type =
req
|> request.get_header("content-type")
|> result.unwrap("application/octet-stream")
|> charlist.from_string
let erl_req = #(erl_url, erl_headers, erl_content_type, req.body)
erl_stream_request(req.method, erl_req, erl_http_options, erl_options)
}
}
|> result.map_error(normalise_error),
)

Ok(request_id)
}

/// Triggers the next asynchronous streaming message to be sent to the calling process
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wrap before 80 columns please 🙏

Could we use a name more like the name in the other library please

/// designated by `pid`.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it to the calling process or the process that the pid is for?

Copy link
Author

@adkelley adkelley Jan 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, this is confusing. Revised to
/// Triggers the next streaming message to be sent to the calling process.
/// Use the stream_pid argument returned by the RawStreamStart message.

///
@external(erlang, "gleam_httpc_ffi", "receive_next_stream_message")
pub fn receive_next_stream_message(id: process.Pid) -> Nil

@external(erlang, "gleam_httpc_ffi", "coerce_stream_message")
fn decode_stream_message(msg: Dynamic) -> RawStreamMessage

/// Configure a selector to receive stream messages
///
/// Note this will receive messages from all processes that sent a HTTP stream request;
/// for example using `send_stream_request`, rather than any specific one.
/// In this case, for finer grained processing, you can filter on the `RequestIdentifier`,
/// which is the first argument in the `StreamMessage` constructor.
/// If you wish to only handle stream messages from one process, then use one
/// process per HTTP stream request.
///
/// ## Example
///
/// ```gleam
/// process.new_selector() |> select_stream_messages(raw_stream_mapper())
/// ```
///
pub fn select_stream_messages(
selector: process.Selector(t),
mapper: fn(RawStreamMessage) -> t,
) -> process.Selector(t) {
let http = atom.create(http.scheme_to_string(http.Http))
let map_stream_message = fn(mapper) {
fn(message) { mapper(decode_stream_message(message)) }
}

selector
|> process.select_record(http, 1, map_stream_message(mapper))
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have this take the selector as an argument, to match all other selecting code. Forcing a new selector means the programmer cannot use this library any time that they need to use an existing selector.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, your mug example (i.e. mug.select_tcp_messages) was very helpful here.


/// Converts a raw stream message into a user-facing `StreamMessage`.
///
/// This mapper is primarily used to transform header values from
/// `List(#(Charlist, Charlist))` into the more idiomatic `List(#(String, String))`,
/// which is easier to work with in Gleam.
///
/// You can use this function as the `mapper` argument to `select_stream_messages/2`,
/// or you can supply your own custom mapper if you need additional transformations.
///
/// ## Example
///
/// ```gleam
/// process.new_selector() |> select_stream_messages(raw_stream_mapper())
/// ```
///
pub fn raw_stream_mapper() -> fn(RawStreamMessage) -> StreamMessage {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks like part of the internal implementation and shouldn't be part of the public API 🙏

fn(msg: RawStreamMessage) {
case msg {
RawStreamChunk(request_id, bin_part) -> StreamChunk(request_id, bin_part)
RawStreamStart(request_id, headers, pid) ->
StreamStart(request_id, list.map(headers, string_header), pid)
RawStreamEnd(request_id, headers) ->
StreamEnd(request_id, list.map(headers, string_header))
RawStreamError(request_id, reason) -> StreamError(request_id, reason)
}
}
}

// TODO: refine error type
/// Send a HTTP request of binary data.
///
Expand Down Expand Up @@ -141,6 +306,7 @@ pub fn dispatch_bits(
)

let #(#(_version, status, _status), headers, resp_body) = response

Ok(Response(status, list.map(headers, string_header), resp_body))
}

Expand Down Expand Up @@ -177,7 +343,6 @@ pub opaque type Configuration {
/// - Redirects are not followed.
/// - The timeout for the response to be received is 30 seconds from when the
/// request is sent.
///
pub fn configure() -> Configuration {
Builder(verify_tls: True, follow_redirects: False, timeout: 30_000)
}
Expand Down Expand Up @@ -209,7 +374,7 @@ pub fn timeout(config: Configuration, timeout: Int) -> Configuration {
Builder(..config, timeout:)
}

/// Send a HTTP request of unicode data.
/// Send a synchronus HTTP request of unicode data.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove this please 🙏

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

///
pub fn dispatch(
config: Configuration,
Expand All @@ -224,7 +389,58 @@ pub fn dispatch(
}
}

// TODO: refine error type
/// Send a HTTP stream request of unicode data using a custom `Configuration`.
///
/// This function supports only the `stream: {self, once}` mode from `httpc`, which is a
/// **pull-based** streaming approach. In this mode, the caller must explicitly request
/// the next stream message using `receive_next_stream_message/1`.
///
/// If the request is successfully dispatched, this function returns a `RequestIdentifier`.
/// This identifier is useful when managing multiple concurrent streaming requests,
/// allowing you to match incoming messages to the originating request.
///
/// Once you've configured a selector to receive stream messages (see `select_stream_messages/1`),
/// the other `StreamMessage` variants will be delivered to the user
///
/// With the exception of timeout errors, all other errors will be delivered via:
/// `StreamError(RequestIdentifier, HttpError)`.
///
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a complex API, so folks will need examples to understand how to use it. Could you add these please 🙏

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've added a thorough example to the README

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I also added a simpler example to the documentation above dispatch_stream_request and send_stream_request functions.

pub fn dispatch_stream_request(
config: Configuration,
request: Request(String),
) -> Result(RequestIdentifier, HttpError) {
let request = request.map(request, bit_array.from_string)
use request_id <- result.try(dispatch_stream_bits(config, request))
Ok(request_id)
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is named as if it works with strings, but the body type returned is a bit array. Maybe we don't need this version and just working always with bit arrays is fine.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's always work with bits and not have a string specialised version, seeing as it is not possible to ensure that the response is a string, as the other string versions do.


/// Sends an HTTP streaming request with a Unicode body using the default `Configuration`.
///
/// This function supports only the `stream: {self, once}` mode from `httpc`, which is a
/// **pull-based** streaming approach. In this mode, after receiving the `handler_pid`, from the
/// `StreamStart` message, the caller must explicitly request the next stream message
/// using `receive_next_stream_message/1`.the caller must explicitly request
///
/// If the request is successfully dispatched, this function returns a `RequestIdentifier`.
/// This identifier is useful when managing multiple concurrent streaming requests,
/// allowing you to match incoming messages to the originating request.
///
/// Once you've configured a selector to receive stream messages (see `select_stream_messages/1`),
/// the other `StreamMessage` variants will be delivered to the user
///
/// With the exception of timeout errors, all other errors will be delivered via:
/// `StreamError(RequestIdentifier, HttpError)`.
///
/// If you want to customize the streaming behavior, use `dispatch_stream_request/2`
/// with a custom `Configuration` instead.
///
pub fn send_stream_request(
req: Request(String),
) -> Result(RequestIdentifier, HttpError) {
configure()
|> dispatch_stream_request(req)
}

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's have just the dispatch function 🙏

Copy link
Author

@adkelley adkelley Nov 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you explain more about this? Are you suggesting that I remove pub fn send_stream_request(req: Request(String)) -> Result(RequestIdentifier, HttpError), leaving dispatch_stream_request whenever streaming?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes please

/// Send a HTTP request of unicode data using the default configuration.
///
/// If you wish to use some other configuration use `dispatch` instead.
Expand Down
19 changes: 18 additions & 1 deletion src/gleam_httpc_ffi.erl
Original file line number Diff line number Diff line change
@@ -1,5 +1,22 @@
-module(gleam_httpc_ffi).
-export([default_user_agent/0, normalise_error/1]).
-export([default_user_agent/0, normalise_error/1, receive_next_stream_message/1, coerce_stream_message/1]).

%%====================================================================
%% Streaming
%%====================================================================
%% Helper: call stream_next with whatever the handler expects
receive_next_stream_message(HandlerPid) when is_pid(HandlerPid) ->
httpc:stream_next(HandlerPid),
nil.

coerce_stream_message({http, {ReqId, stream_start, Headers, Pid}}) -> {raw_stream_start, ReqId, Headers, Pid};
coerce_stream_message({http, {ReqId, stream, BinBodyPart}}) when is_binary(BinBodyPart) -> {raw_stream_chunk, ReqId, BinBodyPart};
coerce_stream_message({http, {ReqId, stream_end, Headers}}) -> {raw_stream_end, ReqId, Headers};
coerce_stream_message({http, {ReqId, {error, Reason}}}) -> {raw_stream_error, ReqId, normalise_error(Reason)}.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wrap these long lines before 80 columns please

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done


%%====================================================================
%% Error normalization
%%====================================================================

normalise_error(Error = {failed_connect, Opts}) ->
Ipv6 = case lists:keyfind(inet6, 1, Opts) of
Expand Down
Loading