Skip to content

Commit 17c7941

Browse files
committed
Merge branch 'master' into dialyzer
2 parents 87bf8f7 + 0ca1e59 commit 17c7941

File tree

17 files changed

+521
-53
lines changed

17 files changed

+521
-53
lines changed

.github/workflows/release.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ jobs:
1616
elixir-version: '1.16.0'
1717
otp-version: '26.2.1'
1818
- name: Restore dependencies cache
19-
uses: actions/cache@v3
19+
uses: actions/cache@v4
2020
with:
2121
path: deps
2222
key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }}

.github/workflows/test.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ jobs:
2626
otp-version: ${{ matrix.otp }}
2727
version-type: strict
2828
- name: Restore dependencies cache
29-
uses: actions/cache@v3
29+
uses: actions/cache@v4
3030
with:
3131
path: deps
3232
key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }}
@@ -64,7 +64,7 @@ jobs:
6464
otp-version: ${{ matrix.otp }}
6565
version-type: strict
6666
- name: Restore dependencies cache
67-
uses: actions/cache@v3
67+
uses: actions/cache@v4
6868
with:
6969
path: deps
7070
key: ${{ runner.os }}-mix-${{ hashFiles('test/lockfiles/gun1.lock') }}
@@ -93,7 +93,7 @@ jobs:
9393
otp-version: '24.3'
9494
version-type: strict
9595
- name: Restore dependencies cache
96-
uses: actions/cache@v3
96+
uses: actions/cache@v4
9797
with:
9898
path: deps
9999
key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }}

README.md

Lines changed: 50 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ Tesla is an HTTP client loosely based on [Faraday](https://github.com/lostisland
1212
It embraces the concept of middleware when processing the request/response cycle.
1313

1414
> Note that this README refers to the `master` branch of Tesla, not the latest
15-
released version on Hex. See [the documentation](https://hexdocs.pm/tesla) for
16-
the documentation of the version you're using.
15+
> released version on Hex. See [the documentation](https://hexdocs.pm/tesla) for
16+
> the documentation of the version you're using.
1717
1818
For the list of changes, checkout the latest [release notes](https://github.com/teamon/tesla/releases).
1919

@@ -61,13 +61,13 @@ Add `:tesla` as dependency in `mix.exs`:
6161
```elixir
6262
defp deps do
6363
[
64-
{:tesla, "~> 1.4"},
64+
{:tesla, "~> 1.9"},
6565

6666
# optional, but recommended adapter
67-
{:hackney, "~> 1.17"},
67+
{:hackney, "~> 1.20"},
6868

6969
# optional, required by JSON middleware
70-
{:jason, ">= 1.0.0"}
70+
{:jason, "~> 1.4"}
7171
]
7272
end
7373
```
@@ -83,8 +83,8 @@ config :tesla, adapter: Tesla.Adapter.Hackney
8383
```
8484

8585
> The default adapter is erlang's built-in `httpc`, but it is not recommended
86-
to use it in production environment as it does not validate SSL certificates
87-
[among other issues](https://github.com/teamon/tesla/issues?utf8=%E2%9C%93&q=is%3Aissue+label%3Ahttpc+).
86+
> to use it in production environment as it does not validate SSL certificates
87+
> [among other issues](https://github.com/teamon/tesla/issues?utf8=%E2%9C%93&q=is%3Aissue+label%3Ahttpc+).
8888
8989
## Documentation
9090

@@ -198,8 +198,8 @@ When using adapter other than `:httpc` remember to add it to the dependencies li
198198
```elixir
199199
defp deps do
200200
[
201-
{:tesla, "~> 1.4.0"},
202-
{:hackney, "~> 1.10"} # when using hackney adapter
201+
{:tesla, "~> 1.9"},
202+
{:hackney, "~> 1.20"} # when using hackney adapter
203203
]
204204
end
205205
```
@@ -243,7 +243,11 @@ Tesla.get(client, "/", opts: [adapter: [recv_timeout: 30_000]])
243243

244244
## Streaming
245245

246-
If adapter supports it, you can pass a [Stream](https://hexdocs.pm/elixir/main/Stream.html) as body, e.g.:
246+
### Streaming Request Body
247+
248+
If adapter supports it, you can pass a
249+
[Stream](https://hexdocs.pm/elixir/main/Stream.html) as request
250+
body, e.g.:
247251

248252
```elixir
249253
defmodule ElasticSearch do
@@ -259,7 +263,41 @@ defmodule ElasticSearch do
259263
end
260264
```
261265

262-
Each piece of stream will be encoded as JSON and sent as a new line (conforming to JSON stream format).
266+
Each piece of stream will be encoded as JSON and sent as a new line (conforming
267+
to JSON stream format).
268+
269+
### Streaming Response Body
270+
271+
If adapter supports it, you can pass a `response: :stream` option to return
272+
response body as a
273+
[Stream](https://elixir-lang.org/docs/stable/elixir/Stream.html)
274+
275+
```elixir
276+
defmodule OpenAI do
277+
def new(token) do
278+
middleware = [
279+
{Tesla.Middleware.BaseUrl, "https://api.openai.com/v1"},
280+
{Tesla.Middleware.BearerAuth, token: token},
281+
{Tesla.Middleware.JSON, decode_content_types: ["text/event-stream"]},
282+
{Tesla.Middleware.SSE, only: :data}
283+
]
284+
Tesla.client(middleware, {Tesla.Adapter.Finch, name: MyFinch})
285+
end
286+
287+
def completion(client, prompt) do
288+
data = %{
289+
model: "gpt-3.5-turbo",
290+
messages: [%{role: "user", content: prompt}],
291+
stream: true
292+
}
293+
Tesla.post(client, "/chat/completions", data, opts: [adapter: [response: :stream]])
294+
end
295+
end
296+
client = OpenAI.new("<token>")
297+
{:ok, env} = OpenAI.completion(client, "What is the meaning of life?")
298+
env.body
299+
|> Stream.each(fn chunk -> IO.inspect(chunk) end)
300+
```
263301

264302
## Multipart
265303

@@ -476,6 +514,7 @@ use Tesla, except: [:delete, :options]
476514
```elixir
477515
use Tesla, docs: false
478516
```
517+
479518
### Encode only JSON request (do not decode response)
480519

481520
```elixir

lib/tesla/adapter/finch.ex

Lines changed: 72 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -52,37 +52,99 @@ if Code.ensure_loaded?(Finch) do
5252
@behaviour Tesla.Adapter
5353
alias Tesla.Multipart
5454

55+
@defaults [
56+
receive_timeout: 15_000
57+
]
58+
5559
@impl Tesla.Adapter
5660
def call(%Tesla.Env{} = env, opts) do
57-
opts = Tesla.Adapter.opts(env, opts)
61+
opts = Tesla.Adapter.opts(@defaults, env, opts)
5862

5963
name = Keyword.fetch!(opts, :name)
6064
url = Tesla.build_url(env.url, env.query)
6165
req_opts = Keyword.take(opts, [:pool_timeout, :receive_timeout])
66+
req = build(env.method, url, env.headers, env.body)
6267

63-
case request(name, env.method, url, env.headers, env.body, req_opts) do
68+
case request(req, name, req_opts, opts) do
6469
{:ok, %Finch.Response{status: status, headers: headers, body: body}} ->
6570
{:ok, %Tesla.Env{env | status: status, headers: headers, body: body}}
6671

67-
{:error, mint_error} ->
68-
{:error, Exception.message(mint_error)}
72+
{:error, %Mint.TransportError{reason: reason}} ->
73+
{:error, reason}
74+
75+
{:error, reason} ->
76+
{:error, reason}
6977
end
7078
end
7179

72-
defp request(name, method, url, headers, %Multipart{} = mp, opts) do
80+
defp build(method, url, headers, %Multipart{} = mp) do
7381
headers = headers ++ Multipart.headers(mp)
7482
body = Multipart.body(mp) |> Enum.to_list()
7583

76-
request(name, method, url, headers, body, opts)
84+
build(method, url, headers, body)
7785
end
7886

79-
defp request(_name, _method, _url, _headers, %Stream{}, _opts) do
80-
raise "Streaming is not supported by this adapter!"
87+
defp build(method, url, headers, %Stream{} = body_stream) do
88+
build(method, url, headers, {:stream, body_stream})
8189
end
8290

83-
defp request(name, method, url, headers, body, opts) do
91+
defp build(method, url, headers, body_stream_fun) when is_function(body_stream_fun) do
92+
build(method, url, headers, {:stream, body_stream_fun})
93+
end
94+
95+
defp build(method, url, headers, body) do
8496
Finch.build(method, url, headers, body)
85-
|> Finch.request(name, opts)
97+
end
98+
99+
defp request(req, name, req_opts, opts) do
100+
case opts[:response] do
101+
:stream -> stream(req, name, req_opts)
102+
nil -> Finch.request(req, name, req_opts)
103+
other -> raise "Unknown response option: #{inspect(other)}"
104+
end
105+
end
106+
107+
defp stream(req, name, opts) do
108+
owner = self()
109+
ref = make_ref()
110+
111+
fun = fn
112+
{:status, status}, _acc -> status
113+
{:headers, headers}, status -> send(owner, {ref, {:status, status, headers}})
114+
{:data, data}, _acc -> send(owner, {ref, {:data, data}})
115+
end
116+
117+
task =
118+
Task.async(fn ->
119+
case Finch.stream(req, name, nil, fun, opts) do
120+
{:ok, _acc} -> send(owner, {ref, :eof})
121+
{:error, error} -> send(owner, {ref, {:error, error}})
122+
end
123+
end)
124+
125+
receive do
126+
{^ref, {:status, status, headers}} ->
127+
body =
128+
Stream.unfold(nil, fn _ ->
129+
receive do
130+
{^ref, {:data, data}} ->
131+
{data, nil}
132+
133+
{^ref, :eof} ->
134+
Task.await(task)
135+
nil
136+
after
137+
opts[:receive_timeout] ->
138+
Task.shutdown(task, :brutal_kill)
139+
nil
140+
end
141+
end)
142+
143+
{:ok, %Finch.Response{status: status, headers: headers, body: body}}
144+
after
145+
opts[:receive_timeout] ->
146+
{:error, :timeout}
147+
end
86148
end
87149
end
88150
end

lib/tesla/adapter/httpc.ex

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -55,33 +55,33 @@ defmodule Tesla.Adapter.Httpc do
5555
Enum.map(env.headers, fn {k, v} -> {to_charlist(k), to_charlist(v)} end),
5656
content_type,
5757
env.body,
58-
Keyword.split(opts, @http_opts)
58+
opts
5959
)
6060
)
6161
end
6262

6363
# fix for # see https://github.com/teamon/tesla/issues/147
64-
defp request(:delete, url, headers, content_type, nil, {http_opts, opts}) do
65-
request(:delete, url, headers, content_type, "", {http_opts, opts})
64+
defp request(:delete, url, headers, content_type, nil, opts) do
65+
request(:delete, url, headers, content_type, "", opts)
6666
end
6767

68-
defp request(method, url, headers, _content_type, nil, {http_opts, opts}) do
69-
:httpc.request(method, {url, headers}, http_opts, opts)
68+
defp request(method, url, headers, _content_type, nil, opts) do
69+
:httpc.request(method, {url, headers}, http_opts(opts), adapter_opts(opts), profile(opts))
7070
end
7171

7272
# These methods aren't able to contain a content_type and body
73-
defp request(method, url, headers, _content_type, _body, {http_opts, opts})
73+
defp request(method, url, headers, _content_type, _body, opts)
7474
when method in [:get, :options, :head, :trace] do
75-
:httpc.request(method, {url, headers}, http_opts, opts)
75+
:httpc.request(method, {url, headers}, http_opts(opts), adapter_opts(opts), profile(opts))
7676
end
7777

7878
defp request(method, url, headers, _content_type, %Multipart{} = mp, opts) do
7979
headers = headers ++ Multipart.headers(mp)
8080
headers = for {key, value} <- headers, do: {to_charlist(key), to_charlist(value)}
8181

8282
{content_type, headers} =
83-
case List.keytake(headers, 'content-type', 0) do
84-
nil -> {'text/plain', headers}
83+
case List.keytake(headers, ~c"content-type", 0) do
84+
nil -> {~c"text/plain", headers}
8585
{{_, ct}, headers} -> {ct, headers}
8686
end
8787

@@ -100,10 +100,22 @@ defmodule Tesla.Adapter.Httpc do
100100
request(method, url, headers, content_type, body, opts)
101101
end
102102

103-
defp request(method, url, headers, content_type, body, {http_opts, opts}) do
104-
:httpc.request(method, {url, headers, content_type, body}, http_opts, opts)
103+
defp request(method, url, headers, content_type, body, opts) do
104+
:httpc.request(
105+
method,
106+
{url, headers, content_type, body},
107+
http_opts(opts),
108+
adapter_opts(opts),
109+
profile(opts)
110+
)
105111
end
106112

107113
defp handle({:error, {:failed_connect, _}}), do: {:error, :econnrefused}
108114
defp handle(response), do: response
115+
116+
defp http_opts(opts), do: opts |> Keyword.take(@http_opts) |> Keyword.delete(:profile)
117+
118+
defp adapter_opts(opts), do: opts |> Keyword.drop(@http_opts) |> Keyword.delete(:profile)
119+
120+
defp profile(opts), do: opts[:profile] || :default
109121
end

lib/tesla/middleware/json.ex

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,12 +113,18 @@ defmodule Tesla.Middleware.JSON do
113113
end
114114
end
115115

116+
defp decode_body(body, opts) when is_struct(body, Stream) or is_function(body),
117+
do: {:ok, decode_stream(body, opts)}
118+
116119
defp decode_body(body, opts), do: process(body, :decode, opts)
117120

118121
defp decodable?(env, opts), do: decodable_body?(env) && decodable_content_type?(env, opts)
119122

120123
defp decodable_body?(env) do
121-
(is_binary(env.body) && env.body != "") || (is_list(env.body) && env.body != [])
124+
(is_binary(env.body) && env.body != "") ||
125+
(is_list(env.body) && env.body != []) ||
126+
is_function(env.body) ||
127+
is_struct(env.body, Stream)
122128
end
123129

124130
defp decodable_content_type?(env, opts) do
@@ -128,6 +134,15 @@ defmodule Tesla.Middleware.JSON do
128134
end
129135
end
130136

137+
defp decode_stream(body, opts) do
138+
Stream.map(body, fn chunk ->
139+
case decode_body(chunk, opts) do
140+
{:ok, item} -> item
141+
_ -> chunk
142+
end
143+
end)
144+
end
145+
131146
defp content_types(opts),
132147
do: @default_content_types ++ Keyword.get(opts, :decode_content_types, [])
133148

0 commit comments

Comments
 (0)