├── .editorconfig ├── .formatter.exs ├── .github ├── dependabot.yml ├── release-please-config.json ├── release-please-manifest.json └── workflows │ ├── pr.yml │ ├── publish-docs.yml │ ├── release-please.yml │ ├── release.yml │ └── test.yml ├── .gitignore ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── config └── config.exs ├── coveralls.json ├── guides ├── cheatsheets │ └── general.cheatmd ├── elixir-tesla-logo.png ├── explanations │ ├── 0.client.md │ ├── 1.testing.md │ ├── 2.middleware.md │ └── 3.adapter.md └── howtos │ ├── migrations │ ├── v0-to-v1.md │ └── v1-macro-migration.md │ └── test-using-mox.md ├── lib ├── tesla.ex └── tesla │ ├── adapter.ex │ ├── adapter │ ├── finch.ex │ ├── gun.ex │ ├── hackney.ex │ ├── httpc.ex │ ├── ibrowse.ex │ ├── mint.ex │ └── shared.ex │ ├── builder.ex │ ├── client.ex │ ├── env.ex │ ├── error.ex │ ├── middleware.ex │ ├── middleware │ ├── base_url.ex │ ├── basic_auth.ex │ ├── bearer_auth.ex │ ├── compression.ex │ ├── decode_rels.ex │ ├── digest_auth.ex │ ├── follow_redirects.ex │ ├── form_urlencoded.ex │ ├── fuse.ex │ ├── headers.ex │ ├── json.ex │ ├── json │ │ └── json_adapter.ex │ ├── keep_request.ex │ ├── logger.ex │ ├── message_pack.ex │ ├── method_override.ex │ ├── opts.ex │ ├── path_params.ex │ ├── query.ex │ ├── retry.ex │ ├── sse.ex │ ├── telemetry.ex │ └── timeout.ex │ ├── mock.ex │ ├── multipart.ex │ └── test.ex ├── mix.exs ├── mix.lock └── test ├── lockfiles └── gun1.lock ├── support ├── adapter_case.ex ├── adapter_case │ ├── basic.ex │ ├── multipart.ex │ ├── ssl.ex │ ├── stream_request_body.ex │ └── stream_response_body.ex ├── dialyzer.ex ├── docs.ex ├── mock_client.ex └── test_support.ex ├── tesla ├── adapter │ ├── finch_test.exs │ ├── gun_test.exs │ ├── hackney_test.exs │ ├── httpc_test.exs │ ├── httpc_test │ │ └── profile.ex │ ├── ibrowse_test.exs │ └── mint_test.exs ├── builder_test.exs ├── client_test.exs ├── global_mock_test.exs ├── middleware │ ├── base_url_test.exs │ ├── basic_auth_test.exs │ ├── bearer_auth_test.exs │ ├── compression_test.exs │ ├── decode_rels_test.exs │ ├── digest_auth_test.exs │ ├── follow_redirects_test.exs │ ├── form_urlencoded_test.exs │ ├── fuse_test.exs │ ├── header_test.exs │ ├── json │ │ └── json_adapter_test.exs │ ├── json_test.exs │ ├── keep_request_test.exs │ ├── logger_test.exs │ ├── message_pack_test.exs │ ├── method_override_test.exs │ ├── opts_test.exs │ ├── path_params_test.exs │ ├── query_test.exs │ ├── retry_test.exs │ ├── sse_test.exs │ ├── telemetry_test.exs │ └── timeout_test.exs ├── mock │ ├── global_a_test.exs │ ├── global_b_test.exs │ ├── local_a_test.exs │ └── local_b_test.exs ├── mock_test.exs ├── multipart_test.exs ├── multipart_test_file.sh └── test_test.exs ├── tesla_test.exs └── test_helper.exs /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | max_line_length = 80 11 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" and to export configuration. 2 | export_locals_without_parens = [ 3 | plug: 1, 4 | plug: 2, 5 | adapter: 1, 6 | adapter: 2 7 | ] 8 | 9 | [ 10 | inputs: [ 11 | "lib/**/*.{ex,exs}", 12 | "test/**/*.{ex,exs}", 13 | "mix.exs" 14 | ], 15 | locals_without_parens: export_locals_without_parens, 16 | export: [locals_without_parens: export_locals_without_parens] 17 | ] 18 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: "/" 5 | schedule: 6 | interval: monthly 7 | 8 | - package-ecosystem: mix 9 | directory: "/" 10 | schedule: 11 | interval: monthly 12 | groups: 13 | prod: 14 | dependency-type: production 15 | dev: 16 | dependency-type: development 17 | ignore: 18 | - dependency-name: "ibrowse" 19 | versions: ["4.4.1"] 20 | -------------------------------------------------------------------------------- /.github/release-please-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", 3 | "draft": false, 4 | "draft-pull-request": false, 5 | "packages": { 6 | ".": { 7 | "extra-files": ["README.md"], 8 | "release-type": "elixir" 9 | } 10 | }, 11 | "plugins": [ 12 | { 13 | "type": "sentence-case" 14 | } 15 | ], 16 | "prerelease": false, 17 | "pull-request-header": "An automated release has been created for you.", 18 | "separate-pull-requests": true 19 | } 20 | -------------------------------------------------------------------------------- /.github/release-please-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | ".": "1.13.2" 3 | } 4 | -------------------------------------------------------------------------------- /.github/workflows/pr.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: PR 3 | 4 | on: 5 | pull_request: 6 | types: 7 | - edited 8 | - opened 9 | - reopened 10 | - synchronize 11 | 12 | jobs: 13 | title: 14 | name: Conventional Commits 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4.1.3 18 | - uses: webiny/action-conventional-commits@v1.3.0 19 | -------------------------------------------------------------------------------- /.github/workflows/publish-docs.yml: -------------------------------------------------------------------------------- 1 | name: Hex Publish Docs 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | concurrency: 7 | group: hex-publish-docs 8 | cancel-in-progress: true 9 | 10 | jobs: 11 | publish: 12 | name: Publish 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Verify Branch 16 | if: github.ref != 'refs/heads/master' 17 | run: exit 1 18 | - uses: actions/checkout@v4 19 | - name: Set up Elixir 20 | uses: erlef/setup-beam@v1 21 | with: 22 | elixir-version: "1.17" 23 | otp-version: "27.1" 24 | - name: Restore dependencies cache 25 | uses: actions/cache@v4 26 | with: 27 | path: deps 28 | key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }} 29 | restore-keys: ${{ runner.os }}-mix- 30 | - name: Install dependencies 31 | run: | 32 | mix local.rebar --force 33 | mix local.hex --force 34 | mix deps.get 35 | - name: Run Hex Publish Docs 36 | run: mix hex.publish docs --yes 37 | env: 38 | HEX_API_KEY: ${{ secrets.HEX_API_KEY }} 39 | -------------------------------------------------------------------------------- /.github/workflows/release-please.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | permissions: 9 | contents: write 10 | pull-requests: write 11 | 12 | jobs: 13 | Please: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - id: release 18 | name: Release 19 | uses: googleapis/release-please-action@v4.2.0 20 | with: 21 | config-file: .github/release-please-config.json 22 | manifest-file: .github/release-please-manifest.json 23 | release-type: elixir 24 | target-branch: master 25 | token: ${{ secrets.GH_PAT_RELEASE_PLEASE_ACTION }} 26 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Hexpm Release 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | publish: 9 | name: Publish 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - name: Set up Elixir 14 | uses: erlef/setup-beam@v1 15 | with: 16 | elixir-version: '1.15' 17 | otp-version: '25.3' 18 | - name: Restore dependencies cache 19 | uses: actions/cache@v4 20 | with: 21 | path: deps 22 | key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }} 23 | restore-keys: ${{ runner.os }}-mix- 24 | - name: Install dependencies 25 | run: | 26 | mix local.rebar --force 27 | mix local.hex --force 28 | mix deps.get 29 | - name: Run Hex Publish 30 | run: mix hex.publish --yes 31 | env: 32 | HEX_API_KEY: ${{ secrets.HEX_API_KEY }} 33 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [pull_request] 4 | 5 | jobs: 6 | Test: 7 | runs-on: ubuntu-latest 8 | name: OTP ${{matrix.otp}} / Elixir ${{matrix.elixir}} 9 | continue-on-error: ${{ matrix.experimental }} 10 | strategy: 11 | matrix: 12 | include: 13 | - elixir: '1.15.8' 14 | otp: '25.3.2.12' 15 | experimental: false 16 | lint: false 17 | - elixir: '1.16.3' 18 | otp: '26.2.5' 19 | experimental: false 20 | lint: true 21 | - elixir: '1.17.1' 22 | otp: '27.0' 23 | experimental: false 24 | lint: false 25 | steps: 26 | - uses: actions/checkout@v4 27 | - name: Set up Elixir 28 | uses: erlef/setup-beam@v1 29 | with: 30 | elixir-version: ${{ matrix.elixir }} 31 | otp-version: ${{ matrix.otp }} 32 | version-type: strict 33 | - name: Restore dependencies cache 34 | uses: actions/cache@v4 35 | id: cache 36 | with: 37 | path: deps 38 | key: ${{ runner.os }}-${{ steps.beam.outputs.elixir-version }}-${{ steps.beam.outputs.otp-version }}-mix-${{ hashFiles('**/mix.lock') }} 39 | restore-keys: ${{ runner.os }}-${{ steps.beam.outputs.elixir-version }}-${{ steps.beam.outputs.otp-version }}-mix- 40 | - name: Install Dependencies 41 | if: steps.cache.outputs.cache-hit != 'true' 42 | env: 43 | MIX_ENV: test 44 | run: | 45 | mix local.rebar --force 46 | mix local.hex --force 47 | mix deps.get 48 | - name: Run Tests 49 | run: mix test --trace 50 | - if: ${{ matrix.lint }} 51 | name: Check Format 52 | run: mix format --check-formatted 53 | 54 | # This tests with Gun 1, where as the standard Test job tests Gun 2 55 | Test-gun1: 56 | runs-on: ubuntu-latest 57 | name: Gun 1 58 | steps: 59 | - uses: actions/checkout@v4 60 | - name: Set up Elixir 61 | uses: erlef/setup-beam@v1 62 | with: 63 | elixir-version: '1.15.8' 64 | otp-version: '25.3.2.12' 65 | version-type: strict 66 | - name: Restore dependencies cache 67 | uses: actions/cache@v4 68 | id: cache 69 | with: 70 | path: deps 71 | key: ${{ runner.os }}-${{ steps.beam.outputs.elixir-version }}-${{ steps.beam.outputs.otp-version }}-mix-gun-${{ hashFiles('test/lockfiles/gun1.lock') }} 72 | restore-keys: ${{ runner.os }}-${{ steps.beam.outputs.elixir-version }}-${{ steps.beam.outputs.otp-version }}-mix-gun- 73 | - name: Install Dependencies 74 | env: 75 | MIX_ENV: test 76 | LOCKFILE: gun1 77 | run: | 78 | mix local.rebar --force 79 | mix local.hex --force 80 | mix deps.get 81 | - name: Run Tests 82 | env: 83 | LOCKFILE: gun1 84 | run: mix test test/tesla/adapter/gun_test.exs --trace 85 | 86 | dialyzer: 87 | runs-on: ubuntu-latest 88 | name: Dialyzer 89 | steps: 90 | - uses: actions/checkout@v4 91 | - name: Set up Elixir 92 | uses: erlef/setup-beam@v1 93 | with: 94 | elixir-version: '1.16.3' 95 | otp-version: '26.2.5' 96 | version-type: strict 97 | - name: Restore dependencies cache 98 | uses: actions/cache@v4 99 | id: cache 100 | with: 101 | path: | 102 | deps 103 | _build 104 | dialyzer 105 | key: ${{ runner.os }}-${{ steps.beam.outputs.elixir-version }}-${{ steps.beam.outputs.otp-version }}-dialyzer-${{ hashFiles('**/mix.lock') }} 106 | restore-keys: | 107 | ${{ runner.os }}-${{ steps.beam.outputs.elixir-version }}-${{ steps.beam.outputs.otp-version }}-dialyzer- 108 | - name: Install Dependencies 109 | if: steps.cache.outputs.cache-hit != 'true' 110 | env: 111 | MIX_ENV: test 112 | run: | 113 | mix local.rebar --force 114 | mix local.hex --force 115 | mix deps.get 116 | # Doesn't cache PLTs based on mix.lock hash, as Dialyzer can incrementally update even old ones 117 | # Cache key based on Elixir & Erlang version. 118 | - name: Restore PLT cache 119 | uses: actions/cache@v4 120 | id: plt_cache 121 | with: 122 | key: | 123 | ${{ runner.os }}-${{ steps.beam.outputs.elixir-version }}-${{ steps.beam.outputs.otp-version }}-plt 124 | restore-keys: | 125 | ${{ runner.os }}-${{ steps.beam.outputs.elixir-version }}-${{ steps.beam.outputs.otp-version }}-plt 126 | path: | 127 | priv/plts 128 | # Create PLTs if no cache was found 129 | - name: Create PLTs 130 | if: steps.plt_cache.outputs.cache-hit != 'true' 131 | run: mix dialyzer --plt 132 | - name: Run dialyzer 133 | run: mix dialyzer --format github 134 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # If the VM crashes, it generates a dump, let's ignore it too. 14 | erl_crash.dump 15 | 16 | # Also ignore archive artifacts (built via "mix archive.build"). 17 | *.ez 18 | 19 | # Ignore package tarball (built via "mix hex.build"). 20 | tesla-*.tar 21 | 22 | # Temporary files for e.g. tests 23 | /tmp 24 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [1.14.2](https://github.com/elixir-tesla/tesla/compare/v1.14.1...v1.14.2) (2025-05-02) 4 | 5 | 6 | ### Bug Fixes 7 | 8 | * bring back searching for mocks in ancestors ([#771](https://github.com/elixir-tesla/tesla/issues/771)) ([601e7b6](https://github.com/elixir-tesla/tesla/commit/601e7b69714acf63a6800945f66fa79a21d7d823)) 9 | * fix race condition in Tesla.Mock.agent_set ([8cf7745](https://github.com/elixir-tesla/tesla/commit/8cf7745179088ea96f5b4c7f2f05b2b7046b5677)) 10 | * handle HTTP response trailers when use Finch + stream ([#767](https://github.com/elixir-tesla/tesla/issues/767)) ([727cb0f](https://github.com/elixir-tesla/tesla/commit/727cb0f18369e7d307df5c051b2060c07477586a)) 11 | * move regexes out of module attributes to fix compatibility with OTP 28 ([#763](https://github.com/elixir-tesla/tesla/issues/763)) ([1196bc6](https://github.com/elixir-tesla/tesla/commit/1196bc600e30d0d9e38d72fcc6ccf1863054bb33)) 12 | 13 | ## [1.14.1](https://github.com/elixir-tesla/tesla/compare/v1.14.0...v1.14.1) (2025-02-22) 14 | 15 | 16 | ### Bug Fixes 17 | 18 | * add basic Hackney 1.22 support: {:connect_error, _} ([#754](https://github.com/elixir-tesla/tesla/issues/754)) ([127db9f](https://github.com/elixir-tesla/tesla/commit/127db9f0f4632cf290ce76d61bd1407367676266)), closes [#753](https://github.com/elixir-tesla/tesla/issues/753) 19 | 20 | ## [1.14.0](https://github.com/elixir-tesla/tesla/compare/v1.13.2...v1.14.0) (2025-02-03) 21 | 22 | 23 | ### Features 24 | 25 | * release-please and conventional commit ([#719](https://github.com/elixir-tesla/tesla/issues/719)) ([c9f6a1c](https://github.com/elixir-tesla/tesla/commit/c9f6a1c917d707e849d51a09557b453a8f9f012f)) 26 | * support retry-after header in retry middleware ([#639](https://github.com/elixir-tesla/tesla/issues/639)) ([86ad37d](https://github.com/elixir-tesla/tesla/commit/86ad37dec511ca00047a2640510a4c6c92acf636)) 27 | * Tesla.Middleware.JSON: Add support for Elixir 1.18's JSON module ([#747](https://github.com/elixir-tesla/tesla/issues/747)) ([1413167](https://github.com/elixir-tesla/tesla/commit/1413167f5408585405b8812f307897a6501b693a)) 28 | 29 | 30 | ### Bug Fixes 31 | 32 | * mocks for supervised tasks ([#750](https://github.com/elixir-tesla/tesla/issues/750)) ([2f6b2a6](https://github.com/elixir-tesla/tesla/commit/2f6b2a646c9bff3888b7aa0f4fc4440a2b5c97ee)) 33 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | 1. Fork it (https://github.com/elixir-tesla/tesla/fork) 4 | 2. Create your feature branch (`git checkout -b my-new-feature`) 5 | 3. Commit your changes (`git commit -am 'Add some feature'`) 6 | 4. Push to the branch (`git push origin my-new-feature`) 7 | 5. Create new Pull Request 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015-Present Tymon Tobolski 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :logger, :console, 4 | level: :debug, 5 | format: "$date $time [$level] $metadata$message\n" 6 | 7 | config :httparrot, 8 | http_port: 5080, 9 | https_port: 5443, 10 | ssl: true, 11 | unix_socket: false 12 | 13 | config :sasl, 14 | errlog_type: :error, 15 | sasl_error_logger: false 16 | 17 | config :tesla, MockClient, adapter: Tesla.Mock 18 | 19 | config :tesla, disable_deprecated_builder_warning: true 20 | -------------------------------------------------------------------------------- /coveralls.json: -------------------------------------------------------------------------------- 1 | { 2 | "skip_files": [ 3 | "test/support" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /guides/cheatsheets/general.cheatmd: -------------------------------------------------------------------------------- 1 | # Basic Cheat Sheet 2 | 3 | ## Making Requests 101 4 | 5 | ### Creating a client 6 | 7 | ```elixir 8 | client = Tesla.client([{Tesla.Middleware.BaseUrl, "https://httpbin.org"}]) 9 | Tesla.get(client, "/path") 10 | ``` 11 | 12 | ### All Methods 13 | 14 | ```elixir 15 | Tesla.get("https://httpbin.org/get") 16 | 17 | Tesla.head("https://httpbin.org/anything") 18 | Tesla.options("https://httpbin.org/anything") 19 | Tesla.trace("https://httpbin.org/anything") 20 | 21 | Tesla.post("https://httpbin.org/post", "body") 22 | Tesla.put("https://httpbin.org/put", "body") 23 | Tesla.patch("https://httpbin.org/patch", "body") 24 | Tesla.delete("https://httpbin.org/anything") 25 | ``` 26 | 27 | ### Query Params 28 | 29 | ```elixir 30 | # GET /path?a=hi&b[]=1&b[]=2&b[]=3 31 | Tesla.get("https://httpbin.org/anything", query: [a: "hi", b: [1, 2, 3]]) 32 | ``` 33 | 34 | ### Request Headers 35 | 36 | ```elixir 37 | Tesla.get("https://httpbin.org/headers", headers: [{"x-api-key", "1"}]) 38 | ``` 39 | 40 | ### Client Default Headers 41 | 42 | ```elixir 43 | client = Tesla.client([{Tesla.Middleware.Headers, [{"user-agent", "Tesla"}]}]) 44 | ``` 45 | 46 | ### Multipart 47 | 48 | You can pass a `Tesla.Multipart` struct as the body: 49 | 50 | ```elixir 51 | alias Tesla.Multipart 52 | 53 | mp = 54 | Multipart.new() 55 | |> Multipart.add_content_type_param("charset=utf-8") 56 | |> Multipart.add_field("field1", "foo") 57 | |> Multipart.add_field("field2", "bar", 58 | headers: [{"content-id", "1"}, {"content-type", "text/plain"}] 59 | ) 60 | |> Multipart.add_file("test/tesla/multipart_test_file.sh") 61 | |> Multipart.add_file("test/tesla/multipart_test_file.sh", name: "foobar") 62 | |> Multipart.add_file_content("sample file content", "sample.txt") 63 | 64 | {:ok, response} = Tesla.post("https://httpbin.org/post", mp) 65 | ``` 66 | 67 | ## Streaming 68 | 69 | ### Streaming Request Body 70 | 71 | If adapter supports it, you can pass a [Stream](https://hexdocs.pm/elixir/main/Stream.html) 72 | as request body, e.g.: 73 | 74 | ```elixir 75 | defmodule ElasticSearch do 76 | def index(records_stream) do 77 | stream = Stream.map(records_stream, fn record -> %{index: [some, data]} end) 78 | Tesla.post(client(), "/_bulk", stream) 79 | end 80 | 81 | defp client do 82 | Tesla.client([ 83 | {Tesla.Middleware.BaseUrl, "http://localhost:9200"}, 84 | Tesla.Middleware.JSON 85 | ], {Tesla.Adapter.Finch, name: MyFinch}) 86 | end 87 | end 88 | ``` 89 | 90 | ### Streaming Response Body 91 | 92 | If adapter supports it, you can pass a `response: :stream` option to return 93 | response body as a [Stream](https://hexdocs.pm/elixir/main/Stream.html) 94 | 95 | ```elixir 96 | defmodule OpenAI do 97 | def client(token) do 98 | middleware = [ 99 | {Tesla.Middleware.BaseUrl, "https://api.openai.com/v1"}, 100 | {Tesla.Middleware.BearerAuth, token: token}, 101 | {Tesla.Middleware.JSON, decode_content_types: ["text/event-stream"]}, 102 | {Tesla.Middleware.SSE, only: :data} 103 | ] 104 | Tesla.client(middleware, {Tesla.Adapter.Finch, name: MyFinch}) 105 | end 106 | 107 | def completion(client, prompt) do 108 | data = %{ 109 | model: "gpt-3.5-turbo", 110 | messages: [%{role: "user", content: prompt}], 111 | stream: true 112 | } 113 | Tesla.post(client, "/chat/completions", data, opts: [adapter: [response: :stream]]) 114 | end 115 | end 116 | 117 | client = OpenAI.new("") 118 | {:ok, env} = OpenAI.completion(client, "What is the meaning of life?") 119 | env.body |> Stream.each(fn chunk -> IO.inspect(chunk) end) 120 | ``` 121 | 122 | ## Middleware 123 | 124 | ### Custom middleware 125 | 126 | ```elixir 127 | defmodule Tesla.Middleware.MyCustomMiddleware do 128 | @moduledoc """ 129 | Short description what it does 130 | 131 | Longer description, including e.g. additional dependencies. 132 | 133 | ### Options 134 | 135 | - `:list` - all possible options 136 | - `:with` - their default values 137 | 138 | ### Examples 139 | 140 | client = Tesla.client([{Tesla.Middleware.MyCustomMiddleware, with: value}]) 141 | """ 142 | 143 | @behaviour Tesla.Middleware 144 | 145 | @impl Tesla.Middleware 146 | def call(env, next, options) do 147 | with %Tesla.Env{} = env <- preprocess(env) do 148 | env 149 | |> Tesla.run(next) 150 | |> postprocess() 151 | end 152 | end 153 | 154 | defp preprocess(env) do 155 | env 156 | end 157 | 158 | defp postprocess({:ok, env}) do 159 | {:ok, env} 160 | end 161 | 162 | def postprocess({:error, reason}) do 163 | {:error, reason} 164 | end 165 | end 166 | ``` 167 | 168 | ## Adapter 169 | 170 | ### Custom adapter 171 | 172 | ```elixir 173 | defmodule Tesla.Adapter.MyCustomAdapter do 174 | @behaviour Tesla.Adapter 175 | 176 | @impl Tesla.Adapter 177 | def run(env, opts) do 178 | # do something 179 | end 180 | end 181 | ``` 182 | -------------------------------------------------------------------------------- /guides/elixir-tesla-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elixir-tesla/tesla/1efe6e3fb426950697f4fcd7cda2bf9197ea4477/guides/elixir-tesla-logo.png -------------------------------------------------------------------------------- /guides/explanations/0.client.md: -------------------------------------------------------------------------------- 1 | # Client 2 | 3 | In Tesla, a **client** is an entity that combines middleware and an adapter, 4 | created using `Tesla.client/2`. Middleware components modify or enhance requests 5 | and responses—such as adding headers or handling authentication—while adapters 6 | handle the underlying HTTP communication. For more details, see the sections on 7 | [middleware](./2.middleware.md) and [adapters](./3.adapter.md). 8 | 9 | ## Creating a Client 10 | 11 | A client is created using `Tesla.client/2`, which takes a list of middleware 12 | and an adapter. 13 | 14 | ```elixir 15 | client = Tesla.client([Tesla.Middleware.PathParams, Tesla.Middleware.Logger]) 16 | ``` 17 | 18 | You can then use the client to make requests: 19 | 20 | ```elixir 21 | Tesla.get(client, "/users/123") 22 | ``` 23 | 24 | ### Passing Options to Middleware 25 | 26 | You can pass options to middleware by registering the middleware as a tuple 27 | of two elements, where the first element is the middleware module and the 28 | second is the options. 29 | 30 | ```elixir 31 | client = Tesla.client( 32 | [{Tesla.Middleware.BaseUrl, "https://api.example.com"}] 33 | ) 34 | ``` 35 | 36 | ### Passing Adapter 37 | 38 | By default, the global adapter is used. You can override this by passing an 39 | adapter to the client. 40 | 41 | ```elixir 42 | client = Tesla.client([], Tesla.Adapter.Mint) 43 | ``` 44 | You can also pass options to the adapter. 45 | 46 | ```elixir 47 | client = Tesla.client([], {Tesla.Adapter.Mint, pool: :my_pool}) 48 | ``` 49 | 50 | ## Single-Client (Singleton) Pattern 51 | 52 | A common approach in applications is to encapsulate client creation within a 53 | module or function that sets up standard middleware and adapter configurations. 54 | This results in a single, shared client instance used throughout the 55 | application. For example: 56 | 57 | ```elixir 58 | defmodule MyApp.ServiceName do 59 | defp client do 60 | middleware = [ 61 | {Tesla.Middleware.BaseUrl, "https://api.service.com"}, 62 | {Tesla.Middleware.BearerAuth, token: bearer_token()}, 63 | # Additional middleware... 64 | ] 65 | Tesla.client(middleware, adapter()) 66 | end 67 | 68 | defp adapter do 69 | Keyword.get(config(), :adapter) 70 | end 71 | 72 | defp bearer_token do 73 | Keyword.fetch!(config(), :bearer_token) 74 | end 75 | 76 | defp config do 77 | Application.get_env(:my_app, __MODULE__, []) 78 | end 79 | end 80 | ``` 81 | 82 | In this pattern, the client is constructed internally, and operations use this 83 | singleton client: 84 | 85 | ```elixir 86 | defmodule MyApp.ServiceName do 87 | def operation_name(body) do 88 | url = "/endpoint" 89 | # The client() function is called internally 90 | response = Tesla.post!(client(), url, body) 91 | # Process the response... 92 | end 93 | 94 | defp client do 95 | # Client construction as shown earlier 96 | end 97 | end 98 | ``` 99 | 100 | You can then use the module to make requests without managing the client externally: 101 | 102 | ```elixir 103 | {:ok, response} = MyApp.ServiceName.operation_name(%{key: "value"}) 104 | ``` 105 | 106 | ## Multi-Client Pattern 107 | 108 | In scenarios where different configurations are needed—such as multi-tenancy 109 | applications or interacting with multiple services—you can modify the client 110 | function to accept configuration parameters. This allows for the creation of 111 | multiple clients with varying settings: 112 | 113 | ```elixir 114 | defmodule MyApp.ServiceName do 115 | def operation_name(client, body) do 116 | url = "/endpoint" 117 | # The client is passed as a parameter 118 | response = Tesla.post!(client, url, body) 119 | # Process the response... 120 | end 121 | 122 | def client(opts) do 123 | middleware = [ 124 | {Tesla.Middleware.BaseUrl, opts[:base_url]}, 125 | {Tesla.Middleware.BearerAuth, token: opts[:bearer_token]}, 126 | # Additional middleware... 127 | ] 128 | Tesla.client(middleware, opts[:adapter]) 129 | end 130 | end 131 | ``` 132 | 133 | Now, you can create clients with different configurations: 134 | 135 | ```elixir 136 | client = MyApp.ServiceName.client( 137 | base_url: "https://api.service.com", 138 | bearer_token: "token_value", 139 | adapter: Tesla.Adapter.Hackney 140 | # Additional options... 141 | ) 142 | {:ok, response} = MyApp.ServiceName.operation_name(client, %{key: "value"}) 143 | ``` 144 | 145 | ## Comparing Single-Client and Multi-Client Patterns 146 | 147 | The choice between using a single-client (singleton) or multi-client pattern 148 | depends on your specific needs: 149 | 150 | - **Library Authors**: It's generally advisable to avoid the singleton client 151 | pattern. Hardcoding configurations can limit flexibility and hinder users in 152 | multi-tenant environments. Providing the ability to create clients with custom 153 | configurations makes your library more adaptable and user-friendly. 154 | 155 | - **Application Developers**: For simpler applications, a singleton client might 156 | suffice initially. However, adopting the multi-client approach from the outset 157 | can prevent future refactoring if your application grows or needs change. 158 | 159 | Understanding these patterns helps you design applications and libraries that 160 | are flexible and maintainable, aligning with best practices in software 161 | development. 162 | -------------------------------------------------------------------------------- /guides/explanations/1.testing.md: -------------------------------------------------------------------------------- 1 | # Testing 2 | 3 | There are two primary ways to mock requests in Tesla: 4 | 5 | - Using `Mox` 6 | - Using `Tesla.Mock` (deprecated) 7 | 8 | You can also create a custom mock adapter if needed. For more information about 9 | adapters, refer to the [Adapter Guide](./3.adapter.md) to create your own. 10 | 11 | ## Should I Use `Mox` or `Tesla.Mock`? 12 | 13 | We recommend using `Mox` for mocking requests in tests because it 14 | is well-established in the Elixir community and provides robust features for 15 | concurrent testing. While `Tesla.Mock` offers useful capabilities, it may be 16 | removed in future releases. Consider using `Mox` to ensure long-term 17 | compatibility. 18 | For additional context, see [GitHub Issue #241](https://github.com/elixir-tesla/tesla/issues/241). 19 | 20 | ## References 21 | 22 | - [How-To Test Using Mox](../howtos/test-using-mox.md) 23 | -------------------------------------------------------------------------------- /guides/explanations/2.middleware.md: -------------------------------------------------------------------------------- 1 | # Middleware 2 | 3 | TL;DR: `adapter(middleware3(middleware2(middleware1(env, next, options))))` 4 | 5 | Middleware in `Tesla` extends the request/response pipeline. Requests pass 6 | through a stack of middleware before reaching the adapter, allowing 7 | modifications to both requests and responses. 8 | 9 | Middleware can be a module implementing `Tesla.Middleware` 10 | behaviour or a function matching `c:Tesla.Middleware.call/3`. There is no 11 | distinction between request and response middleware; it's about when you 12 | execute `Tesla.run/2`. 13 | 14 | The middleware stack is processed by calling `Tesla.run/2` until it reaches 15 | the adapter. 16 | 17 | ## Writing Middleware 18 | 19 | Example of a custom middleware module: 20 | 21 | ```elixir 22 | defmodule Tesla.Middleware.MyCustomMiddleware do 23 | @behaviour Tesla.Middleware 24 | 25 | @impl Tesla.Middleware 26 | def call(env, next, options) do 27 | # Actions before calling the next middleware 28 | # ... 29 | Tesla.run(env, next) 30 | # Actions after calling the next middleware 31 | # ... 32 | end 33 | end 34 | ``` 35 | 36 | A request logger middleware example: 37 | 38 | ```elixir 39 | defmodule MyApp.Tesla.Middleware.Logger do 40 | require Logger 41 | 42 | @behaviour Tesla.Middleware 43 | 44 | def call(env, next, _) do 45 | Logger.info("Request: #{inspect(env)}") 46 | case Tesla.run(env, next) do 47 | {:ok, env} -> 48 | Logger.info("Response: #{inspect(env)}") 49 | {:ok, env} 50 | 51 | {:error, reason} -> 52 | Logger.error("Error: #{inspect(reason)}") 53 | {:error, reason} 54 | end 55 | end 56 | end 57 | ``` 58 | 59 | ## Production-Ready Middleware Pipeline Example 60 | 61 | In a production application, you might combine built-in and custom middleware. 62 | Here's an example pipeline: 63 | 64 | ```elixir 65 | defmodule MyApp.ServiceName do 66 | defp middleware do 67 | base_url = "..." 68 | token = "..." 69 | 70 | [ 71 | # Preserve the original request, should be the first middleware in the 72 | # pipeline to preserve the original request. 73 | Tesla.Middleware.KeepRequest, 74 | 75 | # Set the base URL 76 | {Tesla.Middleware.BaseUrl, base_url}, 77 | 78 | # Add headers 79 | {Tesla.Middleware.Headers, [{"user-agent", "MyApp/1.0"}]}, 80 | # Add authorization 81 | {Tesla.Middleware.BearerAuth, [token: token]}, 82 | # Process the body (encoding, compression) 83 | # Use string keys for untrusted input 84 | Tesla.Middleware.JSON, 85 | # Compress the request and response 86 | # Tesla.Middleware.Compression, 87 | 88 | # Optional OpenTelemetry middleware. Be careful adding `Tesla.Middleware.PathParams` 89 | # before this middleware since you want to have low cardinality attribute 90 | # values, therefore, you want the URL template. 91 | # Tesla.Middleware.OpenTelemetry 92 | 93 | # Keep the telemetry and logging as close as possible to the actual request 94 | # being made. 95 | # Log requests and responses 96 | Tesla.Middleware.Logger, 97 | # Telemetry of the Request 98 | Tesla.Middleware.Telemetry, 99 | 100 | # Replaces the Path Params, you may want keep it at the end when telemetry 101 | # packages prefer to work with the URL template. 102 | Tesla.Middleware.PathParams, 103 | ] 104 | end 105 | end 106 | ``` 107 | 108 | See [built-in middlewares](https://github.com/elixir-tesla/tesla/tree/master/lib/tesla/middleware) 109 | for more examples. 110 | -------------------------------------------------------------------------------- /guides/explanations/3.adapter.md: -------------------------------------------------------------------------------- 1 | # Adapter 2 | 3 | An adapter in Tesla implements the `Tesla.Adapter` behaviour and handles the 4 | actual HTTP communication. It's the final step in the middleware chain, 5 | responsible for sending requests and receiving responses. 6 | 7 | ## Writing an Adapter 8 | 9 | You can create a custom adapter by implementing the `Tesla.Adapter` behaviour. 10 | Here's an example: 11 | 12 | ```elixir 13 | defmodule Tesla.Adapter.Req do 14 | @behaviour Tesla.Adapter 15 | 16 | @impl Tesla.Adapter 17 | def run(env, _opts) do 18 | req = Req.new( 19 | url: Tesla.build_url(env), 20 | method: env.method, 21 | headers: env.headers, 22 | body: env.body 23 | ) 24 | 25 | case Req.request(req) do 26 | {:ok, %Req.Response{} = resp} -> 27 | {:ok, %Tesla.Env{env | status: resp.status, headers: resp.headers, body: resp.body}} 28 | 29 | {:error, reason} -> 30 | {:error, reason} 31 | end 32 | end 33 | end 34 | ``` 35 | 36 | ## Setting the Adapter 37 | 38 | If you don't specify an adapter when creating a client with `Tesla.client/2`, 39 | `Tesla` uses the adapter configured in the `:tesla` application environment. 40 | By default, Tesla uses `Tesla.Adapter.Httpc`, which relies on Erlang's built-in 41 | `httpc`. 42 | 43 | > #### :httpc as default Adapter {: .error} 44 | > The default `httpc` adapter is not recommended for production because it 45 | > doesn't validate SSL certificates and has other issues. Consider using `Mint`, 46 | > `Finch`, or `Hackney` adapters instead. 47 | 48 | ## Adapter Options 49 | 50 | You can pass options to adapters in several ways: 51 | 52 | - In the application configuration: 53 | 54 | ```elixir 55 | config :tesla, adapter: {Tesla.Adapter.Hackney, [recv_timeout: 30_000]} 56 | ``` 57 | 58 | - When creating a client: 59 | 60 | ```elixir 61 | defmodule MyService do 62 | def client(...) do 63 | middleware = [...] 64 | adapter = {Tesla.Adapter.Hackney, [recv_timeout: 30_000]} 65 | Tesla.client(middleware, adapter) 66 | end 67 | end 68 | ``` 69 | 70 | - Directly in request functions: 71 | 72 | ```elixir 73 | Tesla.get(client, "/", opts: [adapter: [recv_timeout: 30_000]]) 74 | ``` 75 | 76 | ## About :httpc adapter and security issues 77 | 78 | [People have complained about `:httpc` adapter in `Tesla` due to 79 | its security issues. The main problem is that `:httpc` does not validate SSL 80 | certificates by default][0]. Which, we believe, is a serious security issue and 81 | should be addressed by `:httpc` itself. 82 | 83 | As much as we would like to fix it, we can't, because we are unsure if it would 84 | break existing code. We are not planning to fix it in `Tesla` due to backward 85 | compatibility. We may reconsider this decision for a version 2.0. 86 | 87 | [0]: https://github.com/elixir-tesla/tesla/issues/293 88 | -------------------------------------------------------------------------------- /guides/howtos/migrations/v0-to-v1.md: -------------------------------------------------------------------------------- 1 | # Migrate from v0 to v1 2 | 3 | This is a list of all breaking changes. 4 | 5 | Version `1.0` has been released, try it today! 6 | 7 | ```elixir 8 | defp deps do 9 | [ 10 | {:tesla, "1.0.0"} 11 | ] 12 | end 13 | ``` 14 | 15 | Any other breaking change not on this list is considered a bug - in you find one please create a new issue. 16 | 17 | ## Returning Tuple Result from HTTP Functions 18 | 19 | `get(..)`, `post(..)`, etc. now return `{:ok, Tesla.Env} | {:error, reason}` ([#177](https://github.com/elixir-tesla/tesla/issues/177)) 20 | 21 | In `0.x` all http functions returned either `Tesla.Env` or raised an error. 22 | In `1.0` these functions return ok/error tuples. The old behaviour can be achieved with the new `! (bang)` functions: `get!(...)`, `post!(...)`, etc. 23 | 24 | ```elixir 25 | case MyApi.get("/") do 26 | {:ok, %Tesla.Env{status: 200}} -> # ok response 27 | {:ok, %Tesla.Env{status: 500}} -> # server error 28 | {:error, reason} -> # connection & other errors 29 | end 30 | ``` 31 | 32 | ## Dropped aliases support ([#159](https://github.com/elixir-tesla/tesla/issues/159)) 33 | 34 | Use full module name for middleware and adapters. 35 | 36 | ```diff 37 | # middleware 38 | - plug :json 39 | + plug Tesla.Middleware.JSON 40 | 41 | # adapter 42 | - adapter :hackney 43 | + adapter Tesla.Adapter.Hackney 44 | 45 | # config 46 | - config :tesla, adapter: :mock 47 | + config :tesla, adapter: Tesla.Mock 48 | ``` 49 | 50 | ## Dropped local middleware/adapter functions ([#171](https://github.com/elixir-tesla/tesla/issues/171)) 51 | 52 | Extract functionality into separate module. 53 | 54 | ```diff 55 | defmodule MyClient do 56 | - plug :some_local_fun 57 | - 58 | - def some_local_fun(env, next) do 59 | # implementation 60 | - end 61 | end 62 | 63 | +defmodule ProperlyNamedMiddleware do 64 | + @behaviour Tesla.Middleware 65 | + def call(env, next, _opts) do 66 | # implementation 67 | + end 68 | +end 69 | 70 | defmodule MyClient do 71 | + plug ProperlyNamedMiddleware 72 | end 73 | ``` 74 | 75 | ## Dropped client as function ([#176](https://github.com/elixir-tesla/tesla/issues/176)) 76 | 77 | This is very unlikely, but... if you hacked around with custom functions as client (the first argument) you need to stop. 78 | See `Tesla.client/2` instead. 79 | 80 | ## Headers are now a list ([#160](https://github.com/elixir-tesla/tesla/issues/160)) 81 | 82 | In `0.x` `env.headers` are a `map(binary => binary)`. 83 | 84 | In `1.x` `env.headers` are a `[{binary, binary}]`. 85 | 86 | This change also applies to middleware headers. 87 | 88 | #### Setting a header 89 | 90 | ```diff 91 | - env 92 | - |> Map.update!(&Map.put(&1.headers, "name", "value")) 93 | 94 | + env 95 | + |> Tesla.put_header("name", "value") 96 | ``` 97 | 98 | #### Getting a header 99 | ```diff 100 | - env.headers["cookie"] 101 | + Tesla.get_header(env, "cookie") # => "secret" 102 | + Tesla.get_headers(env, "cookie") # => ["secret", "token", "and more"] 103 | 104 | 105 | - case env.headers do 106 | - %{"server" => server} -> ... 107 | - _ -> ... 108 | - end 109 | + case Tesla.get_header(env, "server") do 110 | + nil -> ... 111 | + server -> 112 | + end 113 | ``` 114 | 115 | There are five new functions to deal with headers: 116 | - `Tesla.get_header(env, name) :: binary | nil` - Get first header with given `name` 117 | - `Tesla.get_headers(env, name)` :: [binary] - Get all headers values with given `name` 118 | - `Tesla.put_header(env, name, value)` - Set header with given `name` and `value`. Existing header with the same name will be overwritten. 119 | - `Tesla.put_headers(env, list)` - Add headers to the end of `env.headers`. Does **not** make the headers unique. 120 | - `Tesla.delete_header(env, name)` - Delete all headers with given `name` 121 | 122 | ## Dropped support for Elixir 1.3 ([#164](https://github.com/elixir-tesla/tesla/issues/164)) 123 | Tesla `1.0` works only with Elixir 1.4 or newer 124 | 125 | ## Adapter options need to be wrapped in `:adapter` key: 126 | 127 | ```diff 128 | - MyClient.get("/", opts: [recv_timeout: 30_000]) 129 | + MyClient.get("/", opts: [adapter: [recv_timeout: 30_000]]) 130 | ``` 131 | 132 | 133 | ## DebugLogger merged into Logger ([#150](https://github.com/elixir-tesla/tesla/issues/150)) 134 | 135 | Debugging request and response details has been merged into a single Logger middleware. See `Tesla.Middleware.Logger` documentation for more information. 136 | 137 | ```diff 138 | defmodule MyClient do 139 | use Tesla 140 | 141 | plug Tesla.Middleware.Logger 142 | - plug Tesla.Middleware.DebugLogger 143 | end 144 | ``` 145 | 146 | ## Jason is the new default JSON library ([#175](https://github.com/elixir-tesla/tesla/issues/175)) 147 | 148 | The `Tesla.Middleware.JSON` now requires [jason](https://github.com/michalmuskala/jason) by default. If you want to keep using poison you will have to set `:engine` option - see [documentation](https://hexdocs.pm/tesla/Tesla.Middleware.JSON.html#module-example-usage) for details. 149 | -------------------------------------------------------------------------------- /guides/howtos/migrations/v1-macro-migration.md: -------------------------------------------------------------------------------- 1 | # Migrate away from v1 Macro 2 | 3 | We encourage users to contribute to this guide to help others migrate away from 4 | the `v1` macro syntax. Every case is different, so we can't provide a 5 | one-size-fits-all solution, but we can provide a guide to help you migrate your 6 | codebase. 7 | Please share your learnings and suggestions in the [Migrating away from v1 Macro GitHub Discussion](https://github.com/elixir-tesla/tesla/discussions/732). 8 | 9 | 1. Find all the modules that use `use Tesla` 10 | 11 | ```elixir 12 | defmodule MyApp.MyTeslaClient do 13 | use Tesla # <- this line 14 | end 15 | ``` 16 | 17 | 2. Remove `use Tesla` 18 | 19 | ```diff 20 | - defmodule MyApp.MyTeslaClient do 21 | - use Tesla 22 | - end 23 | + defmodule MyApp.MyTeslaClient do 24 | + end 25 | ``` 26 | 27 | 3. Find all the `plug` macro calls: 28 | 29 | ```elixir 30 | defmodule MyApp.MyTeslaClient do 31 | plug Tesla.Middleware.KeepRequest # <- this line 32 | plug Tesla.Middleware.PathParams # <- this line 33 | plug Tesla.Middleware.JSON # <- this line 34 | end 35 | ``` 36 | 37 | 4. Move all the `plug` macro calls to a function that returns the middleware. 38 | 39 | ```diff 40 | defmodule MyApp.MyTeslaClient do 41 | - plug Tesla.Middleware.KeepRequest 42 | - plug Tesla.Middleware.PathParams 43 | - plug Tesla.Middleware.JSON 44 | + 45 | + def middleware do 46 | + [Tesla.Middleware.KeepRequest, Tesla.Middleware.PathParams, Tesla.Middleware.JSON] 47 | + end 48 | end 49 | ``` 50 | 51 | 5. Find all the `adapter` macro calls: 52 | 53 | ```elixir 54 | defmodule MyApp.MyTeslaClient do 55 | adapter Tesla.Adapter.Hackney # <- this line 56 | adapter fn env -> # <- or this line 57 | end 58 | end 59 | ``` 60 | 61 | 6. Create a `adapter/0` function that returns the adapter to use for that given 62 | module, or however you prefer to configure the adapter used. Please refer to 63 | the [Adapter Explanation](../explanations/3.adapter.md) documentation for more 64 | information. 65 | 66 | > #### Context Matters {: .warning} 67 | > 68 | > **This step is probably the most important one.** The context in which the 69 | > adapter is used matters a lot. Please be careful with this step, and test 70 | > your changes thoroughly. 71 | 72 | ```diff 73 | defmodule MyApp.MyTeslaClient do 74 | - adapter Tesla.Adapter.Hackney 75 | + defp adapter do 76 | + # if the value is `nil`, the default global Tesla adapter will be used 77 | + # which is the existing behavior. 78 | + :my_app 79 | + |> Application.get_env(__MODULE__, []) 80 | + |> Keyword.get(:adapter) 81 | + end 82 | end 83 | ``` 84 | 85 | 7. Create a `client/0` function that returns a `Tesla.Client` struct with the 86 | middleware and adapter. Please refer to the [Client Explanation](../explanations/0.client.md) 87 | documentation for more information. 88 | 89 | ```elixir 90 | defmodule MyApp.MyTeslaClient do 91 | def client do 92 | Tesla.client(middleware(), adapter()) 93 | end 94 | 95 | defp middleware do 96 | [Tesla.Middleware.KeepRequest, Tesla.Middleware.PathParams, Tesla.Middleware.JSON] 97 | end 98 | 99 | defp adapter do 100 | :my_app 101 | |> Application.get_env(__MODULE__, []) 102 | |> Keyword.get(:adapter) 103 | end 104 | end 105 | ``` 106 | 107 | 8. Replace all the `Tesla.get/2`, `Tesla.post/2`, etc. to receive the client 108 | as an argument. 109 | 110 | ```diff 111 | defmodule MyApp.MyTeslaClient do 112 | def do_something do 113 | - get("/endpoint") 114 | + Tesla.get!(client(), "/endpoint") 115 | end 116 | end 117 | ``` 118 | -------------------------------------------------------------------------------- /guides/howtos/test-using-mox.md: -------------------------------------------------------------------------------- 1 | # Test Using Mox 2 | 3 | To mock HTTP requests in your tests using Mox with the Tesla HTTP client, 4 | follow these steps: 5 | 6 | ## 1. Define a Mock Adapter 7 | 8 | First, define a mock adapter that implements the Tesla.Adapter behaviour. This 9 | adapter will intercept HTTP requests during testing. 10 | 11 | Create a file at `test/support/mocks.ex`: 12 | 13 | ```elixir 14 | # test/support/mocks.ex 15 | Mox.defmock(MyApp.MockAdapter, for: Tesla.Adapter) 16 | ``` 17 | 18 | ## 2. Configure the Mock Adapter for Tests 19 | 20 | In your `config/test.exs` file, configure Tesla to use the mock adapter you 21 | just defined: 22 | 23 | ```elixir 24 | # config/test.exs 25 | config :tesla, adapter: MyApp.MockAdapter 26 | ``` 27 | 28 | If you are not using the global adapter configuration, ensure that your Tesla 29 | client modules are configured to use `MyApp.MockAdapter` during tests. 30 | 31 | ## 3. Set Up Mocking in Your Tests 32 | 33 | Create a test module, for example `test/demo_test.exs`, and set up `Mox` to 34 | define expectations and verify them: 35 | 36 | ```elixir 37 | defmodule MyApp.FeatureTest do 38 | use ExUnit.Case, async: true 39 | 40 | import Mox 41 | import Tesla.Test 42 | 43 | setup :set_mox_from_context 44 | setup :verify_on_exit! 45 | 46 | test "example test" do 47 | #--------- Given - Stubs and Preconditions 48 | # Expect a single HTTP request to be made and return a JSON response 49 | expect_tesla_call( 50 | times: 1, 51 | returns: json(%Tesla.Env{status: 200}, %{id: 1}) 52 | ) 53 | 54 | #--------- When - Run the code under test 55 | # Make the HTTP request using Tesla 56 | # Mimic a use case where we create a user 57 | assert :ok = create_user!(%{username: "johndoe"}) 58 | 59 | #--------- Then - Assert postconditions 60 | # Verify that the HTTP request was made and matches the expected parameters 61 | assert_received_tesla_call(env, []) 62 | assert env.status == 200 63 | assert env.method == :post 64 | assert env.url == "https://acme.com/users" 65 | # ... 66 | 67 | # Or you can verify the entire `t:Tesla.Env.t/0` using something like this: 68 | assert_tesla_env(env, %Tesla.Env{ 69 | method: :post, 70 | url: "https://acme.com/users", 71 | body: %{username: "johndoe"}, 72 | status: 200, 73 | }) 74 | 75 | # Verify that the mailbox is empty, indicating no additional requests were 76 | # made and all messages have been processed 77 | assert_tesla_empty_mailbox() 78 | end 79 | 80 | defp create_user!(body) do 81 | # ... 82 | Tesla.post!("https://acme.com/users", body) 83 | # ... 84 | :ok 85 | end 86 | end 87 | ``` 88 | 89 | ## 4. Run Your Tests 90 | 91 | When you run your tests with `mix test`, all HTTP requests made by Tesla will 92 | be intercepted by `MyApp.MockAdapter`, and responses will be provided based 93 | on your `Mox` expectations. 94 | -------------------------------------------------------------------------------- /lib/tesla/adapter.ex: -------------------------------------------------------------------------------- 1 | defmodule Tesla.Adapter do 2 | @moduledoc """ 3 | The adapter specification. 4 | 5 | Adapter is a module that denormalize request data stored in `Tesla.Env` in order to make 6 | request with lower level http client (e.g. `:httpc` or `:hackney`) and normalize response data 7 | in order to store it back to `Tesla.Env`. It has to implement `c:Tesla.Adapter.call/2`. 8 | 9 | ## Writing custom adapter 10 | 11 | Create a module implementing `c:Tesla.Adapter.call/2`. 12 | 13 | See `c:Tesla.Adapter.call/2` for details. 14 | 15 | ### Examples 16 | 17 | defmodule MyProject.CustomAdapter do 18 | alias Tesla.Multipart 19 | 20 | @behaviour Tesla.Adapter 21 | 22 | @override_defaults [follow_redirect: false] 23 | 24 | @impl Tesla.Adapter 25 | def call(env, opts) do 26 | opts = Tesla.Adapter.opts(@override_defaults, env, opts) 27 | 28 | with {:ok, {status, headers, body}} <- request(env.method, env.body, env.headers, opts) do 29 | {:ok, normalize_response(env, status, headers, body)} 30 | end 31 | end 32 | 33 | defp request(_method, %Stream{}, _headers, _opts) do 34 | {:error, "stream not supported by adapter"} 35 | end 36 | 37 | defp request(_method, %Multipart{}, _headers, _opts) do 38 | {:error, "multipart not supported by adapter"} 39 | end 40 | 41 | defp request(method, body, headers, opts) do 42 | :lower_level_http.request(method, body, denormalize_headers(headers), opts) 43 | end 44 | 45 | defp denormalize_headers(headers), do: ... 46 | defp normalize_response(env, status, headers, body), do: %Tesla.Env{env | ...} 47 | end 48 | 49 | """ 50 | 51 | @typedoc """ 52 | Unstructured data passed to the adapter using `opts[:adapter]`. 53 | """ 54 | @type options :: any() 55 | 56 | @doc """ 57 | Invoked when a request runs. 58 | 59 | ## Arguments 60 | 61 | - `env` - `t:Tesla.Env.t/0` struct that stores request/response data. 62 | - `options` - middleware options provided by user. 63 | """ 64 | @callback call(env :: Tesla.Env.t(), options :: options()) :: Tesla.Env.result() 65 | 66 | @doc """ 67 | Helper function that merges all adapter options. 68 | 69 | ## Arguments 70 | 71 | - `defaults` (optional) - useful to override lower level http client default 72 | configuration. 73 | - `env` - `t:Tesla.Env.t()` 74 | - `opts` - options provided to the adapter from `Tesla.client/2`. 75 | 76 | ## Precedence rules 77 | 78 | The options are merged in the following order of precedence (highest to lowest): 79 | 80 | 1. Options from `env.opts[:adapter]` (highest precedence). 81 | 2. Options provided to the adapter from `Tesla.client/2`. 82 | 3. Default options (lowest precedence). 83 | 84 | This means that options specified in `env.opts[:adapter]` will override any 85 | conflicting options from the other sources, allowing for fine-grained control 86 | on a per-request basis. 87 | """ 88 | @spec opts(Keyword.t(), Tesla.Env.t(), Keyword.t()) :: Keyword.t() 89 | def opts(defaults \\ [], env, opts) do 90 | defaults 91 | |> Keyword.merge(opts || []) 92 | |> Keyword.merge(env.opts[:adapter] || []) 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /lib/tesla/adapter/finch.ex: -------------------------------------------------------------------------------- 1 | if Code.ensure_loaded?(Finch) do 2 | defmodule Tesla.Adapter.Finch do 3 | @moduledoc """ 4 | Adapter for [finch](https://github.com/sneako/finch). 5 | 6 | Remember to add `{:finch, "~> 0.14.0"}` to dependencies. Also, you need to 7 | recompile tesla after adding the `:finch` dependency: 8 | 9 | ```shell 10 | mix deps.clean tesla 11 | mix compile 12 | ``` 13 | 14 | ## Examples 15 | 16 | In order to use Finch, you must start it and provide a `:name`. For example, 17 | in your supervision tree: 18 | 19 | ```elixir 20 | children = [ 21 | {Finch, name: MyFinch} 22 | ] 23 | ``` 24 | 25 | You must provide the same name to this adapter: 26 | 27 | ```elixir 28 | # set globally in config/config.exs 29 | config :tesla, :adapter, {Tesla.Adapter.Finch, name: MyFinch} 30 | 31 | # set per module 32 | defmodule MyClient do 33 | def client do 34 | Tesla.client([], {Tesla.Adapter.Finch, name: MyFinch}) 35 | end 36 | end 37 | ``` 38 | 39 | ## Adapter specific options 40 | 41 | * `:name` - The `:name` provided to Finch (**required**). 42 | 43 | ## [Finch options](https://hexdocs.pm/finch/Finch.html#request/3) 44 | 45 | * `:pool_timeout` - This timeout is applied when a connection is checked 46 | out from the pool. Default value is `5_000`. 47 | 48 | * `:receive_timeout` - The maximum time to wait for a response before 49 | returning an error. Default value is `15_000`. 50 | 51 | """ 52 | @behaviour Tesla.Adapter 53 | alias Tesla.Multipart 54 | 55 | @defaults [ 56 | receive_timeout: 15_000 57 | ] 58 | 59 | @impl Tesla.Adapter 60 | def call(%Tesla.Env{} = env, opts) do 61 | opts = Tesla.Adapter.opts(@defaults, env, opts) 62 | 63 | name = Keyword.fetch!(opts, :name) 64 | url = Tesla.build_url(env) 65 | req_opts = Keyword.take(opts, [:pool_timeout, :receive_timeout]) 66 | req = build(env.method, url, env.headers, env.body) 67 | 68 | case request(req, name, req_opts, opts) do 69 | {:ok, %Finch.Response{status: status, headers: headers, body: body}} -> 70 | {:ok, %Tesla.Env{env | status: status, headers: headers, body: body}} 71 | 72 | {:error, %Mint.TransportError{reason: reason}} -> 73 | {:error, reason} 74 | 75 | {:error, reason} -> 76 | {:error, reason} 77 | end 78 | end 79 | 80 | defp build(method, url, headers, %Multipart{} = mp) do 81 | headers = headers ++ Multipart.headers(mp) 82 | body = Multipart.body(mp) 83 | 84 | build(method, url, headers, body) 85 | end 86 | 87 | defp build(method, url, headers, %Stream{} = body_stream) do 88 | build(method, url, headers, {:stream, body_stream}) 89 | end 90 | 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 96 | Finch.build(method, url, headers, body) 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 | {:trailers, trailers}, _acc -> trailers 116 | end 117 | 118 | task = 119 | Task.async(fn -> 120 | case Finch.stream(req, name, nil, fun, opts) do 121 | {:ok, _acc} -> send(owner, {ref, :eof}) 122 | {:error, error} -> send(owner, {ref, {:error, error}}) 123 | end 124 | end) 125 | 126 | receive do 127 | {^ref, {:status, status, headers}} -> 128 | body = 129 | Stream.unfold(nil, fn _ -> 130 | receive do 131 | {^ref, {:data, data}} -> 132 | {data, nil} 133 | 134 | {^ref, :eof} -> 135 | Task.await(task) 136 | nil 137 | after 138 | opts[:receive_timeout] -> 139 | Task.shutdown(task, :brutal_kill) 140 | nil 141 | end 142 | end) 143 | 144 | {:ok, %Finch.Response{status: status, headers: headers, body: body}} 145 | after 146 | opts[:receive_timeout] -> 147 | {:error, :timeout} 148 | end 149 | end 150 | end 151 | end 152 | -------------------------------------------------------------------------------- /lib/tesla/adapter/hackney.ex: -------------------------------------------------------------------------------- 1 | if Code.ensure_loaded?(:hackney) do 2 | defmodule Tesla.Adapter.Hackney do 3 | @moduledoc """ 4 | Adapter for [hackney](https://github.com/benoitc/hackney). 5 | 6 | Remember to add `{:hackney, "~> 1.13"}` to dependencies (and `:hackney` to applications in `mix.exs`) 7 | Also, you need to recompile tesla after adding `:hackney` dependency: 8 | 9 | ```shell 10 | mix deps.clean tesla 11 | mix deps.compile tesla 12 | ``` 13 | 14 | ## Examples 15 | 16 | ```elixir 17 | # set globally in config/config.exs 18 | config :tesla, :adapter, Tesla.Adapter.Hackney 19 | 20 | # set per module 21 | defmodule MyClient do 22 | def client do 23 | Tesla.client([], Tesla.Adapter.Hackney) 24 | end 25 | end 26 | ``` 27 | 28 | ## Adapter specific options 29 | 30 | - `:max_body` - Max response body size in bytes. Actual response may be bigger because hackney stops after the last chunk that surpasses `:max_body`. 31 | """ 32 | @behaviour Tesla.Adapter 33 | alias Tesla.Multipart 34 | 35 | @impl Tesla.Adapter 36 | def call(env, opts) do 37 | with {:ok, status, headers, body} <- request(env, opts) do 38 | {:ok, %{env | status: status, headers: format_headers(headers), body: format_body(body)}} 39 | end 40 | end 41 | 42 | defp format_headers(headers) do 43 | for {key, value} <- headers do 44 | {String.downcase(to_string(key)), to_string(value)} 45 | end 46 | end 47 | 48 | defp format_body(data) when is_list(data), do: IO.iodata_to_binary(data) 49 | defp format_body(data) when is_binary(data) or is_reference(data), do: data 50 | 51 | defp request(env, opts) do 52 | request( 53 | env.method, 54 | Tesla.build_url(env), 55 | env.headers, 56 | env.body, 57 | Tesla.Adapter.opts(env, opts) 58 | ) 59 | end 60 | 61 | defp request(method, url, headers, %Stream{} = body, opts), 62 | do: request_stream(method, url, headers, body, opts) 63 | 64 | defp request(method, url, headers, body, opts) when is_function(body), 65 | do: request_stream(method, url, headers, body, opts) 66 | 67 | defp request(method, url, headers, %Multipart{} = mp, opts) do 68 | headers = headers ++ Multipart.headers(mp) 69 | body = Multipart.body(mp) 70 | 71 | request(method, url, headers, body, opts) 72 | end 73 | 74 | defp request(method, url, headers, body, opts) do 75 | handle(:hackney.request(method, url, headers, body || ~c"", opts), opts) 76 | end 77 | 78 | defp request_stream(method, url, headers, body, opts) do 79 | with {:ok, ref} <- :hackney.request(method, url, headers, :stream, opts) do 80 | case send_stream(ref, body) do 81 | :ok -> handle(:hackney.start_response(ref), opts) 82 | error -> handle(error, opts) 83 | end 84 | else 85 | e -> handle(e, opts) 86 | end 87 | end 88 | 89 | defp send_stream(ref, body) do 90 | Enum.reduce_while(body, :ok, fn data, _ -> 91 | case :hackney.send_body(ref, data) do 92 | :ok -> {:cont, :ok} 93 | error -> {:halt, error} 94 | end 95 | end) 96 | end 97 | 98 | defp handle({:connect_error, {:error, reason}}, _opts), do: {:error, reason} 99 | defp handle({:error, _} = error, _opts), do: error 100 | defp handle({:ok, status, headers}, _opts), do: {:ok, status, headers, []} 101 | 102 | defp handle({:ok, ref}, _opts) when is_reference(ref) do 103 | handle_async_response({ref, %{status: nil, headers: nil}}) 104 | end 105 | 106 | defp handle({:ok, status, headers, ref}, opts) when is_reference(ref) do 107 | with {:ok, body} <- :hackney.body(ref, Keyword.get(opts, :max_body, :infinity)) do 108 | {:ok, status, headers, body} 109 | end 110 | end 111 | 112 | defp handle({:ok, status, headers, body}, _opts), do: {:ok, status, headers, body} 113 | 114 | defp handle_async_response({ref, %{headers: headers, status: status}}) 115 | when not (is_nil(headers) or is_nil(status)) do 116 | {:ok, status, headers, ref} 117 | end 118 | 119 | defp handle_async_response({ref, output}) do 120 | receive do 121 | {:hackney_response, ^ref, {:status, status, _}} -> 122 | handle_async_response({ref, %{output | status: status}}) 123 | 124 | {:hackney_response, ^ref, {:headers, headers}} -> 125 | handle_async_response({ref, %{output | headers: headers}}) 126 | end 127 | end 128 | end 129 | end 130 | -------------------------------------------------------------------------------- /lib/tesla/adapter/httpc.ex: -------------------------------------------------------------------------------- 1 | defmodule Tesla.Adapter.Httpc do 2 | @moduledoc """ 3 | Adapter for [httpc](http://erlang.org/doc/man/httpc.html). 4 | 5 | This is the default adapter. 6 | 7 | **NOTE** Tesla overrides default autoredirect value with false to ensure 8 | consistency between adapters 9 | """ 10 | 11 | @behaviour Tesla.Adapter 12 | import Tesla.Adapter.Shared, only: [stream_to_fun: 1, next_chunk: 1] 13 | alias Tesla.Multipart 14 | 15 | @override_defaults autoredirect: false 16 | @http_opts ~w(timeout connect_timeout ssl essl autoredirect proxy_auth version relaxed url_encode)a 17 | 18 | @impl Tesla.Adapter 19 | def call(env, opts) do 20 | opts = Tesla.Adapter.opts(@override_defaults, env, opts) 21 | 22 | with {:ok, {status, headers, body}} <- request(env, opts) do 23 | {:ok, format_response(env, status, headers, body)} 24 | end 25 | end 26 | 27 | defp format_response(env, {_, status, _}, headers, body) do 28 | %{env | status: status, headers: format_headers(headers), body: format_body(body)} 29 | end 30 | 31 | # from http://erlang.org/doc/man/httpc.html 32 | # headers() = [header()] 33 | # header() = {field(), value()} 34 | # field() = string() 35 | # value() = string() 36 | defp format_headers(headers) do 37 | for {key, value} <- headers do 38 | {String.downcase(to_string(key)), to_string(value)} 39 | end 40 | end 41 | 42 | # from http://erlang.org/doc/man/httpc.html 43 | # string() = list of ASCII characters 44 | # Body = string() | binary() 45 | defp format_body(data) when is_list(data), do: IO.iodata_to_binary(data) 46 | defp format_body(data) when is_binary(data), do: data 47 | 48 | defp request(env, opts) do 49 | content_type = to_charlist(Tesla.get_header(env, "content-type") || "") 50 | 51 | handle( 52 | request( 53 | env.method, 54 | Tesla.build_url(env) |> to_charlist(), 55 | Enum.map(env.headers, fn {k, v} -> {to_charlist(k), to_charlist(v)} end), 56 | content_type, 57 | env.body, 58 | opts 59 | ) 60 | ) 61 | end 62 | 63 | # fix for # see https://github.com/teamon/tesla/issues/147 64 | defp request(:delete, url, headers, content_type, nil, opts) do 65 | request(:delete, url, headers, content_type, "", opts) 66 | end 67 | 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)) 70 | end 71 | 72 | # These methods aren't able to contain a content_type and body 73 | defp request(method, url, headers, _content_type, _body, opts) 74 | when method in [:get, :options, :head, :trace] do 75 | :httpc.request(method, {url, headers}, http_opts(opts), adapter_opts(opts), profile(opts)) 76 | end 77 | 78 | defp request(method, url, headers, _content_type, %Multipart{} = mp, opts) do 79 | headers = headers ++ Multipart.headers(mp) 80 | headers = for {key, value} <- headers, do: {to_charlist(key), to_charlist(value)} 81 | 82 | {content_type, headers} = 83 | case List.keytake(headers, ~c"content-type", 0) do 84 | nil -> {~c"text/plain", headers} 85 | {{_, ct}, headers} -> {ct, headers} 86 | end 87 | 88 | body = stream_to_fun(Multipart.body(mp)) 89 | 90 | request(method, url, headers, to_charlist(content_type), body, opts) 91 | end 92 | 93 | defp request(method, url, headers, content_type, %Stream{} = body, opts) do 94 | fun = stream_to_fun(body) 95 | request(method, url, headers, content_type, fun, opts) 96 | end 97 | 98 | defp request(method, url, headers, content_type, body, opts) when is_function(body) do 99 | body = {:chunkify, &next_chunk/1, body} 100 | request(method, url, headers, content_type, body, opts) 101 | end 102 | 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 | ) 111 | end 112 | 113 | defp handle({:error, {:failed_connect, _}}), do: {:error, :econnrefused} 114 | 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 121 | end 122 | -------------------------------------------------------------------------------- /lib/tesla/adapter/ibrowse.ex: -------------------------------------------------------------------------------- 1 | if Code.ensure_loaded?(:ibrowse) do 2 | defmodule Tesla.Adapter.Ibrowse do 3 | @moduledoc """ 4 | Adapter for [ibrowse](https://github.com/cmullaparthi/ibrowse). 5 | 6 | Remember to add `{:ibrowse, "~> 4.2"}` to dependencies (and `:ibrowse` to applications in `mix.exs`) 7 | Also, you need to recompile tesla after adding `:ibrowse` dependency: 8 | 9 | ```elixir 10 | mix deps.clean tesla 11 | mix deps.compile tesla 12 | ``` 13 | 14 | ## Examples 15 | 16 | ```elixir 17 | # set globally in config/config.exs 18 | config :tesla, :adapter, Tesla.Adapter.Ibrowse 19 | 20 | # set per module 21 | defmodule MyClient do 22 | def client do 23 | Tesla.client([], Tesla.Adapter.Ibrowse) 24 | end 25 | end 26 | ``` 27 | """ 28 | 29 | @behaviour Tesla.Adapter 30 | import Tesla.Adapter.Shared, only: [stream_to_fun: 1, next_chunk: 1] 31 | alias Tesla.Multipart 32 | 33 | @impl Tesla.Adapter 34 | def call(env, opts) do 35 | with {:ok, status, headers, body} <- request(env, opts) do 36 | {:ok, 37 | %{ 38 | env 39 | | status: format_status(status), 40 | headers: format_headers(headers), 41 | body: format_body(body) 42 | }} 43 | end 44 | end 45 | 46 | defp format_status(status) when is_list(status) do 47 | status |> to_string() |> String.to_integer() 48 | end 49 | 50 | defp format_headers(headers) do 51 | for {key, value} <- headers do 52 | {String.downcase(to_string(key)), to_string(value)} 53 | end 54 | end 55 | 56 | defp format_body(data) when is_list(data), do: IO.iodata_to_binary(data) 57 | defp format_body(data) when is_binary(data), do: data 58 | 59 | defp request(env, opts) do 60 | body = env.body || [] 61 | 62 | handle( 63 | request( 64 | Tesla.build_url(env) |> to_charlist(), 65 | env.headers, 66 | env.method, 67 | body, 68 | Tesla.Adapter.opts(env, opts) 69 | ) 70 | ) 71 | end 72 | 73 | defp request(url, headers, method, %Multipart{} = mp, opts) do 74 | headers = headers ++ Multipart.headers(mp) 75 | body = stream_to_fun(Multipart.body(mp)) 76 | 77 | request(url, headers, method, body, opts) 78 | end 79 | 80 | defp request(url, headers, method, %Stream{} = body, opts) do 81 | fun = stream_to_fun(body) 82 | request(url, headers, method, fun, opts) 83 | end 84 | 85 | defp request(url, headers, method, body, opts) when is_function(body) do 86 | body = {&next_chunk/1, body} 87 | opts = Keyword.put(opts, :transfer_encoding, :chunked) 88 | request(url, headers, method, body, opts) 89 | end 90 | 91 | defp request(url, headers, method, body, opts) do 92 | {timeout, opts} = opts |> Keyword.pop(:timeout, 30_000) 93 | :ibrowse.send_req(url, headers, method, body, opts, timeout) 94 | end 95 | 96 | defp handle({:error, {:conn_failed, error}}), do: error 97 | defp handle(response), do: response 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /lib/tesla/adapter/shared.ex: -------------------------------------------------------------------------------- 1 | defmodule Tesla.Adapter.Shared do 2 | @moduledoc false 3 | 4 | def stream_to_fun(stream) do 5 | reductor = fn item, _acc -> {:suspend, item} end 6 | {_, _, fun} = Enumerable.reduce(stream, {:suspend, nil}, reductor) 7 | 8 | fun 9 | end 10 | 11 | def next_chunk(fun), do: parse_chunk(fun.({:cont, nil})) 12 | 13 | defp parse_chunk({:suspended, item, fun}), do: {:ok, item, fun} 14 | defp parse_chunk(_), do: :eof 15 | 16 | @spec prepare_path(String.t() | nil, String.t() | nil) :: String.t() 17 | def prepare_path(nil, nil), do: "/" 18 | def prepare_path(nil, query), do: "/?" <> query 19 | def prepare_path(path, nil), do: path 20 | def prepare_path(path, query), do: path <> "?" <> query 21 | 22 | @spec format_method(atom()) :: String.t() 23 | def format_method(method), do: to_string(method) |> String.upcase() 24 | end 25 | -------------------------------------------------------------------------------- /lib/tesla/client.ex: -------------------------------------------------------------------------------- 1 | defmodule Tesla.Client do 2 | @type adapter :: module | {module, any} | (Tesla.Env.t() -> Tesla.Env.result()) 3 | @type middleware :: module | {module, any} 4 | 5 | @type t :: %__MODULE__{ 6 | pre: Tesla.Env.stack(), 7 | post: Tesla.Env.stack(), 8 | adapter: Tesla.Env.runtime() | nil 9 | } 10 | defstruct fun: nil, 11 | pre: [], 12 | post: [], 13 | adapter: nil 14 | 15 | @doc ~S""" 16 | Returns the client's adapter in the same form it was provided. 17 | This can be used to copy an adapter from one client to another. 18 | 19 | ## Examples 20 | 21 | iex> client = Tesla.client([], {Tesla.Adapter.Hackney, [recv_timeout: 30_000]}) 22 | iex> Tesla.Client.adapter(client) 23 | {Tesla.Adapter.Hackney, [recv_timeout: 30_000]} 24 | """ 25 | @spec adapter(t) :: adapter 26 | def adapter(client) do 27 | if client.adapter, do: unruntime(client.adapter) 28 | end 29 | 30 | @doc ~S""" 31 | Returns the client's middleware in the same form it was provided. 32 | This can be used to copy middleware from one client to another. 33 | 34 | ## Examples 35 | 36 | iex> middleware = [Tesla.Middleware.JSON, {Tesla.Middleware.BaseUrl, "https://api.github.com"}] 37 | iex> client = Tesla.client(middleware) 38 | iex> Tesla.Client.middleware(client) 39 | [Tesla.Middleware.JSON, {Tesla.Middleware.BaseUrl, "https://api.github.com"}] 40 | """ 41 | @spec middleware(t) :: [middleware] 42 | def middleware(client) do 43 | unruntime(client.pre) 44 | end 45 | 46 | defp unruntime(list) when is_list(list), do: Enum.map(list, &unruntime/1) 47 | defp unruntime({module, :call, [[]]}) when is_atom(module), do: module 48 | defp unruntime({module, :call, [opts]}) when is_atom(module), do: {module, opts} 49 | defp unruntime({:fn, fun}) when is_function(fun), do: fun 50 | end 51 | -------------------------------------------------------------------------------- /lib/tesla/env.ex: -------------------------------------------------------------------------------- 1 | defmodule Tesla.Env do 2 | @moduledoc """ 3 | This module defines a `t:Tesla.Env.t/0` struct that stores all data related to request/response. 4 | 5 | ## Fields 6 | 7 | - `:method` - method of request. Example: `:get` 8 | - `:url` - request url. Example: `"https://www.google.com"` 9 | - `:query` - list of query params. 10 | Example: `[{"param", "value"}]` will be translated to `?params=value`. 11 | Note: query params passed in url (e.g. `"/get?param=value"`) are not parsed to `query` field. 12 | - `:headers` - list of request/response headers. 13 | Example: `[{"content-type", "application/json"}]`. 14 | Note: request headers are overridden by response headers when adapter is called. 15 | - `:body` - request/response body. 16 | Note: request body is overridden by response body when adapter is called. 17 | - `:status` - response status. Example: `200` 18 | - `:opts` - list of options. Example: `[adapter: [recv_timeout: 30_000]]` 19 | """ 20 | 21 | @type client :: Tesla.Client.t() 22 | @type method :: :head | :get | :delete | :trace | :options | :post | :put | :patch 23 | @type url :: binary 24 | @type param :: binary | [{binary | atom, param}] 25 | @type query :: [{binary | atom, param}] 26 | @type headers :: [{binary, binary}] 27 | 28 | @type body :: any 29 | @type status :: integer | nil 30 | @type opts :: keyword 31 | 32 | @type runtime :: {atom, atom, any} | {atom, atom} | {:fn, (t -> t)} | {:fn, (t, stack -> t)} 33 | @type stack :: [runtime] 34 | @type result :: {:ok, t()} | {:error, any} 35 | 36 | @type t :: %__MODULE__{ 37 | method: method, 38 | query: query, 39 | url: url, 40 | headers: headers, 41 | body: body, 42 | status: status, 43 | opts: opts, 44 | __module__: atom, 45 | __client__: client 46 | } 47 | 48 | defstruct method: nil, 49 | url: "", 50 | query: [], 51 | headers: [], 52 | body: nil, 53 | status: nil, 54 | opts: [], 55 | __module__: nil, 56 | __client__: nil 57 | end 58 | -------------------------------------------------------------------------------- /lib/tesla/error.ex: -------------------------------------------------------------------------------- 1 | defmodule Tesla.Error do 2 | defexception env: nil, stack: [], reason: nil 3 | 4 | def message(%Tesla.Error{env: %{url: url, method: method}, reason: reason}) do 5 | "#{inspect(reason)} (#{method |> to_string() |> String.upcase()} #{url})" 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/tesla/middleware.ex: -------------------------------------------------------------------------------- 1 | defmodule Tesla.Middleware do 2 | @moduledoc """ 3 | The middleware specification. 4 | 5 | Middleware is an extension of basic `Tesla` functionality. It is a module that must 6 | implement `c:Tesla.Middleware.call/3`. 7 | 8 | ## Middleware options 9 | 10 | Options can be passed to middleware inside tuple in case of dynamic middleware 11 | (`Tesla.client/1`): 12 | 13 | Tesla.client([{Tesla.Middleware.BaseUrl, "https://example.com"}]) 14 | 15 | ## Ordering 16 | 17 | The order in which middleware is defined matters. Note that the order when _sending_ the request 18 | matches the order the middleware was defined in, but the order when _receiving_ the response 19 | is reversed. 20 | 21 | For example, `Tesla.Middleware.DecompressResponse` must come _after_ `Tesla.Middleware.JSON`, 22 | otherwise the response isn't decompressed before it reaches the JSON parser. 23 | 24 | ## Writing custom middleware 25 | 26 | Writing custom middleware is as simple as creating a module implementing `c:Tesla.Middleware.call/3`. 27 | 28 | See `c:Tesla.Middleware.call/3` for details. 29 | 30 | ### Examples 31 | 32 | defmodule MyProject.InspectHeadersMiddleware do 33 | @behaviour Tesla.Middleware 34 | 35 | @impl true 36 | def call(env, next, _options) do 37 | IO.inspect(env.headers) 38 | 39 | with {:ok, env} <- Tesla.run(env, next) do 40 | IO.inspect(env.headers) 41 | {:ok, env} 42 | end 43 | end 44 | end 45 | """ 46 | 47 | @doc """ 48 | Invoked when a request runs. 49 | 50 | - (optionally) read and/or writes request data 51 | - calls `Tesla.run/2` 52 | - (optionally) read and/or writes response data 53 | 54 | ## Arguments 55 | 56 | - `env` - `Tesla.Env` struct that stores request/response data 57 | - `next` - middlewares that should be called after current one 58 | - `options` - middleware options provided by user 59 | """ 60 | @callback call(env :: Tesla.Env.t(), next :: Tesla.Env.stack(), options :: any) :: 61 | Tesla.Env.result() 62 | end 63 | -------------------------------------------------------------------------------- /lib/tesla/middleware/base_url.ex: -------------------------------------------------------------------------------- 1 | defmodule Tesla.Middleware.BaseUrl do 2 | @moduledoc """ 3 | Set base URL for all requests. 4 | 5 | The base URL will be prepended to request path/URL only 6 | if it does not include http(s). 7 | 8 | ## Examples 9 | 10 | ```elixir 11 | defmodule MyClient do 12 | def client do 13 | Tesla.client([ 14 | {Tesla.Middleware.BaseUrl, "https://example.com/foo"} 15 | ]) 16 | end 17 | end 18 | 19 | client = MyClient.client() 20 | 21 | Tesla.get(client, "/path") 22 | # equals to GET https://example.com/foo/path 23 | 24 | Tesla.get(client, "path") 25 | # equals to GET https://example.com/foo/path 26 | 27 | Tesla.get(client, "") 28 | # equals to GET https://example.com/foo 29 | 30 | Tesla.get(client, "http://example.com/bar") 31 | # equals to GET http://example.com/bar 32 | ``` 33 | """ 34 | 35 | @behaviour Tesla.Middleware 36 | 37 | @impl Tesla.Middleware 38 | def call(env, next, base) do 39 | env 40 | |> apply_base(base) 41 | |> Tesla.run(next) 42 | end 43 | 44 | defp apply_base(env, base) do 45 | if Regex.match?(~r/^https?:\/\//i, env.url) do 46 | # skip if url is already with scheme 47 | env 48 | else 49 | %{env | url: join(base, env.url)} 50 | end 51 | end 52 | 53 | defp join(base, url) do 54 | case {String.last(to_string(base)), url} do 55 | {nil, url} -> url 56 | {"/", "/" <> rest} -> base <> rest 57 | {"/", rest} -> base <> rest 58 | {_, ""} -> base 59 | {_, "/" <> rest} -> base <> "/" <> rest 60 | {_, rest} -> base <> "/" <> rest 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /lib/tesla/middleware/basic_auth.ex: -------------------------------------------------------------------------------- 1 | defmodule Tesla.Middleware.BasicAuth do 2 | @moduledoc """ 3 | Basic authentication middleware. 4 | 5 | [Wiki on the topic](https://en.wikipedia.org/wiki/Basic_access_authentication) 6 | 7 | ## Examples 8 | 9 | ```elixir 10 | defmodule MyClient do 11 | def client(username, password, opts \\ %{}) do 12 | Tesla.client([ 13 | {Tesla.Middleware.BasicAuth, 14 | Map.merge(%{username: username, password: password}, opts)} 15 | ]) 16 | end 17 | end 18 | ``` 19 | 20 | ## Options 21 | 22 | - `:username` - username (defaults to `""`) 23 | - `:password` - password (defaults to `""`) 24 | """ 25 | 26 | @behaviour Tesla.Middleware 27 | 28 | @impl Tesla.Middleware 29 | def call(env, next, opts) do 30 | opts = opts || %{} 31 | 32 | env 33 | |> Tesla.put_headers(authorization_header(opts)) 34 | |> Tesla.run(next) 35 | end 36 | 37 | defp authorization_header(opts) do 38 | opts 39 | |> authorization_vars() 40 | |> encode() 41 | |> create_header() 42 | end 43 | 44 | defp authorization_vars(opts) do 45 | %{ 46 | username: opts[:username] || "", 47 | password: opts[:password] || "" 48 | } 49 | end 50 | 51 | defp create_header(auth) do 52 | [{"authorization", "Basic #{auth}"}] 53 | end 54 | 55 | defp encode(%{username: username, password: password}) do 56 | Base.encode64("#{username}:#{password}") 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/tesla/middleware/bearer_auth.ex: -------------------------------------------------------------------------------- 1 | defmodule Tesla.Middleware.BearerAuth do 2 | @moduledoc """ 3 | Bearer authentication middleware. 4 | 5 | Adds a `{"authorization", "Bearer "}` header. 6 | 7 | ## Examples 8 | 9 | ``` 10 | defmodule MyClient do 11 | def new(token) do 12 | Tesla.client([ 13 | {Tesla.Middleware.BearerAuth, token: token} 14 | ]) 15 | end 16 | end 17 | ``` 18 | 19 | ## Options 20 | 21 | - `:token` - token (defaults to `""`) 22 | """ 23 | 24 | @behaviour Tesla.Middleware 25 | 26 | @impl Tesla.Middleware 27 | def call(env, next, opts \\ []) do 28 | token = Keyword.get(opts, :token, "") 29 | 30 | env 31 | |> Tesla.put_headers([{"authorization", "Bearer #{token}"}]) 32 | |> Tesla.run(next) 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/tesla/middleware/compression.ex: -------------------------------------------------------------------------------- 1 | defmodule Tesla.Middleware.Compression do 2 | @moduledoc """ 3 | Compress requests and decompress responses. 4 | 5 | Supports "gzip" and "deflate" encodings using Erlang's built-in `:zlib` module. 6 | 7 | ## Examples 8 | 9 | ```elixir 10 | defmodule MyClient do 11 | def client do 12 | Tesla.client([ 13 | {Tesla.Middleware.Compression, format: "gzip"} 14 | ]) 15 | end 16 | end 17 | ``` 18 | 19 | ## Options 20 | 21 | - `:format` - request compression format, `"gzip"` (default) or `"deflate"` 22 | """ 23 | 24 | @behaviour Tesla.Middleware 25 | 26 | @impl Tesla.Middleware 27 | def call(env, next, opts) do 28 | env 29 | |> compress(opts) 30 | |> add_accept_encoding() 31 | |> Tesla.run(next) 32 | |> decompress() 33 | end 34 | 35 | @doc false 36 | def add_accept_encoding(env) do 37 | Tesla.put_headers(env, [{"accept-encoding", "gzip, deflate, identity"}]) 38 | end 39 | 40 | defp compressible?(body), do: is_binary(body) 41 | 42 | @doc """ 43 | Compress request. 44 | 45 | It is used by `Tesla.Middleware.CompressRequest`. 46 | """ 47 | def compress(env, opts) do 48 | if compressible?(env.body) do 49 | format = Keyword.get(opts || [], :format, "gzip") 50 | 51 | env 52 | |> Tesla.put_body(compress_body(env.body, format)) 53 | |> Tesla.put_headers([{"content-encoding", format}]) 54 | else 55 | env 56 | end 57 | end 58 | 59 | defp compress_body(body, "gzip"), do: :zlib.gzip(body) 60 | defp compress_body(body, "deflate"), do: :zlib.zip(body) 61 | 62 | @doc """ 63 | Decompress response. 64 | 65 | It is used by `Tesla.Middleware.DecompressResponse`. 66 | """ 67 | def decompress({:ok, env}), do: {:ok, decompress(env)} 68 | def decompress({:error, reason}), do: {:error, reason} 69 | 70 | def decompress(env) do 71 | codecs = compression_algorithms(Tesla.get_header(env, "content-encoding")) 72 | {decompressed_body, unknown_codecs} = decompress_body(codecs, env.body, []) 73 | 74 | env 75 | |> put_decompressed_body(decompressed_body) 76 | |> put_or_delete_content_encoding(unknown_codecs) 77 | end 78 | 79 | defp put_or_delete_content_encoding(env, []) do 80 | Tesla.delete_header(env, "content-encoding") 81 | end 82 | 83 | defp put_or_delete_content_encoding(env, unknown_codecs) do 84 | Tesla.put_header(env, "content-encoding", Enum.join(unknown_codecs, ", ")) 85 | end 86 | 87 | defp decompress_body([gzip | rest], body, acc) when gzip in ["gzip", "x-gzip"] do 88 | decompress_body(rest, :zlib.gunzip(body), acc) 89 | end 90 | 91 | defp decompress_body(["deflate" | rest], body, acc) do 92 | decompress_body(rest, :zlib.unzip(body), acc) 93 | end 94 | 95 | defp decompress_body(["identity" | rest], body, acc) do 96 | decompress_body(rest, body, acc) 97 | end 98 | 99 | defp decompress_body([codec | rest], body, acc) do 100 | decompress_body(rest, body, [codec | acc]) 101 | end 102 | 103 | defp decompress_body([], body, acc) do 104 | {body, acc} 105 | end 106 | 107 | defp compression_algorithms(nil) do 108 | [] 109 | end 110 | 111 | defp compression_algorithms(value) do 112 | value 113 | |> String.downcase() 114 | |> String.split(",", trim: true) 115 | |> Enum.map(&String.trim/1) 116 | |> Enum.reverse() 117 | end 118 | 119 | defp put_decompressed_body(env, body) do 120 | env 121 | |> Tesla.put_body(body) 122 | |> Tesla.delete_header("content-length") 123 | end 124 | end 125 | 126 | defmodule Tesla.Middleware.CompressRequest do 127 | @moduledoc """ 128 | Only compress request. 129 | 130 | See `Tesla.Middleware.Compression` for options. 131 | """ 132 | 133 | @behaviour Tesla.Middleware 134 | 135 | @impl Tesla.Middleware 136 | def call(env, next, opts) do 137 | env 138 | |> Tesla.Middleware.Compression.compress(opts) 139 | |> Tesla.run(next) 140 | end 141 | end 142 | 143 | defmodule Tesla.Middleware.DecompressResponse do 144 | @moduledoc """ 145 | Only decompress response. 146 | 147 | See `Tesla.Middleware.Compression` for options. 148 | """ 149 | 150 | @behaviour Tesla.Middleware 151 | 152 | @impl Tesla.Middleware 153 | def call(env, next, _opts) do 154 | env 155 | |> Tesla.Middleware.Compression.add_accept_encoding() 156 | |> Tesla.run(next) 157 | |> Tesla.Middleware.Compression.decompress() 158 | end 159 | end 160 | -------------------------------------------------------------------------------- /lib/tesla/middleware/decode_rels.ex: -------------------------------------------------------------------------------- 1 | defmodule Tesla.Middleware.DecodeRels do 2 | @moduledoc """ 3 | Decode `Link` Hypermedia HTTP header into `opts[:rels]` field in response. 4 | 5 | ## Examples 6 | 7 | ```elixir 8 | defmodule MyClient do 9 | def client do 10 | Tesla.client([Tesla.Middleware.DecodeRels]) 11 | end 12 | end 13 | 14 | client = MyClient.client() 15 | 16 | env = Tesla.get(client, "/...") 17 | 18 | env.opts[:rels] 19 | # => %{"Next" => "http://...", "Prev" => "..."} 20 | ``` 21 | """ 22 | 23 | @behaviour Tesla.Middleware 24 | 25 | @impl Tesla.Middleware 26 | def call(env, next, _opts) do 27 | env 28 | |> Tesla.run(next) 29 | |> parse_rels 30 | end 31 | 32 | defp parse_rels({:ok, env}), do: {:ok, parse_rels(env)} 33 | defp parse_rels({:error, reason}), do: {:error, reason} 34 | 35 | defp parse_rels(env) do 36 | if link = Tesla.get_header(env, "link") do 37 | Tesla.put_opt(env, :rels, rels(link)) 38 | else 39 | env 40 | end 41 | end 42 | 43 | defp rels(link) do 44 | link 45 | |> String.split(",") 46 | |> Enum.map(&String.trim/1) 47 | |> Enum.map(&rel/1) 48 | |> Enum.into(%{}) 49 | end 50 | 51 | defp rel(item) do 52 | Regex.run(~r/\A<(.+)>; rel=["]?([^"]+)["]?\z/, item, capture: :all_but_first) 53 | |> Enum.reverse() 54 | |> List.to_tuple() 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/tesla/middleware/digest_auth.ex: -------------------------------------------------------------------------------- 1 | defmodule Tesla.Middleware.DigestAuth do 2 | @moduledoc """ 3 | Digest access authentication middleware. 4 | 5 | [Wiki on the topic](https://en.wikipedia.org/wiki/Digest_access_authentication) 6 | 7 | **NOTE**: Currently the implementation is incomplete and works only for MD5 algorithm 8 | and auth "quality of protection" (qop). 9 | 10 | ## Examples 11 | 12 | ``` 13 | defmodule MyClient do 14 | def client(username, password, opts \\ %{}) do 15 | Tesla.client([ 16 | {Tesla.Middleware.DigestAuth, Map.merge(%{username: username, password: password}, opts)} 17 | ]) 18 | end 19 | end 20 | ``` 21 | 22 | ## Options 23 | 24 | - `:username` - username (defaults to `""`) 25 | - `:password` - password (defaults to `""`) 26 | - `:cnonce_fn` - custom function generating client nonce (defaults to `&Tesla.Middleware.DigestAuth.cnonce/0`) 27 | - `:nc` - nonce counter (defaults to `"00000000"`) 28 | """ 29 | 30 | @behaviour Tesla.Middleware 31 | 32 | @impl Tesla.Middleware 33 | def call(env, next, opts) do 34 | if env.opts && Keyword.get(env.opts, :digest_auth_handshake) do 35 | Tesla.run(env, next) 36 | else 37 | opts = opts || %{} 38 | 39 | with {:ok, headers} <- authorization_header(env, opts) do 40 | env 41 | |> Tesla.put_headers(headers) 42 | |> Tesla.run(next) 43 | end 44 | end 45 | end 46 | 47 | defp authorization_header(env, opts) do 48 | with {:ok, vars} <- authorization_vars(env, opts) do 49 | {:ok, 50 | vars 51 | |> calculated_authorization_values 52 | |> create_header} 53 | end 54 | end 55 | 56 | defp authorization_vars(env, opts) do 57 | with {:ok, unauthorized_response} <- 58 | env.__module__.request( 59 | env.__client__, 60 | method: env.opts[:pre_auth_method] || env.method, 61 | url: env.url, 62 | opts: Keyword.put(env.opts || [], :digest_auth_handshake, true) 63 | ) do 64 | {:ok, 65 | %{ 66 | username: opts[:username] || "", 67 | password: opts[:password] || "", 68 | path: URI.parse(env.url).path, 69 | auth: 70 | Tesla.get_header(unauthorized_response, "www-authenticate") 71 | |> parse_www_authenticate_header, 72 | method: env.method |> to_string |> String.upcase(), 73 | client_nonce: (opts[:cnonce_fn] || (&cnonce/0)).(), 74 | nc: opts[:nc] || "00000000" 75 | }} 76 | end 77 | end 78 | 79 | defp calculated_authorization_values(%{auth: auth}) when auth == %{}, do: [] 80 | 81 | defp calculated_authorization_values(auth_vars) do 82 | [ 83 | {"username", auth_vars.username}, 84 | {"realm", auth_vars.auth["realm"]}, 85 | {"uri", auth_vars[:path]}, 86 | {"nonce", auth_vars.auth["nonce"]}, 87 | {"nc", auth_vars.nc}, 88 | {"cnonce", auth_vars.client_nonce}, 89 | {"response", response(auth_vars)}, 90 | # hard-coded, will not work for MD5-sess 91 | {"algorithm", "MD5"}, 92 | # hard-coded, will not work for auth-int or unspecified 93 | {"qop", "auth"} 94 | ] 95 | end 96 | 97 | defp single_header_val({k, v}) when k in ~w(nc qop algorithm), do: "#{k}=#{v}" 98 | defp single_header_val({k, v}), do: "#{k}=\"#{v}\"" 99 | 100 | defp create_header([]), do: [] 101 | 102 | defp create_header(calculated_authorization_values) do 103 | vals = 104 | calculated_authorization_values 105 | |> Enum.reduce([], fn val, acc -> [single_header_val(val) | acc] end) 106 | |> Enum.join(", ") 107 | 108 | [{"authorization", "Digest #{vals}"}] 109 | end 110 | 111 | defp ha1(%{username: username, auth: %{"realm" => realm}, password: password}) do 112 | md5("#{username}:#{realm}:#{password}") 113 | end 114 | 115 | defp ha2(%{method: method, path: path}) do 116 | md5("#{method}:#{path}") 117 | end 118 | 119 | defp response(%{auth: %{"nonce" => nonce}, nc: nc, client_nonce: client_nonce} = auth_vars) do 120 | md5("#{ha1(auth_vars)}:#{nonce}:#{nc}:#{client_nonce}:auth:#{ha2(auth_vars)}") 121 | end 122 | 123 | defp parse_www_authenticate_header(nil), do: %{} 124 | 125 | defp parse_www_authenticate_header(header) do 126 | Regex.scan(~r/(\w+?)="(.+?)"/, header) 127 | |> Enum.reduce(%{}, fn [_, key, val], acc -> Map.merge(acc, %{key => val}) end) 128 | end 129 | 130 | defp md5(data), do: Base.encode16(:erlang.md5(data), case: :lower) 131 | 132 | defp cnonce, do: :crypto.strong_rand_bytes(4) |> Base.encode16(case: :lower) 133 | end 134 | -------------------------------------------------------------------------------- /lib/tesla/middleware/follow_redirects.ex: -------------------------------------------------------------------------------- 1 | defmodule Tesla.Middleware.FollowRedirects do 2 | @moduledoc """ 3 | Follow HTTP 3xx redirects. 4 | 5 | ## Examples 6 | 7 | ```elixir 8 | defmodule MyClient do 9 | def client do 10 | # defaults to 5 11 | Tesla.client([ 12 | {Tesla.Middleware.FollowRedirects, max_redirects: 3} 13 | ]) 14 | end 15 | end 16 | ``` 17 | 18 | ## Options 19 | 20 | - `:max_redirects` - limit number of redirects (default: `5`) 21 | """ 22 | 23 | @behaviour Tesla.Middleware 24 | 25 | @max_redirects 5 26 | @redirect_statuses [301, 302, 303, 307, 308] 27 | 28 | @impl Tesla.Middleware 29 | def call(env, next, opts \\ []) do 30 | max = Keyword.get(opts || [], :max_redirects, @max_redirects) 31 | 32 | redirect(env, next, max) 33 | end 34 | 35 | defp redirect(env, next, left) when left == 0 do 36 | case Tesla.run(env, next) do 37 | {:ok, %{status: status} = env} when status not in @redirect_statuses -> 38 | {:ok, env} 39 | 40 | {:ok, _env} -> 41 | {:error, {__MODULE__, :too_many_redirects}} 42 | 43 | error -> 44 | error 45 | end 46 | end 47 | 48 | defp redirect(env, next, left) do 49 | case Tesla.run(env, next) do 50 | {:ok, %{status: status} = res} when status in @redirect_statuses -> 51 | case Tesla.get_header(res, "location") do 52 | nil -> 53 | {:ok, res} 54 | 55 | location -> 56 | prev_uri = URI.parse(env.url) 57 | next_uri = parse_location(location, res) 58 | 59 | # Copy opts and query params from the response env, 60 | # these are not modified in the adapters, but middlewares 61 | # that come after might store state there 62 | env = %{env | opts: res.opts} 63 | 64 | env 65 | |> filter_headers(prev_uri, next_uri) 66 | |> new_request(status, URI.to_string(next_uri)) 67 | |> redirect(next, left - 1) 68 | end 69 | 70 | other -> 71 | other 72 | end 73 | end 74 | 75 | # The 303 (See Other) redirect was added in HTTP/1.1 to indicate that the originally 76 | # requested resource is not available, however a related resource (or another redirect) 77 | # available via GET is available at the specified location. 78 | # https://tools.ietf.org/html/rfc7231#section-6.4.4 79 | defp new_request(env, 303, location), do: %{env | url: location, method: :get, query: []} 80 | 81 | # The 307 (Temporary Redirect) status code indicates that the target 82 | # resource resides temporarily under a different URI and the user agent 83 | # MUST NOT change the request method (...) 84 | # https://tools.ietf.org/html/rfc7231#section-6.4.7 85 | defp new_request(env, 307, location), do: %{env | url: location} 86 | 87 | defp new_request(env, _, location), do: %{env | url: location, query: []} 88 | 89 | defp parse_location("https://" <> _rest = location, _env), do: URI.parse(location) 90 | defp parse_location("http://" <> _rest = location, _env), do: URI.parse(location) 91 | defp parse_location(location, env), do: env.url |> URI.parse() |> URI.merge(location) 92 | 93 | # See https://github.com/teamon/tesla/issues/362 94 | # See https://github.com/teamon/tesla/issues/360 95 | @filter_headers ["authorization", "host"] 96 | defp filter_headers(env, prev, next) do 97 | if next.host != prev.host || next.port != prev.port || next.scheme != prev.scheme do 98 | %{env | headers: Enum.filter(env.headers, fn {k, _} -> k not in @filter_headers end)} 99 | else 100 | env 101 | end 102 | end 103 | end 104 | -------------------------------------------------------------------------------- /lib/tesla/middleware/form_urlencoded.ex: -------------------------------------------------------------------------------- 1 | defmodule Tesla.Middleware.FormUrlencoded do 2 | @moduledoc """ 3 | Send request body as `application/x-www-form-urlencoded`. 4 | 5 | Performs encoding of `body` from a `Map` such as `%{"foo" => "bar"}` into 6 | URL-encoded data. 7 | 8 | Performs decoding of the response into a map when urlencoded and content-type 9 | is `application/x-www-form-urlencoded`, so `"foo=bar"` becomes 10 | `%{"foo" => "bar"}`. 11 | 12 | ## Examples 13 | 14 | ```elixir 15 | defmodule Myclient do 16 | def client do 17 | Tesla.client([ 18 | {Tesla.Middleware.FormUrlencoded, 19 | encode: &Plug.Conn.Query.encode/1, 20 | decode: &Plug.Conn.Query.decode/1} 21 | ]) 22 | end 23 | end 24 | 25 | client = Myclient.client() 26 | Myclient.post(client, "/url", %{key: :value}) 27 | ``` 28 | 29 | ## Options 30 | 31 | - `:decode` - decoding function, defaults to `URI.decode_query/1` 32 | - `:encode` - encoding function, defaults to `URI.encode_query/1` 33 | 34 | ## Nested Maps 35 | 36 | Natively, nested maps are not supported in the body, so 37 | `%{"foo" => %{"bar" => "baz"}}` won't be encoded and raise an error. 38 | Support for this specific case is obtained by configuring the middleware to 39 | encode (and decode) with `Plug.Conn.Query` 40 | 41 | ```elixir 42 | defmodule Myclient do 43 | def client do 44 | Tesla.client([ 45 | {Tesla.Middleware.FormUrlencoded, 46 | encode: &Plug.Conn.Query.encode/1, 47 | decode: &Plug.Conn.Query.decode/1} 48 | ]) 49 | end 50 | end 51 | 52 | client = Myclient.client() 53 | Myclient.post(client, "/url", %{key: %{nested: "value"}}) 54 | ``` 55 | """ 56 | 57 | @behaviour Tesla.Middleware 58 | 59 | @content_type "application/x-www-form-urlencoded" 60 | 61 | @impl Tesla.Middleware 62 | def call(env, next, opts) do 63 | env 64 | |> encode(opts) 65 | |> Tesla.run(next) 66 | |> case do 67 | {:ok, env} -> {:ok, decode(env, opts)} 68 | error -> error 69 | end 70 | end 71 | 72 | @doc """ 73 | Encode response body as querystring. 74 | 75 | It is used by `Tesla.Middleware.EncodeFormUrlencoded`. 76 | """ 77 | def encode(env, opts) do 78 | if encodable?(env) do 79 | env 80 | |> Map.update!(:body, &encode_body(&1, opts)) 81 | |> Tesla.put_headers([{"content-type", @content_type}]) 82 | else 83 | env 84 | end 85 | end 86 | 87 | defp encodable?(%{body: nil}), do: false 88 | defp encodable?(%{body: %Tesla.Multipart{}}), do: false 89 | defp encodable?(_), do: true 90 | 91 | defp encode_body(body, _opts) when is_binary(body), do: body 92 | defp encode_body(body, opts), do: do_encode(body, opts) 93 | 94 | @doc """ 95 | Decode response body as querystring. 96 | 97 | It is used by `Tesla.Middleware.DecodeFormUrlencoded`. 98 | """ 99 | def decode(env, opts) do 100 | if decodable?(env) do 101 | env 102 | |> Map.update!(:body, &decode_body(&1, opts)) 103 | else 104 | env 105 | end 106 | end 107 | 108 | defp decodable?(env), do: decodable_body?(env) && decodable_content_type?(env) 109 | 110 | defp decodable_body?(env) do 111 | (is_binary(env.body) && env.body != "") || (is_list(env.body) && env.body != []) 112 | end 113 | 114 | defp decodable_content_type?(env) do 115 | case Tesla.get_header(env, "content-type") do 116 | nil -> false 117 | content_type -> String.starts_with?(content_type, @content_type) 118 | end 119 | end 120 | 121 | defp decode_body(body, opts), do: do_decode(body, opts) 122 | 123 | defp do_encode(data, opts) do 124 | encoder = Keyword.get(opts, :encode, &URI.encode_query/1) 125 | encoder.(data) 126 | end 127 | 128 | defp do_decode(data, opts) do 129 | decoder = Keyword.get(opts, :decode, &URI.decode_query/1) 130 | decoder.(data) 131 | end 132 | end 133 | 134 | defmodule Tesla.Middleware.DecodeFormUrlencoded do 135 | @behaviour Tesla.Middleware 136 | 137 | @impl true 138 | def call(env, next, opts) do 139 | opts = opts || [] 140 | 141 | with {:ok, env} <- Tesla.run(env, next) do 142 | {:ok, Tesla.Middleware.FormUrlencoded.decode(env, opts)} 143 | end 144 | end 145 | end 146 | 147 | defmodule Tesla.Middleware.EncodeFormUrlencoded do 148 | @behaviour Tesla.Middleware 149 | 150 | @impl true 151 | def call(env, next, opts) do 152 | opts = opts || [] 153 | 154 | with env <- Tesla.Middleware.FormUrlencoded.encode(env, opts) do 155 | Tesla.run(env, next) 156 | end 157 | end 158 | end 159 | -------------------------------------------------------------------------------- /lib/tesla/middleware/fuse.ex: -------------------------------------------------------------------------------- 1 | if Code.ensure_loaded?(:fuse) do 2 | defmodule Tesla.Middleware.Fuse do 3 | @moduledoc """ 4 | Circuit Breaker middleware using [fuse](https://github.com/jlouis/fuse). 5 | 6 | Remember to add `{:fuse, "~> 2.4"}` to dependencies (and `:fuse` to applications in `mix.exs`) 7 | Also, you need to recompile tesla after adding `:fuse` dependency: 8 | 9 | ``` 10 | mix deps.clean tesla 11 | mix deps.compile tesla 12 | ``` 13 | 14 | ## Examples 15 | 16 | ```elixir 17 | defmodule MyClient do 18 | def client do 19 | Tesla.client([ 20 | {Tesla.Middleware.Fuse, 21 | opts: {{:standard, 2, 10_000}, {:reset, 60_000}}, 22 | keep_original_error: true, 23 | should_melt: fn 24 | {:ok, %{status: status}} when status in [428, 500, 504] -> true 25 | {:ok, _} -> false 26 | {:error, _} -> true 27 | end, 28 | mode: :sync} 29 | ]) 30 | end 31 | end 32 | ``` 33 | 34 | ## Options 35 | 36 | - `:name` - fuse name (defaults to module name) 37 | - `:opts` - fuse options (see fuse docs for reference) 38 | - `:keep_original_error` - boolean to indicate if, in case of melting (based on `should_melt`), it should return the upstream's error or the fixed one `{:error, unavailable}`. 39 | It's false by default, but it will be true in `2.0.0` version 40 | - `:should_melt` - function to determine if response should melt the fuse 41 | - `:mode` - how to query the fuse, which has two values: 42 | - `:sync` - queries are serialized through the `:fuse_server` process (the default) 43 | - `:async_dirty` - queries check the fuse state directly, but may not account for recent melts or resets 44 | 45 | ## SASL logger 46 | 47 | fuse library uses [SASL (System Architecture Support Libraries)](http://erlang.org/doc/man/sasl_app.html). 48 | 49 | You can disable its logger output using: 50 | 51 | ```elixir 52 | config :sasl, sasl_error_logger: :false 53 | ``` 54 | 55 | Read more at [jlouis/fuse#32](https://github.com/jlouis/fuse/issues/32) and [jlouis/fuse#19](https://github.com/jlouis/fuse/issues/19). 56 | """ 57 | 58 | @behaviour Tesla.Middleware 59 | 60 | # options borrowed from https://rokkincat.com/blog/2015-09-24-circuit-breakers-in-elixir/ 61 | # most probably not valid for your use case 62 | @defaults {{:standard, 2, 10_000}, {:reset, 60_000}} 63 | 64 | @impl Tesla.Middleware 65 | def call(env, next, opts) do 66 | opts = opts || [] 67 | 68 | context = %{ 69 | name: Keyword.get(opts, :name, env.__module__), 70 | keep_original_error: Keyword.get(opts, :keep_original_error, false), 71 | should_melt: Keyword.get(opts, :should_melt, &match?({:error, _}, &1)), 72 | mode: Keyword.get(opts, :mode, :sync) 73 | } 74 | 75 | case :fuse.ask(context.name, context.mode) do 76 | :ok -> 77 | run(env, next, context) 78 | 79 | :blown -> 80 | {:error, :unavailable} 81 | 82 | {:error, :not_found} -> 83 | :fuse.install(context.name, Keyword.get(opts, :opts, @defaults)) 84 | run(env, next, context) 85 | end 86 | end 87 | 88 | defp run(env, next, %{ 89 | should_melt: should_melt, 90 | name: name, 91 | keep_original_error: keep_original_error 92 | }) do 93 | res = Tesla.run(env, next) 94 | 95 | if should_melt.(res) do 96 | :fuse.melt(name) 97 | if keep_original_error, do: res, else: {:error, :unavailable} 98 | else 99 | res 100 | end 101 | end 102 | end 103 | end 104 | -------------------------------------------------------------------------------- /lib/tesla/middleware/headers.ex: -------------------------------------------------------------------------------- 1 | defmodule Tesla.Middleware.Headers do 2 | @moduledoc """ 3 | Set default headers for all requests 4 | 5 | ## Examples 6 | 7 | ```elixir 8 | defmodule Myclient do 9 | def client do 10 | Tesla.client([ 11 | {Tesla.Middleware.Headers, [{"user-agent", "Tesla"}]} 12 | ]) 13 | end 14 | end 15 | ``` 16 | """ 17 | 18 | @behaviour Tesla.Middleware 19 | 20 | @impl Tesla.Middleware 21 | def call(env, next, headers) do 22 | env 23 | |> Tesla.put_headers(headers) 24 | |> Tesla.run(next) 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/tesla/middleware/json/json_adapter.ex: -------------------------------------------------------------------------------- 1 | if Code.ensure_loaded?(JSON) do 2 | defmodule Tesla.Middleware.JSON.JSONAdapter do 3 | @moduledoc false 4 | # An adapter for Elixir's built-in JSON module introduced in Elixir 1.18 5 | # that adjusts for Tesla's assumptions about a JSON engine, which are not satisfied by 6 | # Elixir's JSON module. The assumptions are: 7 | # - the module provides encode/2 and decode/2 functions 8 | # - the 2nd argument to the functions is opts 9 | # - the functions return {:ok, json} or {:error, reason} - not the case for JSON.encode!/2 10 | # 11 | # We do not support custom encoders and decoders. 12 | # The purpose of this adapter is to allow `engine: JSON` to be set easily. If more advanced 13 | # customization is required, the `:encode` and `:decode` functions can be supplied to the 14 | # middleware instead of the `:engine` option. 15 | def encode(data, _opts), do: {:ok, JSON.encode!(data)} 16 | def decode(binary, _opts), do: JSON.decode(binary) 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/tesla/middleware/keep_request.ex: -------------------------------------------------------------------------------- 1 | defmodule Tesla.Middleware.KeepRequest do 2 | @moduledoc """ 3 | Store request URL, body, and headers into `:opts`. 4 | 5 | ## Examples 6 | 7 | ``` 8 | defmodule MyClient do 9 | def client do 10 | Tesla.client([ 11 | Tesla.Middleware.KeepRequest, 12 | Tesla.Middleware.PathParams 13 | ]) 14 | end 15 | end 16 | 17 | client = MyClient.client() 18 | {:ok, env} = Tesla.post(client, "/users/:user_id", "request-data", opts: [path_params: [user_id: "1234"]]) 19 | 20 | env.body 21 | # => "response-data" 22 | 23 | env.opts[:req_body] 24 | # => "request-data" 25 | 26 | env.opts[:req_headers] 27 | # => [{"request-headers", "are-safe"}, ...] 28 | 29 | env.opts[:req_url] 30 | # => "http://localhost:8000/users/:user_id 31 | ``` 32 | 33 | ## Observability 34 | 35 | In practice, you would combine `Tesla.Middleware.KeepRequest`, `Tesla.Middleware.PathParams`, and 36 | `Tesla.Middleware.Telemetry` to observe the request and response data. 37 | Keep in mind that the request order matters. Make sure to put `Tesla.Middleware.KeepRequest` before 38 | `Tesla.Middleware.PathParams` to make sure that the request data is stored before the path parameters are replaced. 39 | While keeping in mind that this is an application-specific concern, this is the overall recommendation. 40 | """ 41 | 42 | @behaviour Tesla.Middleware 43 | 44 | @impl Tesla.Middleware 45 | def call(env, next, _opts) do 46 | env 47 | |> Tesla.put_opt(:req_body, env.body) 48 | |> Tesla.put_opt(:req_headers, env.headers) 49 | |> Tesla.put_opt(:req_url, env.url) 50 | |> Tesla.run(next) 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/tesla/middleware/message_pack.ex: -------------------------------------------------------------------------------- 1 | if Code.ensure_loaded?(Msgpax) do 2 | defmodule Tesla.Middleware.MessagePack do 3 | @moduledoc """ 4 | Encode requests and decode responses as MessagePack. 5 | 6 | This middleware requires [Msgpax](https://hex.pm/packages/msgpax) as dependency. 7 | 8 | Remember to add `{:msgpax, ">= 2.3.0"}` to dependencies. 9 | Also, you need to recompile Tesla after adding `:msgpax` dependency: 10 | 11 | ```shell 12 | mix deps.clean tesla 13 | mix deps.compile tesla 14 | ``` 15 | 16 | ## Examples 17 | 18 | ```elixir 19 | defmodule MyClient do 20 | def client do 21 | Tesla.client([ 22 | Tesla.Middleware.MessagePack, 23 | # or 24 | {Tesla.Middleware.MessagePack, engine_opts: [binary: true]}, 25 | # or 26 | {Tesla.Middleware.MessagePack, decode: &Custom.decode/1, encode: &Custom.encode/1} 27 | ]) 28 | end 29 | end 30 | ``` 31 | 32 | ## Options 33 | 34 | - `:decode` - decoding function 35 | - `:encode` - encoding function 36 | - `:encode_content_type` - content-type to be used in request header 37 | - `:decode_content_types` - list of additional decodable content-types 38 | - `:engine_opts` - optional engine options 39 | """ 40 | 41 | @behaviour Tesla.Middleware 42 | 43 | @default_decode_content_types ["application/msgpack", "application/x-msgpack"] 44 | @default_encode_content_type "application/msgpack" 45 | 46 | @impl Tesla.Middleware 47 | def call(env, next, opts) do 48 | opts = opts || [] 49 | 50 | with {:ok, env} <- encode(env, opts), 51 | {:ok, env} <- Tesla.run(env, next) do 52 | decode(env, opts) 53 | end 54 | end 55 | 56 | @doc """ 57 | Encode request body as MessagePack. 58 | 59 | It is used by `Tesla.Middleware.EncodeMessagePack`. 60 | """ 61 | def encode(env, opts) do 62 | with true <- encodable?(env), 63 | {:ok, body} <- encode_body(env.body, opts) do 64 | {:ok, 65 | env 66 | |> Tesla.put_body(body) 67 | |> Tesla.put_headers([{"content-type", encode_content_type(opts)}])} 68 | else 69 | false -> {:ok, env} 70 | error -> error 71 | end 72 | end 73 | 74 | defp encode_body(body, opts), do: process(body, :encode, opts) 75 | 76 | defp encode_content_type(opts), 77 | do: Keyword.get(opts, :encode_content_type, @default_encode_content_type) 78 | 79 | defp encodable?(%{body: nil}), do: false 80 | defp encodable?(%{body: body}) when is_binary(body), do: false 81 | defp encodable?(%{body: %Tesla.Multipart{}}), do: false 82 | defp encodable?(_), do: true 83 | 84 | @doc """ 85 | Decode response body as MessagePack. 86 | 87 | It is used by `Tesla.Middleware.DecodeMessagePack`. 88 | """ 89 | def decode(env, opts) do 90 | with true <- decodable?(env, opts), 91 | {:ok, body} <- decode_body(env.body, opts) do 92 | {:ok, %{env | body: body}} 93 | else 94 | false -> {:ok, env} 95 | error -> error 96 | end 97 | end 98 | 99 | defp decode_body(body, opts), do: process(body, :decode, opts) 100 | 101 | defp decodable?(env, opts), do: decodable_body?(env) && decodable_content_type?(env, opts) 102 | 103 | defp decodable_body?(env) do 104 | (is_binary(env.body) && env.body != "") || (is_list(env.body) && env.body != []) 105 | end 106 | 107 | defp decodable_content_type?(env, opts) do 108 | case Tesla.get_header(env, "content-type") do 109 | nil -> false 110 | content_type -> Enum.any?(content_types(opts), &String.starts_with?(content_type, &1)) 111 | end 112 | end 113 | 114 | defp content_types(opts), 115 | do: @default_decode_content_types ++ Keyword.get(opts, :decode_content_types, []) 116 | 117 | defp process(data, op, opts) do 118 | case do_process(data, op, opts) do 119 | {:ok, data} -> {:ok, data} 120 | {:error, reason} -> {:error, {__MODULE__, op, reason}} 121 | {:error, reason, _pos} -> {:error, {__MODULE__, op, reason}} 122 | end 123 | rescue 124 | ex in Protocol.UndefinedError -> 125 | {:error, {__MODULE__, op, ex}} 126 | end 127 | 128 | defp do_process(data, op, opts) do 129 | # :encode/:decode 130 | if fun = opts[op] do 131 | fun.(data) 132 | else 133 | opts = Keyword.get(opts, :engine_opts, []) 134 | 135 | case op do 136 | :encode -> Msgpax.pack(data, opts) 137 | :decode -> Msgpax.unpack(data, opts) 138 | end 139 | end 140 | end 141 | end 142 | 143 | defmodule Tesla.Middleware.DecodeMessagePack do 144 | def call(env, next, opts) do 145 | opts = opts || [] 146 | 147 | with {:ok, env} <- Tesla.run(env, next) do 148 | Tesla.Middleware.MessagePack.decode(env, opts) 149 | end 150 | end 151 | end 152 | 153 | defmodule Tesla.Middleware.EncodeMessagePack do 154 | def call(env, next, opts) do 155 | opts = opts || [] 156 | 157 | with {:ok, env} <- Tesla.Middleware.MessagePack.encode(env, opts) do 158 | Tesla.run(env, next) 159 | end 160 | end 161 | end 162 | end 163 | -------------------------------------------------------------------------------- /lib/tesla/middleware/method_override.ex: -------------------------------------------------------------------------------- 1 | defmodule Tesla.Middleware.MethodOverride do 2 | @moduledoc """ 3 | Middleware that adds `X-HTTP-Method-Override` header with original request 4 | method and sends the request as post. 5 | 6 | Useful when there's an issue with sending non-POST request. 7 | 8 | ## Examples 9 | 10 | ```elixir 11 | defmodule MyClient do 12 | def client do 13 | Tesla.client([Tesla.Middleware.MethodOverride]) 14 | end 15 | end 16 | ``` 17 | 18 | ## Options 19 | 20 | - `:override` - list of HTTP methods that should be overridden, everything except `:get` and `:post` if not specified 21 | """ 22 | 23 | @behaviour Tesla.Middleware 24 | 25 | @impl Tesla.Middleware 26 | def call(env, next, opts) do 27 | if overridable?(env, opts) do 28 | env 29 | |> override 30 | |> Tesla.run(next) 31 | else 32 | env 33 | |> Tesla.run(next) 34 | end 35 | end 36 | 37 | defp override(env) do 38 | env 39 | |> Tesla.put_headers([{"x-http-method-override", "#{env.method}"}]) 40 | |> Map.put(:method, :post) 41 | end 42 | 43 | defp overridable?(env, opts) do 44 | if opts[:override] do 45 | env.method in opts[:override] 46 | else 47 | env.method not in [:get, :post] 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/tesla/middleware/opts.ex: -------------------------------------------------------------------------------- 1 | defmodule Tesla.Middleware.Opts do 2 | @moduledoc """ 3 | Set default opts for all requests. 4 | 5 | ## Examples 6 | 7 | ```elixir 8 | defmodule MyClient do 9 | def client do 10 | Tesla.client([ 11 | {Tesla.Middleware.Opts, [some: "option"]} 12 | ]) 13 | end 14 | end 15 | ``` 16 | """ 17 | 18 | @behaviour Tesla.Middleware 19 | 20 | @impl Tesla.Middleware 21 | def call(env, next, opts) do 22 | adapter = 23 | env.opts 24 | |> Keyword.get(:adapter, []) 25 | |> Keyword.merge(opts[:adapter] || []) 26 | 27 | opts = 28 | env.opts 29 | |> Keyword.merge(opts) 30 | |> Keyword.put(:adapter, adapter) 31 | 32 | Tesla.run(%{env | opts: opts}, next) 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/tesla/middleware/path_params.ex: -------------------------------------------------------------------------------- 1 | defmodule Tesla.Middleware.PathParams do 2 | @moduledoc """ 3 | Use templated URLs with provided parameters in either Phoenix style (`:id`) 4 | or OpenAPI style (`{id}`). 5 | 6 | Useful when logging or reporting metrics per URL. 7 | 8 | ## Parameter Values 9 | 10 | Parameter values may be `t:struct/0` or must implement the `Enumerable` 11 | protocol and produce `{key, value}` tuples when enumerated. 12 | 13 | ## Parameter Name Restrictions 14 | 15 | Phoenix style parameters may contain letters, numbers, or underscores, 16 | matching this regular expression: 17 | 18 | :[a-zA-Z][_a-zA-Z0-9]*\b 19 | 20 | OpenAPI style parameters may contain letters, numbers, underscores, or 21 | hyphens (`-`), matching this regular expression: 22 | 23 | \{[a-zA-Z][-_a-zA-Z0-9]*\} 24 | 25 | In either case, parameters that begin with underscores (`_`), hyphens (`-`), 26 | or numbers (`0-9`) are ignored and left as-is. 27 | 28 | ## Examples 29 | 30 | ```elixir 31 | defmodule MyClient do 32 | def client do 33 | Tesla.client([ 34 | {Tesla.Middleware.BaseUrl, "https://api.example.com"}, 35 | Tesla.Middleware.Logger, 36 | Tesla.Middleware.PathParams 37 | ]) 38 | end 39 | 40 | def user(client, id) do 41 | params = [id: id] 42 | Tesla.get(client, "/users/{id}", opts: [path_params: params]) 43 | end 44 | 45 | def posts(client, id, post_id) do 46 | params = [id: id, post_id: post_id] 47 | Tesla.get(client, "/users/:id/posts/:post_id", opts: [path_params: params]) 48 | end 49 | end 50 | ``` 51 | """ 52 | 53 | @behaviour Tesla.Middleware 54 | 55 | @impl Tesla.Middleware 56 | def call(env, next, _) do 57 | url = build_url(env.url, env.opts[:path_params]) 58 | Tesla.run(%{env | url: url}, next) 59 | end 60 | 61 | defp build_url(url, nil), do: url 62 | 63 | defp build_url(url, params) when is_struct(params), do: build_url(url, Map.from_struct(params)) 64 | 65 | defp build_url(url, params) when is_map(params) or is_list(params) do 66 | rx = ~r/:([a-zA-Z][a-zA-Z0-9_]*)|[{]([a-zA-Z][-a-zA-Z0-9_]*)[}]/ 67 | safe_params = Map.new(params, fn {name, value} -> {to_string(name), value} end) 68 | 69 | Regex.replace(rx, url, fn 70 | # OpenAPI matches 71 | match, "", name -> replace_param(safe_params, name, match) 72 | # Phoenix matches 73 | match, name, _ -> replace_param(safe_params, name, match) 74 | end) 75 | end 76 | 77 | defp build_url(url, _params), do: url 78 | 79 | defp replace_param(params, name, match) do 80 | case Map.fetch(params, name) do 81 | {:ok, nil} -> match 82 | :error -> match 83 | {:ok, value} -> URI.encode_www_form(to_string(value)) 84 | end 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /lib/tesla/middleware/query.ex: -------------------------------------------------------------------------------- 1 | defmodule Tesla.Middleware.Query do 2 | @moduledoc """ 3 | Set default query params for all requests 4 | 5 | ## Examples 6 | 7 | ```elixir 8 | defmodule MyClient do 9 | def client do 10 | Tesla.client([ 11 | {Tesla.Middleware.Query, [token: "some-token"]} 12 | ]) 13 | end 14 | end 15 | ``` 16 | """ 17 | 18 | @behaviour Tesla.Middleware 19 | 20 | @impl Tesla.Middleware 21 | def call(env, next, query) do 22 | env 23 | |> merge(query) 24 | |> Tesla.run(next) 25 | end 26 | 27 | defp merge(env, nil), do: env 28 | 29 | defp merge(env, query) do 30 | Map.update!(env, :query, &(&1 ++ query)) 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/tesla/middleware/sse.ex: -------------------------------------------------------------------------------- 1 | defmodule Tesla.Middleware.SSE do 2 | @moduledoc """ 3 | Decode Server Sent Events. 4 | 5 | This middleware is mostly useful when streaming response body. 6 | 7 | ## Examples 8 | 9 | ```elixir 10 | defmodule MyClient do 11 | def client do 12 | Tesla.client([Tesla.Middleware.SSE, only: :data]) 13 | end 14 | end 15 | ``` 16 | 17 | ## Options 18 | 19 | - `:only` - keep only specified keys in event (necessary for using with `JSON` middleware) 20 | - `:decode_content_types` - list of additional decodable content-types 21 | """ 22 | 23 | @behaviour Tesla.Middleware 24 | 25 | @default_content_types ["text/event-stream"] 26 | 27 | @impl Tesla.Middleware 28 | def call(env, next, opts) do 29 | opts = opts || [] 30 | 31 | with {:ok, env} <- Tesla.run(env, next) do 32 | decode(env, opts) 33 | end 34 | end 35 | 36 | def decode(env, opts) do 37 | if decodable_content_type?(env, opts) do 38 | {:ok, %{env | body: decode_body(env.body, opts)}} 39 | else 40 | {:ok, env} 41 | end 42 | end 43 | 44 | defp decode_body(body, opts) when is_struct(body, Stream) or is_function(body) do 45 | body 46 | |> Stream.chunk_while( 47 | "", 48 | fn elem, acc -> 49 | {lines, [rest]} = 50 | (acc <> elem) 51 | |> String.split(double_linebreak_regex()) 52 | |> Enum.split(-1) 53 | 54 | {:cont, lines, rest} 55 | end, 56 | fn 57 | "" -> {:cont, ""} 58 | acc -> {:cont, acc, ""} 59 | end 60 | ) 61 | |> Stream.flat_map(& &1) 62 | |> Stream.map(&decode_message/1) 63 | |> Stream.flat_map(&only(&1, opts[:only])) 64 | end 65 | 66 | defp decode_body(binary, opts) when is_binary(binary) do 67 | binary 68 | |> String.split(double_linebreak_regex()) 69 | |> Enum.map(&decode_message/1) 70 | |> Enum.flat_map(&only(&1, opts[:only])) 71 | end 72 | 73 | defp double_linebreak_regex(), do: ~r/((\r\n)|((? String.split(["\r\n", "\n", "\r"]) 78 | |> Enum.map(&decode_body/1) 79 | |> Enum.reduce(%{}, fn 80 | :empty, acc -> acc 81 | {:data, data}, acc -> Map.update(acc, :data, data, &(&1 <> "\n" <> data)) 82 | {key, value}, acc -> Map.put_new(acc, key, value) 83 | end) 84 | end 85 | 86 | defp decode_body(": " <> comment), do: {:comment, comment} 87 | defp decode_body("data: " <> data), do: {:data, data} 88 | defp decode_body("event: " <> event), do: {:event, event} 89 | defp decode_body("id: " <> id), do: {:id, id} 90 | defp decode_body("retry: " <> retry), do: {:retry, retry} 91 | defp decode_body(""), do: :empty 92 | 93 | defp decodable_content_type?(env, opts) do 94 | case Tesla.get_header(env, "content-type") do 95 | nil -> false 96 | content_type -> Enum.any?(content_types(opts), &String.starts_with?(content_type, &1)) 97 | end 98 | end 99 | 100 | defp content_types(opts), 101 | do: @default_content_types ++ Keyword.get(opts, :decode_content_types, []) 102 | 103 | defp only(message, nil), do: [message] 104 | 105 | defp only(message, key) do 106 | case Map.get(message, key) do 107 | nil -> [] 108 | val -> [val] 109 | end 110 | end 111 | end 112 | -------------------------------------------------------------------------------- /lib/tesla/middleware/telemetry.ex: -------------------------------------------------------------------------------- 1 | if Code.ensure_loaded?(:telemetry) do 2 | defmodule Tesla.Middleware.Telemetry do 3 | @moduledoc """ 4 | Emits events using the `:telemetry` library to expose instrumentation. 5 | 6 | ## Examples 7 | 8 | ```elixir 9 | defmodule MyClient do 10 | def client do 11 | Tesla.client([Tesla.Middleware.Telemetry]) 12 | end 13 | end 14 | 15 | :telemetry.attach( 16 | "my-tesla-telemetry", 17 | [:tesla, :request, :stop], 18 | fn event, measurements, meta, config -> 19 | # Do something with the event 20 | end, 21 | nil 22 | ) 23 | ``` 24 | 25 | ## Options 26 | 27 | - `:metadata` - additional metadata passed to telemetry events 28 | 29 | ## Telemetry Events 30 | 31 | * `[:tesla, :request, :start]` - emitted at the beginning of the request. 32 | * Measurement: `%{system_time: System.system_time()}` 33 | * Metadata: `%{env: Tesla.Env.t()}` 34 | 35 | * `[:tesla, :request, :stop]` - emitted at the end of the request. 36 | * Measurement: `%{duration: native_time}` 37 | * Metadata: `%{env: Tesla.Env.t()} | %{env: Tesla.Env.t(), error: term()}` 38 | 39 | * `[:tesla, :request, :exception]` - emitted when an exception has been raised. 40 | * Measurement: `%{duration: native_time}` 41 | * Metadata: `%{env: Tesla.Env.t(), kind: Exception.kind(), reason: term(), stacktrace: Exception.stacktrace()}` 42 | 43 | ## Legacy Telemetry Events 44 | 45 | * `[:tesla, :request]` - This event is emitted for backwards compatibility only and should be considered deprecated. 46 | This event can be disabled by setting `config :tesla, Tesla.Middleware.Telemetry, disable_legacy_event: true` in your config. 47 | Be sure to run `mix deps.compile --force tesla` after changing this setting to ensure the change is picked up. 48 | 49 | Please check the [telemetry](https://hexdocs.pm/telemetry/) for further usage. 50 | 51 | ## URL event scoping with `Tesla.Middleware.PathParams` and `Tesla.Middleware.KeepRequest` 52 | 53 | Sometimes, it is useful to have access to a template URL (i.e. `"/users/:user_id"`) for grouping 54 | Telemetry events. For such cases, a combination of the `Tesla.Middleware.PathParams`, 55 | `Tesla.Middleware.Telemetry` and `Tesla.Middleware.KeepRequest` may be used. 56 | 57 | ```elixir 58 | defmodule MyClient do 59 | def client do 60 | Tesla.client([ 61 | # The KeepRequest middleware sets the template URL as a Tesla.Env.opts entry 62 | # Said entry must be used because on happy-path scenarios, 63 | # the Telemetry middleware will receive the Tesla.Env.url resolved by PathParams. 64 | Tesla.Middleware.KeepRequest, 65 | Tesla.Middleware.PathParams, 66 | Tesla.Middleware.Telemetry 67 | ]) 68 | end 69 | end 70 | 71 | :telemetry.attach( 72 | "my-tesla-telemetry", 73 | [:tesla, :request, :stop], 74 | fn event, measurements, meta, config -> 75 | path_params_template_url = meta.env.opts[:req_url] 76 | # The meta.env.url key will only present the resolved URL on happy-path scenarios. 77 | # Error cases will still return the original template URL. 78 | path_params_resolved_url = meta.env.url 79 | end, 80 | nil 81 | ) 82 | ``` 83 | 84 | > #### Order Matters {: .warning} 85 | > Place the `Tesla.Middleware.Telemetry` middleware as close as possible to 86 | > the end of the middleware stack to ensure that you are measuring the 87 | > actual request itself and do not lose any information about the 88 | > `t:Tesla.Env.t/0` due to some transformation that happens in the 89 | > middleware stack before reaching the `Tesla.Middleware.Telemetry` 90 | > middleware. 91 | """ 92 | 93 | @disable_legacy_event Application.compile_env( 94 | :tesla, 95 | [Tesla.Middleware.Telemetry, :disable_legacy_event], 96 | false 97 | ) 98 | 99 | @behaviour Tesla.Middleware 100 | 101 | @impl Tesla.Middleware 102 | def call(env, next, opts) do 103 | metadata = opts[:metadata] || %{} 104 | start_time = System.monotonic_time() 105 | 106 | emit_start(Map.merge(metadata, %{env: env})) 107 | 108 | try do 109 | Tesla.run(env, next) 110 | catch 111 | kind, reason -> 112 | stacktrace = __STACKTRACE__ 113 | duration = System.monotonic_time() - start_time 114 | 115 | emit_exception( 116 | duration, 117 | Map.merge(metadata, %{env: env, kind: kind, reason: reason, stacktrace: stacktrace}) 118 | ) 119 | 120 | :erlang.raise(kind, reason, stacktrace) 121 | else 122 | {:ok, env} = result -> 123 | duration = System.monotonic_time() - start_time 124 | 125 | emit_stop(duration, Map.merge(metadata, %{env: env})) 126 | emit_legacy_event(duration, result) 127 | 128 | result 129 | 130 | {:error, reason} = result -> 131 | duration = System.monotonic_time() - start_time 132 | 133 | emit_stop(duration, Map.merge(metadata, %{env: env, error: reason})) 134 | emit_legacy_event(duration, result) 135 | 136 | result 137 | end 138 | end 139 | 140 | defp emit_start(metadata) do 141 | :telemetry.execute( 142 | [:tesla, :request, :start], 143 | %{system_time: System.system_time()}, 144 | metadata 145 | ) 146 | end 147 | 148 | defp emit_stop(duration, metadata) do 149 | :telemetry.execute( 150 | [:tesla, :request, :stop], 151 | %{duration: duration}, 152 | metadata 153 | ) 154 | end 155 | 156 | if @disable_legacy_event do 157 | defp emit_legacy_event(_duration, _result) do 158 | :ok 159 | end 160 | else 161 | defp emit_legacy_event(duration, result) do 162 | duration = System.convert_time_unit(duration, :native, :microsecond) 163 | 164 | :telemetry.execute( 165 | [:tesla, :request], 166 | %{request_time: duration}, 167 | %{result: result} 168 | ) 169 | end 170 | end 171 | 172 | defp emit_exception(duration, metadata) do 173 | :telemetry.execute( 174 | [:tesla, :request, :exception], 175 | %{duration: duration}, 176 | metadata 177 | ) 178 | end 179 | end 180 | end 181 | -------------------------------------------------------------------------------- /lib/tesla/middleware/timeout.ex: -------------------------------------------------------------------------------- 1 | defmodule Tesla.Middleware.Timeout do 2 | @moduledoc """ 3 | Timeout HTTP request after X milliseconds. 4 | 5 | ## Examples 6 | 7 | ```elixir 8 | defmodule MyClient do 9 | def client do 10 | Tesla.client([ 11 | Tesla.Middleware.Timeout, 12 | timeout: 2_000 13 | ]) 14 | end 15 | end 16 | ``` 17 | 18 | If you are using OpenTelemetry in your project, you may be interested in 19 | using `OpentelemetryProcessPropagator.Task` to have a better integration using 20 | the `task_module` option. 21 | 22 | ```elixir 23 | defmodule MyClient do 24 | def client do 25 | Tesla.client([ 26 | Tesla.Middleware.Timeout, 27 | timeout: 2_000, 28 | task_module: OpentelemetryProcessPropagator.Task 29 | ]) 30 | end 31 | end 32 | ``` 33 | 34 | ## Options 35 | 36 | - `:timeout` - number of milliseconds a request is allowed to take (defaults to `1000`) 37 | - `:task_module` - the `Task` module used to spawn tasks. Useful when you want 38 | to use alternatives such as `OpentelemetryProcessPropagator.Task` from the OTEL 39 | project. 40 | """ 41 | 42 | @behaviour Tesla.Middleware 43 | 44 | @default_timeout 1_000 45 | 46 | @impl Tesla.Middleware 47 | def call(env, next, opts) do 48 | opts = opts || [] 49 | timeout = Keyword.get(opts, :timeout, @default_timeout) 50 | task_module = Keyword.get(opts, :task_module, Task) 51 | 52 | task = safe_async(task_module, fn -> Tesla.run(env, next) end) 53 | 54 | try do 55 | task 56 | |> task_module.await(timeout) 57 | |> repass_error 58 | catch 59 | :exit, {:timeout, _} -> 60 | task_module.shutdown(task, 0) 61 | {:error, :timeout} 62 | end 63 | end 64 | 65 | defp safe_async(task_module, func) do 66 | task_module.async(fn -> 67 | try do 68 | {:ok, func.()} 69 | rescue 70 | e in _ -> 71 | {:exception, e, __STACKTRACE__} 72 | catch 73 | type, value -> 74 | {type, value} 75 | end 76 | end) 77 | end 78 | 79 | defp repass_error({:exception, error, stacktrace}), do: reraise(error, stacktrace) 80 | 81 | defp repass_error({:throw, value}), do: throw(value) 82 | 83 | defp repass_error({:exit, value}), do: exit(value) 84 | 85 | defp repass_error({:ok, result}), do: result 86 | end 87 | -------------------------------------------------------------------------------- /lib/tesla/multipart.ex: -------------------------------------------------------------------------------- 1 | defmodule Tesla.Multipart do 2 | @moduledoc """ 3 | Multipart functionality. 4 | 5 | ## Examples 6 | 7 | ``` 8 | mp = 9 | Multipart.new() 10 | |> Multipart.add_content_type_param("charset=utf-8") 11 | |> Multipart.add_field("field1", "foo") 12 | |> Multipart.add_field("field2", "bar", 13 | headers: [{"content-id", "1"}, {"content-type", "text/plain"}] 14 | ) 15 | |> Multipart.add_file("test/tesla/multipart_test_file.sh") 16 | |> Multipart.add_file("test/tesla/multipart_test_file.sh", name: "foobar") 17 | |> Multipart.add_file_content("sample file content", "sample.txt") 18 | 19 | response = client.post(url, mp) 20 | ``` 21 | """ 22 | 23 | defmodule Part do 24 | defstruct body: nil, 25 | dispositions: [], 26 | headers: [] 27 | 28 | @type t :: %__MODULE__{ 29 | body: String.t(), 30 | headers: Tesla.Env.headers(), 31 | dispositions: Keyword.t() 32 | } 33 | end 34 | 35 | @type part_stream :: Enum.t() 36 | @type part_value :: iodata | part_stream 37 | 38 | defstruct parts: [], 39 | boundary: nil, 40 | content_type_params: [] 41 | 42 | @type t :: %__MODULE__{ 43 | parts: list(Tesla.Multipart.Part.t()), 44 | boundary: String.t(), 45 | content_type_params: [String.t()] 46 | } 47 | 48 | @doc """ 49 | Create a new Multipart struct to be used for a request body. 50 | """ 51 | @spec new() :: t 52 | def new do 53 | %__MODULE__{boundary: unique_string()} 54 | end 55 | 56 | @doc """ 57 | Add a parameter to the multipart content-type. 58 | """ 59 | @spec add_content_type_param(t, String.t()) :: t 60 | def add_content_type_param(%__MODULE__{} = mp, param) do 61 | %{mp | content_type_params: mp.content_type_params ++ [param]} 62 | end 63 | 64 | @doc """ 65 | Add a field part. 66 | """ 67 | @spec add_field(t, String.t(), part_value, Keyword.t()) :: t | no_return 68 | def add_field(%__MODULE__{} = mp, name, value, opts \\ []) do 69 | :ok = assert_part_value!(value) 70 | {headers, opts} = Keyword.pop_first(opts, :headers, []) 71 | 72 | part = %Part{ 73 | body: value, 74 | headers: headers, 75 | dispositions: [{:name, name}] ++ opts 76 | } 77 | 78 | %{mp | parts: mp.parts ++ [part]} 79 | end 80 | 81 | @doc """ 82 | Add a file part. The file will be streamed. 83 | 84 | ## Options 85 | 86 | - `:name` - name of form param 87 | - `:filename` - filename (defaults to path basename) 88 | - `:headers` - additional headers 89 | - `:detect_content_type` - auto-detect file content-type (defaults to false) 90 | """ 91 | @spec add_file(t, String.t(), Keyword.t()) :: t 92 | def add_file(%__MODULE__{} = mp, path, opts \\ []) do 93 | {filename, opts} = Keyword.pop_first(opts, :filename, Path.basename(path)) 94 | {headers, opts} = Keyword.pop_first(opts, :headers, []) 95 | {detect_content_type, opts} = Keyword.pop_first(opts, :detect_content_type, false) 96 | 97 | # add in detected content-type if necessary 98 | headers = 99 | case detect_content_type do 100 | true -> List.keystore(headers, "content-type", 0, {"content-type", MIME.from_path(path)}) 101 | false -> headers 102 | end 103 | 104 | data = stream_file!(path, 2048) 105 | add_file_content(mp, data, filename, opts ++ [headers: headers]) 106 | end 107 | 108 | @doc """ 109 | Add a file part with value. 110 | 111 | Same as `add_file/3` but the file content is read from `data` input argument. 112 | 113 | ## Options 114 | 115 | - `:name` - name of form param 116 | - `:headers` - additional headers 117 | """ 118 | @spec add_file_content(t, part_value, String.t(), Keyword.t()) :: t 119 | def add_file_content(%__MODULE__{} = mp, data, filename, opts \\ []) do 120 | {name, opts} = Keyword.pop_first(opts, :name, "file") 121 | add_field(mp, name, data, opts ++ [filename: filename]) 122 | end 123 | 124 | @doc false 125 | @spec headers(t) :: Tesla.Env.headers() 126 | def headers(%__MODULE__{boundary: boundary, content_type_params: params}) do 127 | ct_params = (["boundary=#{boundary}"] ++ params) |> Enum.join("; ") 128 | [{"content-type", "multipart/form-data; #{ct_params}"}] 129 | end 130 | 131 | @doc false 132 | @spec body(t) :: part_stream 133 | def body(%__MODULE__{boundary: boundary, parts: parts}) do 134 | part_streams = Enum.map(parts, &part_as_stream(&1, boundary)) 135 | Stream.concat(part_streams ++ [["--#{boundary}--\r\n"]]) 136 | end 137 | 138 | @doc false 139 | @spec part_as_stream(Part.t(), String.t()) :: part_stream 140 | def part_as_stream( 141 | %Part{body: body, dispositions: dispositions, headers: part_headers}, 142 | boundary 143 | ) do 144 | part_headers = Enum.map(part_headers, fn {k, v} -> "#{k}: #{v}\r\n" end) 145 | part_headers = part_headers ++ [part_headers_for_disposition(dispositions)] 146 | 147 | enum_body = 148 | case body do 149 | b when is_binary(b) -> [b] 150 | b -> b 151 | end 152 | 153 | Stream.concat([ 154 | ["--#{boundary}\r\n"], 155 | part_headers, 156 | ["\r\n"], 157 | enum_body, 158 | ["\r\n"] 159 | ]) 160 | end 161 | 162 | @doc false 163 | @spec part_headers_for_disposition(Keyword.t()) :: [String.t()] 164 | def part_headers_for_disposition([]), do: [] 165 | 166 | def part_headers_for_disposition(kvs) do 167 | ds = 168 | kvs 169 | |> Enum.map(fn {k, v} -> "#{k}=\"#{v}\"" end) 170 | |> Enum.join("; ") 171 | 172 | ["content-disposition: form-data; #{ds}\r\n"] 173 | end 174 | 175 | @spec unique_string() :: String.t() 176 | defp unique_string() do 177 | 16 178 | |> :crypto.strong_rand_bytes() 179 | |> Base.encode16(case: :lower) 180 | end 181 | 182 | @spec assert_part_value!(any) :: :ok | no_return 183 | defp assert_part_value!(%maybe_stream{}) 184 | when maybe_stream in [IO.Stream, File.Stream, Stream], 185 | do: :ok 186 | 187 | defp assert_part_value!(value) 188 | when is_list(value) 189 | when is_binary(value), 190 | do: :ok 191 | 192 | defp assert_part_value!(val) do 193 | raise(ArgumentError, "#{inspect(val)} is not a supported multipart value.") 194 | end 195 | 196 | if Version.compare(System.version(), "1.16.0") in [:gt, :eq] do 197 | defp stream_file!(path, bytes), do: File.stream!(path, bytes) 198 | else 199 | defp stream_file!(path, bytes), do: File.stream!(path, [], bytes) 200 | end 201 | end 202 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Tesla.Mixfile do 2 | use Mix.Project 3 | 4 | @source_url "https://github.com/elixir-tesla/tesla" 5 | @version "1.14.2" 6 | 7 | def project do 8 | [ 9 | app: :tesla, 10 | version: @version, 11 | description: description(), 12 | package: package(), 13 | elixir: "~> 1.14", 14 | elixirc_paths: elixirc_paths(Mix.env()), 15 | deps: deps(), 16 | lockfile: lockfile(System.get_env("LOCKFILE")), 17 | test_coverage: [tool: ExCoveralls], 18 | dialyzer: [ 19 | plt_core_path: "_build/#{Mix.env()}", 20 | plt_add_apps: [:mix, :inets, :idna, :ssl_verify_fun, :ex_unit], 21 | plt_add_deps: :apps_direct 22 | ], 23 | docs: docs(), 24 | preferred_cli_env: [coveralls: :test, "coveralls.html": :test] 25 | ] 26 | end 27 | 28 | def application do 29 | [extra_applications: [:logger, :ssl, :inets]] 30 | end 31 | 32 | defp description do 33 | "HTTP client library, with support for middleware and multiple adapters." 34 | end 35 | 36 | defp package do 37 | [ 38 | maintainers: ["Tymon Tobolski"], 39 | licenses: ["MIT"], 40 | links: %{ 41 | "GitHub" => @source_url, 42 | "Changelog" => "#{@source_url}/blob/master/CHANGELOG.md" 43 | } 44 | ] 45 | end 46 | 47 | defp elixirc_paths(:test), do: ["lib", "test/support"] 48 | defp elixirc_paths(_), do: ["lib"] 49 | 50 | defp lockfile(nil), do: "mix.lock" 51 | defp lockfile(lockfile), do: "test/lockfiles/#{lockfile}.lock" 52 | 53 | defp deps do 54 | [ 55 | {:mime, "~> 1.0 or ~> 2.0"}, 56 | 57 | # http clients 58 | {:ibrowse, "4.4.2", optional: true}, 59 | {:hackney, "~> 1.21", optional: true}, 60 | {:gun, ">= 1.0.0", optional: true}, 61 | {:finch, "~> 0.13", optional: true}, 62 | {:castore, "~> 0.1 or ~> 1.0", optional: true}, 63 | {:mint, "~> 1.0", optional: true}, 64 | 65 | # json parsers 66 | {:jason, ">= 1.0.0", optional: true}, 67 | {:poison, ">= 1.0.0", optional: true}, 68 | {:exjsx, ">= 3.0.0", optional: true}, 69 | 70 | # messagepack parsers 71 | {:msgpax, "~> 2.3", optional: true}, 72 | 73 | # other 74 | {:fuse, "~> 2.4", optional: true}, 75 | {:telemetry, "~> 0.4 or ~> 1.0", optional: true}, 76 | {:mox, "~> 1.0", optional: true}, 77 | 78 | # devtools 79 | {:opentelemetry_process_propagator, ">= 0.0.0", only: [:test, :dev]}, 80 | {:excoveralls, ">= 0.0.0", only: :test, runtime: false}, 81 | {:ex_doc, ">= 0.0.0", only: :dev, runtime: false}, 82 | {:mix_test_watch, ">= 0.0.0", only: :dev}, 83 | {:dialyxir, ">= 0.0.0", only: [:dev, :test], runtime: false}, 84 | {:inch_ex, ">= 0.0.0", only: :docs}, 85 | 86 | # httparrot dependencies 87 | {:httparrot, "~> 1.4", only: :test}, 88 | {:cowlib, "~> 2.9", only: [:dev, :test], override: true}, 89 | {:ranch, "~> 2.1", only: :test, override: true} 90 | ] 91 | end 92 | 93 | defp docs do 94 | [ 95 | main: "readme", 96 | source_url: @source_url, 97 | source_ref: "v#{@version}", 98 | skip_undefined_reference_warnings_on: [ 99 | "CHANGELOG.md", 100 | "guides/howtos/migrations/v1-macro-migration.md" 101 | ], 102 | extra_section: "GUIDES", 103 | logo: "guides/elixir-tesla-logo.png", 104 | extras: 105 | [ 106 | "README.md", 107 | LICENSE: [title: "License"] 108 | # TODO: add CHANGELOG.md 109 | # "CHANGELOG.md": [title: "Changelog"] 110 | ] ++ Path.wildcard("guides/**/*.{cheatmd,md}"), 111 | groups_for_extras: [ 112 | Explanations: ~r"/explanations/", 113 | Cheatsheets: ~r"/cheatsheets/", 114 | "How-To's": ~r"/howtos/" 115 | ], 116 | groups_for_modules: [ 117 | Behaviours: [ 118 | Tesla.Adapter, 119 | Tesla.Middleware 120 | ], 121 | Adapters: [~r/Tesla.Adapter./], 122 | Middlewares: [~r/Tesla.Middleware./], 123 | TestSupport: [~r/Tesla.TestSupport./] 124 | ], 125 | nest_modules_by_prefix: [ 126 | Tesla.Adapter, 127 | Tesla.Middleware 128 | ] 129 | ] 130 | end 131 | end 132 | -------------------------------------------------------------------------------- /test/support/adapter_case.ex: -------------------------------------------------------------------------------- 1 | defmodule Tesla.AdapterCase do 2 | defmacro __using__(adapter: adapter) do 3 | quote do 4 | @adapter unquote(adapter) 5 | @http "http://localhost:#{Application.compile_env(:httparrot, :http_port)}" 6 | @https "https://localhost:#{Application.compile_env(:httparrot, :https_port)}" 7 | 8 | defp call(env, opts \\ []) do 9 | case @adapter do 10 | {adapter, adapter_opts} -> adapter.call(env, Keyword.merge(opts, adapter_opts)) 11 | adapter -> adapter.call(env, opts) 12 | end 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /test/support/adapter_case/basic.ex: -------------------------------------------------------------------------------- 1 | defmodule Tesla.AdapterCase.Basic do 2 | defmacro __using__(_) do 3 | quote do 4 | alias Tesla.Env 5 | 6 | describe "Basic" do 7 | test "HEAD request" do 8 | request = %Env{ 9 | method: :head, 10 | url: "#{@http}/ip" 11 | } 12 | 13 | assert {:ok, %Env{} = response} = call(request) 14 | assert response.status == 200 15 | end 16 | 17 | test "GET request" do 18 | request = %Env{ 19 | method: :get, 20 | url: "#{@http}/ip" 21 | } 22 | 23 | assert {:ok, %Env{} = response} = call(request) 24 | assert response.status == 200 25 | end 26 | 27 | test "POST request" do 28 | request = %Env{ 29 | method: :post, 30 | url: "#{@http}/post", 31 | body: "some-post-data", 32 | headers: [{"content-type", "text/plain"}] 33 | } 34 | 35 | assert {:ok, %Env{} = response} = call(request) 36 | assert response.status == 200 37 | assert Tesla.get_header(response, "content-type") == "application/json" 38 | assert Regex.match?(~r/some-post-data/, response.body) 39 | end 40 | 41 | test "unicode" do 42 | request = %Env{ 43 | method: :post, 44 | url: "#{@http}/post", 45 | body: "1 ø 2 đ 1 \u00F8 2 \u0111", 46 | headers: [{"content-type", "text/plain"}] 47 | } 48 | 49 | assert {:ok, %Env{} = response} = call(request) 50 | assert response.status == 200 51 | assert Tesla.get_header(response, "content-type") == "application/json" 52 | assert Regex.match?(~r/1 ø 2 đ 1 ø 2 đ/, response.body) 53 | end 54 | 55 | test "passing query params" do 56 | request = %Env{ 57 | method: :get, 58 | url: "#{@http}/get", 59 | query: [ 60 | page: 1, 61 | sort: "desc", 62 | status: ["a", "b", "c"], 63 | user: [name: "Jon", age: 20] 64 | ] 65 | } 66 | 67 | assert {:ok, %Env{} = response} = call(request) 68 | assert response.status == 200 69 | 70 | assert {:ok, %Env{} = response} = Tesla.Middleware.JSON.decode(response, []) 71 | 72 | args = response.body["args"] 73 | 74 | assert args["page"] == "1" 75 | assert args["sort"] == "desc" 76 | assert args["status[]"] == ["a", "b", "c"] 77 | assert args["user[name]"] == "Jon" 78 | assert args["user[age]"] == "20" 79 | end 80 | 81 | test "encoding query params with www_form by default" do 82 | request = %Env{ 83 | method: :get, 84 | url: "#{@http}/get", 85 | query: [user_name: "John Smith"] 86 | } 87 | 88 | assert {:ok, %Env{} = response} = call(request) 89 | assert {:ok, %Env{} = response} = Tesla.Middleware.JSON.decode(response, []) 90 | 91 | assert response.body["url"] == "#{@http}/get?user_name=John+Smith" 92 | end 93 | 94 | test "encoding query params with rfc3986 optionally" do 95 | request = %Env{ 96 | method: :get, 97 | url: "#{@http}/get", 98 | query: [user_name: "John Smith"], 99 | opts: [query_encoding: :rfc3986] 100 | } 101 | 102 | assert {:ok, %Env{} = response} = call(request) 103 | assert {:ok, %Env{} = response} = Tesla.Middleware.JSON.decode(response, []) 104 | 105 | assert response.body["url"] == "#{@http}/get?user_name=John%20Smith" 106 | end 107 | 108 | test "autoredirects disabled by default" do 109 | request = %Env{ 110 | method: :get, 111 | url: "#{@http}/redirect-to?url=#{@http}/status/200" 112 | } 113 | 114 | assert {:ok, %Env{} = response} = call(request) 115 | assert response.status == 301 116 | end 117 | 118 | test "error: connection refused" do 119 | request = %Env{ 120 | method: :get, 121 | url: "http://localhost:1234" 122 | } 123 | 124 | assert {:error, _} = call(request) 125 | end 126 | end 127 | end 128 | end 129 | end 130 | -------------------------------------------------------------------------------- /test/support/adapter_case/multipart.ex: -------------------------------------------------------------------------------- 1 | defmodule Tesla.AdapterCase.Multipart do 2 | defmacro __using__(_) do 3 | quote do 4 | alias Tesla.Env 5 | alias Tesla.Multipart 6 | 7 | describe "Multipart" do 8 | test "POST request" do 9 | mp = 10 | Multipart.new() 11 | |> Multipart.add_content_type_param("charset=utf-8") 12 | |> Multipart.add_field("field1", "foo") 13 | |> Multipart.add_field( 14 | "field2", 15 | "bar", 16 | headers: [{:"Content-Id", 1}, {:"Content-Type", "text/plain"}] 17 | ) 18 | |> Multipart.add_file("test/tesla/multipart_test_file.sh") 19 | |> Multipart.add_file("test/tesla/multipart_test_file.sh", name: "foobar") 20 | 21 | request = %Env{ 22 | method: :post, 23 | url: "#{@http}/post", 24 | body: mp 25 | } 26 | 27 | assert {:ok, %Env{} = response} = call(request) 28 | assert response.status == 200 29 | assert Tesla.get_header(response, "content-type") == "application/json" 30 | 31 | assert {:ok, %Env{} = response} = Tesla.Middleware.JSON.decode(response, []) 32 | 33 | assert Regex.match?( 34 | ~r[multipart/form-data; boundary=#{mp.boundary}; charset=utf-8$], 35 | response.body["headers"]["content-type"] 36 | ) 37 | 38 | assert response.body["form"] == %{"field1" => "foo", "field2" => "bar"} 39 | 40 | assert response.body["files"] == %{ 41 | "file" => "#!/usr/bin/env bash\necho \"test multipart file\"\n", 42 | "foobar" => "#!/usr/bin/env bash\necho \"test multipart file\"\n" 43 | } 44 | end 45 | end 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /test/support/adapter_case/ssl.ex: -------------------------------------------------------------------------------- 1 | defmodule Tesla.AdapterCase.SSL do 2 | defmacro __using__(opts) do 3 | quote do 4 | alias Tesla.Env 5 | 6 | describe "SSL" do 7 | test "GET request" do 8 | request = %Env{ 9 | method: :get, 10 | url: "#{@https}/ip" 11 | } 12 | 13 | assert {:ok, %Env{} = response} = call(request, unquote(opts)) 14 | assert response.status == 200 15 | end 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /test/support/adapter_case/stream_request_body.ex: -------------------------------------------------------------------------------- 1 | defmodule Tesla.AdapterCase.StreamRequestBody do 2 | defmacro __using__(_) do 3 | quote do 4 | alias Tesla.Env 5 | 6 | describe "Stream Request" do 7 | test "stream request body: Stream.map" do 8 | request = %Env{ 9 | method: :post, 10 | url: "#{@http}/post", 11 | headers: [{"content-type", "text/plain"}], 12 | body: Stream.map(1..5, &to_string/1) 13 | } 14 | 15 | assert {:ok, %Env{} = response} = call(request) 16 | assert response.status == 200 17 | assert Regex.match?(~r/12345/, to_string(response.body)) 18 | end 19 | 20 | test "stream request body: Stream.unfold" do 21 | body = 22 | Stream.unfold(5, fn 23 | 0 -> nil 24 | n -> {n, n - 1} 25 | end) 26 | |> Stream.map(&to_string/1) 27 | 28 | request = %Env{ 29 | method: :post, 30 | url: "#{@http}/post", 31 | headers: [{"content-type", "text/plain"}], 32 | body: body 33 | } 34 | 35 | assert {:ok, %Env{} = response} = call(request) 36 | assert response.status == 200 37 | assert Regex.match?(~r/54321/, to_string(response.body)) 38 | end 39 | end 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /test/support/adapter_case/stream_response_body.ex: -------------------------------------------------------------------------------- 1 | defmodule Tesla.AdapterCase.StreamResponseBody do 2 | defmacro __using__(_) do 3 | quote do 4 | alias Tesla.Env 5 | 6 | describe "Stream Response" do 7 | test "stream response body" do 8 | request = %Env{ 9 | method: :get, 10 | url: "#{@http}/stream/20" 11 | } 12 | 13 | assert {:ok, %Env{} = response} = call(request, response: :stream) 14 | assert response.status == 200 15 | assert is_function(response.body) || response.body.__struct__ == Stream 16 | 17 | body = Enum.to_list(response.body) 18 | assert Enum.count(body) == 20 19 | end 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /test/support/dialyzer.ex: -------------------------------------------------------------------------------- 1 | defmodule Tesla.Dialyzer do 2 | @moduledoc """ 3 | This module's purpose is to catch typing errors. 4 | It is compiled in test env and can be validated with 5 | 6 | MIX_ENV=test mix dialyzer 7 | """ 8 | 9 | def test_client do 10 | middleware = [ 11 | {Tesla.Middleware.BaseUrl, "url"}, 12 | {Tesla.Middleware.Headers, []}, 13 | Tesla.Middleware.JSON 14 | ] 15 | 16 | Tesla.client(middleware) 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /test/support/docs.ex: -------------------------------------------------------------------------------- 1 | defmodule TeslaDocsTest do 2 | defmodule Default do 3 | use Tesla 4 | end 5 | 6 | defmodule NoDocs do 7 | use Tesla, docs: false 8 | 9 | @doc """ 10 | Something something. 11 | """ 12 | def custom(url), do: get(url) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /test/support/mock_client.ex: -------------------------------------------------------------------------------- 1 | defmodule MockClient do 2 | use Tesla 3 | end 4 | -------------------------------------------------------------------------------- /test/support/test_support.ex: -------------------------------------------------------------------------------- 1 | defmodule TestSupport do 2 | def gzip_headers(env) do 3 | env.headers 4 | |> Enum.map_join("|", fn {key, value} -> "#{key}: #{value}" end) 5 | |> :zlib.gzip() 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /test/tesla/adapter/finch_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Tesla.Adapter.FinchTest do 2 | use ExUnit.Case 3 | 4 | @finch_name MyFinch 5 | 6 | use Tesla.AdapterCase, adapter: {Tesla.Adapter.Finch, [name: @finch_name]} 7 | use Tesla.AdapterCase.Basic 8 | use Tesla.AdapterCase.Multipart 9 | use Tesla.AdapterCase.StreamRequestBody 10 | use Tesla.AdapterCase.StreamResponseBody 11 | use Tesla.AdapterCase.SSL 12 | 13 | setup do 14 | opts = [ 15 | name: @finch_name, 16 | pools: %{ 17 | @https => [ 18 | conn_opts: [ 19 | transport_opts: [cacertfile: "#{:code.priv_dir(:httparrot)}/ssl/server-ca.crt"] 20 | ] 21 | ] 22 | } 23 | ] 24 | 25 | start_supervised!({Finch, opts}) 26 | :ok 27 | end 28 | 29 | test "Delay request" do 30 | request = %Env{ 31 | method: :head, 32 | url: "#{@http}/delay/1" 33 | } 34 | 35 | assert {:error, :timeout} = call(request, receive_timeout: 100) 36 | end 37 | 38 | test "Delay request with stream" do 39 | request = %Env{ 40 | method: :head, 41 | url: "#{@http}/delay/1" 42 | } 43 | 44 | assert {:error, :timeout} = call(request, receive_timeout: 100, response: :stream) 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /test/tesla/adapter/hackney_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Tesla.Adapter.HackneyTest do 2 | use ExUnit.Case 3 | 4 | use Tesla.AdapterCase, adapter: Tesla.Adapter.Hackney 5 | use Tesla.AdapterCase.Basic 6 | use Tesla.AdapterCase.Multipart 7 | use Tesla.AdapterCase.StreamRequestBody 8 | 9 | use Tesla.AdapterCase.SSL, 10 | ssl_options: [ 11 | verify: :verify_peer, 12 | cacertfile: Path.join([to_string(:code.priv_dir(:httparrot)), "/ssl/server-ca.crt"]) 13 | ] 14 | 15 | alias Tesla.Env 16 | 17 | test "get with `with_body: true` option" do 18 | request = %Env{ 19 | method: :get, 20 | url: "#{@http}/ip" 21 | } 22 | 23 | assert {:ok, %Env{} = response} = call(request, with_body: true) 24 | 25 | assert response.status == 200 26 | end 27 | 28 | test "get with `with_body: true` option even when async" do 29 | request = %Env{ 30 | method: :get, 31 | url: "#{@http}/ip" 32 | } 33 | 34 | assert {:ok, %Env{} = response} = call(request, with_body: true, async: true) 35 | assert response.status == 200 36 | assert is_reference(response.body) == true 37 | end 38 | 39 | test "get with `:max_body` option" do 40 | request = %Env{ 41 | method: :post, 42 | url: "#{@http}/post", 43 | body: String.duplicate("long response", 1000) 44 | } 45 | 46 | assert {:ok, %Env{} = response} = call(request, with_body: true, max_body: 100) 47 | assert response.status == 200 48 | assert byte_size(response.body) < 2000 49 | end 50 | 51 | test "request timeout error" do 52 | request = %Env{ 53 | method: :get, 54 | url: "#{@http}/delay/10", 55 | body: "test" 56 | } 57 | 58 | assert {:error, :timeout} = call(request, recv_timeout: 100) 59 | end 60 | 61 | test "stream request body: error" do 62 | body = 63 | Stream.unfold(5, fn 64 | 0 -> nil 65 | 3 -> {fn -> {:error, :fake_error} end, 2} 66 | n -> {to_string(n), n - 1} 67 | end) 68 | 69 | request = %Env{ 70 | method: :post, 71 | url: "#{@http}/post", 72 | body: body 73 | } 74 | 75 | assert {:error, :fake_error} = call(request) 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /test/tesla/adapter/httpc_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Tesla.Adapter.HttpcTest do 2 | use ExUnit.Case 3 | 4 | use Tesla.AdapterCase, adapter: Tesla.Adapter.Httpc 5 | use Tesla.AdapterCase.Basic 6 | use Tesla.AdapterCase.Multipart 7 | use Tesla.AdapterCase.StreamRequestBody 8 | 9 | use Tesla.AdapterCase.SSL, 10 | ssl: [ 11 | verify: :verify_peer, 12 | cacertfile: Path.join([to_string(:code.priv_dir(:httparrot)), "/ssl/server-ca.crt"]) 13 | ] 14 | 15 | # see https://github.com/teamon/tesla/issues/147 16 | test "Set content-type for DELETE requests" do 17 | env = %Env{ 18 | method: :delete, 19 | url: "#{@http}/delete" 20 | } 21 | 22 | env = Tesla.put_header(env, "content-type", "text/plain") 23 | 24 | assert {:ok, %Env{} = response} = call(env) 25 | assert response.status == 200 26 | 27 | {:ok, data} = Jason.decode(response.body) 28 | 29 | assert data["headers"]["content-type"] == "text/plain" 30 | end 31 | 32 | test "that get uses the correct request" do 33 | env = %Env{ 34 | method: :get, 35 | body: "", 36 | url: "#{@http}/get" 37 | } 38 | 39 | env = Tesla.put_header(env, "content-type", "text/plain") 40 | 41 | assert {:ok, %Env{} = response} = call(env) 42 | assert response.status == 200 43 | 44 | {:ok, data} = Jason.decode(response.body) 45 | 46 | assert data["headers"]["content-type"] == "text/plain" 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /test/tesla/adapter/httpc_test/profile.ex: -------------------------------------------------------------------------------- 1 | defmodule Tesla.Adapter.HttpcTest.Profile do 2 | @profile :test_profile 3 | 4 | use ExUnit.Case 5 | use Tesla.AdapterCase, adapter: {Tesla.Adapter.Httpc, profile: @profile} 6 | 7 | alias Tesla.Env 8 | 9 | setup do 10 | {:ok, _pid} = :inets.start(:httpc, profile: @profile) 11 | 12 | on_exit(fn -> :inets.stop(:httpc, @profile) end) 13 | end 14 | 15 | test "a non-default profile is used" do 16 | request = %Env{ 17 | method: :get, 18 | url: "#{@http}/ip" 19 | } 20 | 21 | assert {:ok, %Env{} = response} = call(request) 22 | assert response.status == 200 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /test/tesla/adapter/ibrowse_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Tesla.Adapter.IbrowseTest do 2 | use ExUnit.Case 3 | use Tesla.AdapterCase, adapter: Tesla.Adapter.Ibrowse 4 | use Tesla.AdapterCase.Basic 5 | use Tesla.AdapterCase.Multipart 6 | use Tesla.AdapterCase.StreamRequestBody 7 | # SSL test disabled on purpose 8 | # ibrowser seems to have a problem with "localhost" host, as explained in 9 | # https://github.com/cmullaparthi/ibrowse/issues/162 10 | # 11 | # In case of the test below it results in 12 | # {:tls_alert, {:handshake_failure, 'TLS client: In state wait_cert_cr at ssl_handshake.erl:1990 generated CLIENT ALERT: Fatal - Handshake Failure\n {bad_cert,hostname_check_failed}'}} 13 | # while the same configuration works well with other adapters. 14 | # 15 | # use Tesla.AdapterCase.SSL, 16 | # ssl_options: [ 17 | # verify: :verify_peer, 18 | # cacertfile: Path.join([to_string(:code.priv_dir(:httparrot)), "/ssl/server-ca.crt"]) 19 | # ] 20 | end 21 | -------------------------------------------------------------------------------- /test/tesla/client_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Tesla.ClientTest do 2 | use ExUnit.Case 3 | doctest Tesla.Client 4 | 5 | describe "Tesla.Client.adapter/1" do 6 | test "converts atom adapter properly" do 7 | adapter = Tesla.Adapter.Httpc 8 | 9 | client = Tesla.client([], adapter) 10 | 11 | assert adapter == Tesla.Client.adapter(client) 12 | end 13 | 14 | test "converts tuple adapter properly" do 15 | adapter = {Tesla.Adapter.Hackney, [recv_timeout: 30_000]} 16 | 17 | client = Tesla.client([], adapter) 18 | 19 | assert adapter == Tesla.Client.adapter(client) 20 | end 21 | 22 | test "converts function adapter properly" do 23 | adapter = fn env -> 24 | {:ok, %{env | body: "new"}} 25 | end 26 | 27 | client = Tesla.client([], adapter) 28 | 29 | assert adapter == Tesla.Client.adapter(client) 30 | end 31 | end 32 | 33 | test "converts nil adapter properly" do 34 | client = Tesla.client([]) 35 | 36 | assert Tesla.Client.adapter(client) == nil 37 | end 38 | 39 | describe "Tesla.Client.middleware/1" do 40 | test "converts middleware properly" do 41 | middlewares = [ 42 | FirstMiddleware, 43 | {SecondMiddleware, options: :are, fun: 1}, 44 | fn env, _next -> env end 45 | ] 46 | 47 | client = Tesla.client(middlewares) 48 | 49 | assert middlewares == Tesla.Client.middleware(client) 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /test/tesla/global_mock_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Tesla.GlobalMockTest do 2 | use ExUnit.Case, async: false 3 | 4 | setup_all do 5 | Tesla.Mock.mock_global(fn 6 | %{method: :get, url: "/list"} -> %Tesla.Env{status: 200, body: "hello"} 7 | %{method: :post, url: "/create"} -> {201, %{}, %{id: 42}} 8 | end) 9 | 10 | :ok 11 | end 12 | 13 | test "mock request from spawned process" do 14 | pid = self() 15 | spawn(fn -> send(pid, MockClient.get("/list")) end) 16 | 17 | assert_receive {:ok, %Tesla.Env{status: 200, body: "hello"}} 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /test/tesla/middleware/base_url_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Tesla.Middleware.BaseUrlTest do 2 | use ExUnit.Case, async: true 3 | alias Tesla.Env 4 | 5 | @middleware Tesla.Middleware.BaseUrl 6 | 7 | test "base without slash, empty path" do 8 | assert {:ok, env} = @middleware.call(%Env{url: ""}, [], "http://example.com") 9 | assert env.url == "http://example.com" 10 | end 11 | 12 | test "base with slash, empty path" do 13 | assert {:ok, env} = @middleware.call(%Env{url: ""}, [], "http://example.com/") 14 | assert env.url == "http://example.com/" 15 | end 16 | 17 | test "base without slash, path without slash" do 18 | assert {:ok, env} = @middleware.call(%Env{url: "path"}, [], "http://example.com") 19 | assert env.url == "http://example.com/path" 20 | end 21 | 22 | test "base without slash, path with slash" do 23 | assert {:ok, env} = @middleware.call(%Env{url: "/path"}, [], "http://example.com") 24 | assert env.url == "http://example.com/path" 25 | end 26 | 27 | test "base with slash, path without slash" do 28 | assert {:ok, env} = @middleware.call(%Env{url: "path"}, [], "http://example.com/") 29 | assert env.url == "http://example.com/path" 30 | end 31 | 32 | test "base with slash, path with slash" do 33 | assert {:ok, env} = @middleware.call(%Env{url: "/path"}, [], "http://example.com/") 34 | assert env.url == "http://example.com/path" 35 | end 36 | 37 | test "base and path without slash, empty path" do 38 | assert {:ok, env} = @middleware.call(%Env{url: ""}, [], "http://example.com/top") 39 | assert env.url == "http://example.com/top" 40 | end 41 | 42 | test "base and path with slash, empty path" do 43 | assert {:ok, env} = @middleware.call(%Env{url: ""}, [], "http://example.com/top/") 44 | assert env.url == "http://example.com/top/" 45 | end 46 | 47 | test "base and path without slash, path without slash" do 48 | assert {:ok, env} = @middleware.call(%Env{url: "path"}, [], "http://example.com/top") 49 | assert env.url == "http://example.com/top/path" 50 | end 51 | 52 | test "base and path without slash, path with slash" do 53 | assert {:ok, env} = @middleware.call(%Env{url: "/path"}, [], "http://example.com/top") 54 | assert env.url == "http://example.com/top/path" 55 | end 56 | 57 | test "base and path with slash, path without slash" do 58 | assert {:ok, env} = @middleware.call(%Env{url: "path"}, [], "http://example.com/top/") 59 | assert env.url == "http://example.com/top/path" 60 | end 61 | 62 | test "base and path with slash, path with slash" do 63 | assert {:ok, env} = @middleware.call(%Env{url: "/path"}, [], "http://example.com/top/") 64 | assert env.url == "http://example.com/top/path" 65 | end 66 | 67 | test "skip double append on http / https" do 68 | assert {:ok, env} = @middleware.call(%Env{url: "http://other.foo"}, [], "http://example.com") 69 | assert env.url == "http://other.foo" 70 | 71 | assert {:ok, env} = @middleware.call(%Env{url: "https://other.foo"}, [], "http://example.com") 72 | assert env.url == "https://other.foo" 73 | end 74 | 75 | test "skip double append on http / https in different case" do 76 | assert {:ok, env} = @middleware.call(%Env{url: "Http://other.foo"}, [], "http://example.com") 77 | assert env.url == "Http://other.foo" 78 | 79 | assert {:ok, env} = @middleware.call(%Env{url: "HTTPS://other.foo"}, [], "http://example.com") 80 | assert env.url == "HTTPS://other.foo" 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /test/tesla/middleware/basic_auth_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Tesla.Middleware.BasicAuthTest do 2 | use ExUnit.Case 3 | 4 | defmodule BasicClient do 5 | use Tesla 6 | 7 | adapter fn env -> 8 | case env.url do 9 | "/basic-auth" -> {:ok, env} 10 | end 11 | end 12 | 13 | def client(username, password, opts \\ %{}) do 14 | Tesla.client([ 15 | { 16 | Tesla.Middleware.BasicAuth, 17 | Map.merge( 18 | %{ 19 | username: username, 20 | password: password 21 | }, 22 | opts 23 | ) 24 | } 25 | ]) 26 | end 27 | 28 | def client() do 29 | Tesla.client([Tesla.Middleware.BasicAuth]) 30 | end 31 | end 32 | 33 | defmodule BasicClientPlugOptions do 34 | use Tesla 35 | plug Tesla.Middleware.BasicAuth, username: "Auth", password: "Test" 36 | 37 | adapter fn env -> 38 | case env.url do 39 | "/basic-auth" -> {:ok, env} 40 | end 41 | end 42 | end 43 | 44 | test "sends request with proper authorization header" do 45 | username = "Aladdin" 46 | password = "OpenSesame" 47 | 48 | base_64_encoded = Base.encode64("#{username}:#{password}") 49 | assert base_64_encoded == "QWxhZGRpbjpPcGVuU2VzYW1l" 50 | 51 | {:ok, request} = BasicClient.client(username, password) |> BasicClient.get("/basic-auth") 52 | auth_header = Tesla.get_header(request, "authorization") 53 | 54 | assert auth_header == "Basic #{base_64_encoded}" 55 | end 56 | 57 | test "it correctly encodes a blank username and password" do 58 | base_64_encoded = Base.encode64(":") 59 | assert base_64_encoded == "Og==" 60 | 61 | {:ok, request} = BasicClient.client() |> BasicClient.get("/basic-auth") 62 | auth_header = Tesla.get_header(request, "authorization") 63 | 64 | assert auth_header == "Basic #{base_64_encoded}" 65 | end 66 | 67 | test "username and password can be passed to plug directly" do 68 | username = "Auth" 69 | password = "Test" 70 | 71 | base_64_encoded = Base.encode64("#{username}:#{password}") 72 | assert base_64_encoded == "QXV0aDpUZXN0" 73 | 74 | {:ok, request} = BasicClientPlugOptions.get("/basic-auth") 75 | auth_header = Tesla.get_header(request, "authorization") 76 | 77 | assert auth_header == "Basic #{base_64_encoded}" 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /test/tesla/middleware/bearer_auth_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Tesla.Middleware.BearerAuthTest do 2 | use ExUnit.Case 3 | alias Tesla.Env 4 | 5 | @middleware Tesla.Middleware.BearerAuth 6 | 7 | test "adds expected headers" do 8 | assert {:ok, env} = @middleware.call(%Env{}, [], []) 9 | assert env.headers == [{"authorization", "Bearer "}] 10 | 11 | assert {:ok, env} = @middleware.call(%Env{}, [], token: "token") 12 | assert env.headers == [{"authorization", "Bearer token"}] 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /test/tesla/middleware/compression_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Tesla.Middleware.CompressionTest do 2 | use ExUnit.Case 3 | 4 | defmodule CompressionGzipRequestClient do 5 | use Tesla 6 | 7 | plug Tesla.Middleware.Compression 8 | 9 | adapter fn env -> 10 | {status, headers, body} = 11 | case env.url do 12 | "/" -> 13 | {200, [{"content-type", "text/plain"}], :zlib.gunzip(env.body)} 14 | end 15 | 16 | {:ok, %{env | status: status, headers: headers, body: body}} 17 | end 18 | end 19 | 20 | test "compress request body (gzip)" do 21 | assert {:ok, env} = CompressionGzipRequestClient.post("/", "compress request") 22 | assert env.body == "compress request" 23 | end 24 | 25 | defmodule CompressionDeflateRequestClient do 26 | use Tesla 27 | 28 | plug Tesla.Middleware.Compression, format: "deflate" 29 | 30 | adapter fn env -> 31 | {status, headers, body} = 32 | case env.url do 33 | "/" -> 34 | {200, [{"content-type", "text/plain"}], :zlib.unzip(env.body)} 35 | end 36 | 37 | {:ok, %{env | status: status, headers: headers, body: body}} 38 | end 39 | end 40 | 41 | test "compress request body (deflate)" do 42 | assert {:ok, env} = CompressionDeflateRequestClient.post("/", "compress request") 43 | assert env.body == "compress request" 44 | end 45 | 46 | defmodule CompressionResponseClient do 47 | use Tesla 48 | 49 | plug Tesla.Middleware.Compression 50 | 51 | adapter fn env -> 52 | {status, headers, body} = 53 | case env.url do 54 | "/response-gzip" -> 55 | {200, [{"content-type", "text/plain"}, {"content-encoding", "gzip"}], 56 | :zlib.gzip("decompressed gzip")} 57 | 58 | "/response-deflate" -> 59 | {200, [{"content-type", "text/plain"}, {"content-encoding", "deflate"}], 60 | :zlib.zip("decompressed deflate")} 61 | 62 | "/response-identity" -> 63 | {200, [{"content-type", "text/plain"}, {"content-encoding", "identity"}], "unchanged"} 64 | end 65 | 66 | {:ok, %{env | status: status, headers: headers, body: body}} 67 | end 68 | end 69 | 70 | test "decompress response body (gzip)" do 71 | assert {:ok, env} = CompressionResponseClient.get("/response-gzip") 72 | assert env.headers == [{"content-type", "text/plain"}] 73 | assert env.body == "decompressed gzip" 74 | end 75 | 76 | test "decompress response body (deflate)" do 77 | assert {:ok, env} = CompressionResponseClient.get("/response-deflate") 78 | assert env.body == "decompressed deflate" 79 | end 80 | 81 | test "return unchanged response for unsupported content-encoding" do 82 | assert {:ok, env} = CompressionResponseClient.get("/response-identity") 83 | assert env.body == "unchanged" 84 | assert env.headers == [{"content-type", "text/plain"}] 85 | end 86 | 87 | defmodule CompressRequestDecompressResponseClient do 88 | use Tesla 89 | 90 | plug Tesla.Middleware.CompressRequest 91 | plug Tesla.Middleware.DecompressResponse 92 | 93 | adapter fn env -> 94 | {status, headers, body} = 95 | case env.url do 96 | "/" -> 97 | {200, [{"content-type", "text/plain"}, {"content-encoding", "gzip"}], env.body} 98 | end 99 | 100 | {:ok, %{env | status: status, headers: headers, body: body}} 101 | end 102 | end 103 | 104 | test "CompressRequest / DecompressResponse work without options" do 105 | alias CompressRequestDecompressResponseClient, as: CRDRClient 106 | assert {:ok, env} = CRDRClient.post("/", "foo bar") 107 | assert env.body == "foo bar" 108 | end 109 | 110 | defmodule CompressionHeadersClient do 111 | use Tesla 112 | 113 | plug Tesla.Middleware.Compression 114 | 115 | adapter fn env -> 116 | {status, headers, body} = 117 | case env.url do 118 | "/" -> 119 | {200, [{"content-type", "text/plain"}, {"content-encoding", "gzip"}], 120 | TestSupport.gzip_headers(env)} 121 | end 122 | 123 | {:ok, %{env | status: status, headers: headers, body: body}} 124 | end 125 | end 126 | 127 | test "Compression headers" do 128 | assert {:ok, env} = CompressionHeadersClient.get("/") 129 | assert env.body == "accept-encoding: gzip, deflate, identity" 130 | end 131 | 132 | defmodule DecompressResponseHeadersClient do 133 | use Tesla 134 | 135 | plug Tesla.Middleware.DecompressResponse 136 | 137 | adapter fn env -> 138 | {status, headers, body} = 139 | case env.url do 140 | "/" -> 141 | {200, [{"content-type", "text/plain"}, {"content-encoding", "gzip"}], 142 | TestSupport.gzip_headers(env)} 143 | end 144 | 145 | {:ok, %{env | status: status, headers: headers, body: body}} 146 | end 147 | end 148 | 149 | test "Decompress response headers" do 150 | assert {:ok, env} = DecompressResponseHeadersClient.get("/") 151 | assert env.body == "accept-encoding: gzip, deflate, identity" 152 | end 153 | 154 | defmodule CompressRequestHeadersClient do 155 | use Tesla 156 | 157 | plug Tesla.Middleware.CompressRequest 158 | 159 | adapter fn env -> 160 | {status, headers, body} = 161 | case env.url do 162 | "/" -> 163 | {200, [{"content-type", "text/plain"}, {"content-encoding", "gzip"}], env.headers} 164 | end 165 | 166 | {:ok, %{env | status: status, headers: headers, body: body}} 167 | end 168 | end 169 | 170 | test "Compress request headers" do 171 | assert {:ok, env} = CompressRequestHeadersClient.get("/") 172 | assert env.body == [] 173 | end 174 | end 175 | -------------------------------------------------------------------------------- /test/tesla/middleware/decode_rels_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Tesla.Middleware.DecodeRelsTest do 2 | use ExUnit.Case 3 | 4 | defmodule Client do 5 | use Tesla 6 | 7 | plug Tesla.Middleware.DecodeRels 8 | 9 | adapter fn env -> 10 | {:ok, 11 | case env.url do 12 | "/rels-with-semi-colon-in-quote" -> 13 | Tesla.put_headers(env, [ 14 | {"link", ~s(; rel=next, 15 | ; rel="some;back", 16 | ; rel=last)} 17 | ]) 18 | 19 | "/rels-with-no-quotes" -> 20 | Tesla.put_headers(env, [ 21 | {"link", ~s(; rel=next, 22 | ; rel=last)} 23 | ]) 24 | 25 | "/rels" -> 26 | Tesla.put_headers(env, [ 27 | {"link", ~s(; rel="next", 28 | ; rel="last")} 29 | ]) 30 | 31 | _ -> 32 | env 33 | end} 34 | end 35 | end 36 | 37 | test "decode rels" do 38 | assert {:ok, env} = Client.get("/rels") 39 | 40 | assert env.opts[:rels] == %{ 41 | "next" => "https://api.github.com/resource?page=2", 42 | "last" => "https://api.github.com/resource?page=5" 43 | } 44 | 45 | assert {:ok, unquoted_env} = Client.get("/rels-with-no-quotes") 46 | 47 | assert unquoted_env.opts[:rels] == %{ 48 | "next" => "https://api.github.com/resource?page=2", 49 | "last" => "https://api.github.com/resource?page=5" 50 | } 51 | 52 | assert {:ok, unquoted_env} = Client.get("/rels-with-semi-colon-in-quote") 53 | 54 | assert unquoted_env.opts[:rels] == %{ 55 | "next" => "https://api.github.com/resource?page=2", 56 | "last" => "https://api.github.com/resource?page=5", 57 | "some;back" => "https://api.github.com/resource?page=5" 58 | } 59 | end 60 | 61 | test "skip if no Link header" do 62 | assert {:ok, env} = Client.get("/") 63 | 64 | assert env.opts[:rels] == nil 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /test/tesla/middleware/digest_auth_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Tesla.Middleware.DigestAuthTest do 2 | use ExUnit.Case, async: false 3 | 4 | defmodule DigestClient do 5 | use Tesla 6 | 7 | adapter fn env -> 8 | {:ok, 9 | cond do 10 | env.url == "/no-digest-auth" -> 11 | env 12 | 13 | Tesla.get_header(env, "authorization") -> 14 | env 15 | 16 | true -> 17 | Tesla.put_headers(env, [ 18 | {"www-authenticate", 19 | """ 20 | Digest realm="testrealm@host.com", 21 | qop="auth,auth-int", 22 | nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093", 23 | opaque="5ccc069c403ebaf9f0171e9517f40e41" 24 | """} 25 | ]) 26 | end} 27 | end 28 | 29 | def client(username, password, opts \\ %{}) do 30 | Tesla.client([ 31 | { 32 | Tesla.Middleware.DigestAuth, 33 | Map.merge( 34 | %{ 35 | username: username, 36 | password: password, 37 | cnonce_fn: fn -> "0a4f113b" end, 38 | nc: "00000001" 39 | }, 40 | opts 41 | ) 42 | } 43 | ]) 44 | end 45 | end 46 | 47 | defmodule DigestClientWithDefaults do 48 | use Tesla 49 | 50 | def client do 51 | Tesla.client([ 52 | {Tesla.Middleware.DigestAuth, nil} 53 | ]) 54 | end 55 | end 56 | 57 | test "sends request with proper authorization header" do 58 | assert {:ok, request} = 59 | DigestClient.client("Mufasa", "Circle Of Life") 60 | |> DigestClient.get("/dir/index.html") 61 | 62 | auth_header = Tesla.get_header(request, "authorization") 63 | 64 | assert auth_header =~ ~r/^Digest / 65 | assert auth_header =~ "username=\"Mufasa\"" 66 | assert auth_header =~ "realm=\"testrealm@host.com\"" 67 | assert auth_header =~ "algorithm=MD5" 68 | assert auth_header =~ "qop=auth" 69 | assert auth_header =~ "uri=\"/dir/index.html\"" 70 | assert auth_header =~ "nonce=\"dcd98b7102dd2f0e8b11d0f600bfb0c093\"" 71 | assert auth_header =~ "nc=00000001" 72 | assert auth_header =~ "cnonce=\"0a4f113b\"" 73 | assert auth_header =~ "response=\"6629fae49393a05397450978507c4ef1\"" 74 | end 75 | 76 | test "has default values for username and nc" do 77 | assert {:ok, response} = DigestClientWithDefaults.client() |> DigestClient.get("/") 78 | auth_header = Tesla.get_header(response, "authorization") 79 | 80 | assert auth_header =~ "username=\"\"" 81 | assert auth_header =~ "nc=00000000" 82 | end 83 | 84 | test "generates different cnonce with each request by default" do 85 | assert {:ok, env} = DigestClientWithDefaults.client() |> DigestClient.get("/") 86 | [_, cnonce_1 | _] = Regex.run(~r/cnonce="(.*?)"/, Tesla.get_header(env, "authorization")) 87 | 88 | assert {:ok, env} = DigestClientWithDefaults.client() |> DigestClient.get("/") 89 | [_, cnonce_2 | _] = Regex.run(~r/cnonce="(.*?)"/, Tesla.get_header(env, "authorization")) 90 | 91 | assert cnonce_1 != cnonce_2 92 | end 93 | 94 | test "works when passing custom opts" do 95 | assert {:ok, request} = 96 | DigestClientWithDefaults.client() |> DigestClient.get("/", opts: [hodor: "hodor"]) 97 | 98 | assert request.opts == [hodor: "hodor"] 99 | end 100 | 101 | test "ignores digest auth when server doesn't respond with www-authenticate header" do 102 | assert {:ok, response} = 103 | DigestClientWithDefaults.client() |> DigestClient.get("/no-digest-auth") 104 | 105 | refute Tesla.get_header(response, "authorization") 106 | end 107 | end 108 | -------------------------------------------------------------------------------- /test/tesla/middleware/form_urlencoded_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Tesla.Middleware.FormUrlencodedTest do 2 | use ExUnit.Case 3 | 4 | defmodule Client do 5 | use Tesla 6 | 7 | plug Tesla.Middleware.FormUrlencoded 8 | 9 | adapter fn env -> 10 | {status, headers, body} = 11 | case env.url do 12 | "/post" -> 13 | {201, [{"content-type", "text/html"}], env.body} 14 | 15 | "/check_incoming_content_type" -> 16 | {201, [{"content-type", "text/html"}], Tesla.get_header(env, "content-type")} 17 | 18 | "/decode_response" -> 19 | {200, [{"content-type", "application/x-www-form-urlencoded; charset=utf-8"}], 20 | "x=1&y=2"} 21 | end 22 | 23 | {:ok, %{env | status: status, headers: headers, body: body}} 24 | end 25 | end 26 | 27 | test "encode body as application/x-www-form-urlencoded" do 28 | assert {:ok, env} = Client.post("/post", %{"foo" => "%bar "}) 29 | assert URI.decode_query(env.body) == %{"foo" => "%bar "} 30 | end 31 | 32 | test "leave body alone if binary" do 33 | assert {:ok, env} = Client.post("/post", "data") 34 | assert env.body == "data" 35 | end 36 | 37 | test "check header is set as application/x-www-form-urlencoded" do 38 | assert {:ok, env} = Client.post("/check_incoming_content_type", %{"foo" => "%bar "}) 39 | assert env.body == "application/x-www-form-urlencoded" 40 | end 41 | 42 | test "decode response" do 43 | assert {:ok, env} = Client.get("/decode_response") 44 | assert env.body == %{"x" => "1", "y" => "2"} 45 | end 46 | 47 | defmodule MultipartClient do 48 | use Tesla 49 | 50 | plug Tesla.Middleware.FormUrlencoded 51 | 52 | adapter fn %{url: url, body: %Tesla.Multipart{}} = env -> 53 | {status, headers, body} = 54 | case url do 55 | "/upload" -> 56 | {200, [{"content-type", "text/html"}], "ok"} 57 | end 58 | 59 | {:ok, %{env | status: status, headers: headers, body: body}} 60 | end 61 | end 62 | 63 | test "skips encoding multipart bodies" do 64 | alias Tesla.Multipart 65 | 66 | mp = 67 | Multipart.new() 68 | |> Multipart.add_field("param", "foo") 69 | 70 | assert {:ok, env} = MultipartClient.post("/upload", mp) 71 | assert env.body == "ok" 72 | end 73 | 74 | defmodule NewEncoderClient do 75 | use Tesla 76 | 77 | def encoder(_data) do 78 | "iamencoded" 79 | end 80 | 81 | plug Tesla.Middleware.FormUrlencoded, encode: &encoder/1 82 | 83 | adapter fn env -> 84 | {status, headers, body} = 85 | case env.url do 86 | "/post" -> 87 | {201, [{"content-type", "text/html"}], env.body} 88 | end 89 | 90 | {:ok, %{env | status: status, headers: headers, body: body}} 91 | end 92 | end 93 | 94 | test "uses encoder configured in options" do 95 | {:ok, env} = NewEncoderClient.post("/post", %{"foo" => "bar"}) 96 | 97 | assert env.body == "iamencoded" 98 | end 99 | 100 | defmodule NewDecoderClient do 101 | use Tesla 102 | 103 | def decoder(_data) do 104 | "decodedbody" 105 | end 106 | 107 | plug Tesla.Middleware.FormUrlencoded, decode: &decoder/1 108 | 109 | adapter fn env -> 110 | {status, headers, body} = 111 | case env.url do 112 | "/post" -> 113 | {200, [{"content-type", "application/x-www-form-urlencoded; charset=utf-8"}], 114 | "x=1&y=2"} 115 | end 116 | 117 | {:ok, %{env | status: status, headers: headers, body: body}} 118 | end 119 | end 120 | 121 | test "uses decoder configured in options" do 122 | {:ok, env} = NewDecoderClient.post("/post", %{"foo" => "bar"}) 123 | 124 | assert env.body == "decodedbody" 125 | end 126 | 127 | describe "Encode / Decode" do 128 | defmodule EncodeDecodeFormUrlencodedClient do 129 | use Tesla 130 | 131 | plug Tesla.Middleware.DecodeFormUrlencoded 132 | plug Tesla.Middleware.EncodeFormUrlencoded 133 | 134 | adapter fn env -> 135 | {status, headers, body} = 136 | case env.url do 137 | "/foo2baz" -> 138 | {200, [{"content-type", "application/x-www-form-urlencoded"}], 139 | env.body |> String.replace("foo", "baz")} 140 | end 141 | 142 | {:ok, %{env | status: status, headers: headers, body: body}} 143 | end 144 | end 145 | 146 | test "work without options" do 147 | assert {:ok, env} = EncodeDecodeFormUrlencodedClient.post("/foo2baz", %{"foo" => "bar"}) 148 | assert env.body == %{"baz" => "bar"} 149 | end 150 | end 151 | end 152 | -------------------------------------------------------------------------------- /test/tesla/middleware/fuse_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Tesla.Middleware.FuseTest do 2 | use ExUnit.Case, async: false 3 | 4 | defmodule Report do 5 | def call(env, next, _) do 6 | send(self(), :request_made) 7 | Tesla.run(env, next) 8 | end 9 | end 10 | 11 | defmodule Client do 12 | use Tesla 13 | 14 | plug Tesla.Middleware.Fuse 15 | plug Report 16 | 17 | adapter fn env -> 18 | case env.url do 19 | "/ok" -> 20 | {:ok, env} 21 | 22 | "/unavailable" -> 23 | {:error, :econnrefused} 24 | end 25 | end 26 | end 27 | 28 | defmodule ClientWithCustomSetup do 29 | use Tesla 30 | 31 | plug Tesla.Middleware.Fuse, 32 | keep_original_error: true, 33 | should_melt: fn 34 | {:ok, %{status: status}} when status in [504] -> true 35 | {:ok, _} -> false 36 | {:error, _} -> true 37 | end 38 | 39 | plug Report 40 | 41 | adapter fn env -> 42 | case env.url do 43 | "/ok" -> 44 | {:ok, env} 45 | 46 | "/error_500" -> 47 | {:ok, %{env | status: 500}} 48 | 49 | "/error_504" -> 50 | {:ok, %{env | status: 504}} 51 | 52 | "/unavailable" -> 53 | {:error, :econnrefused} 54 | end 55 | end 56 | end 57 | 58 | setup do 59 | Application.ensure_all_started(:fuse) 60 | :fuse.reset(Client) 61 | :fuse.reset(ClientWithCustomSetup) 62 | 63 | :ok 64 | end 65 | 66 | test "regular endpoint" do 67 | assert {:ok, %Tesla.Env{url: "/ok"}} = Client.get("/ok") 68 | end 69 | 70 | test "custom should_melt function - not melting 500" do 71 | assert {:ok, %Tesla.Env{status: 500}} = ClientWithCustomSetup.get("/error_500") 72 | assert_receive :request_made 73 | assert {:ok, %Tesla.Env{status: 500}} = ClientWithCustomSetup.get("/error_500") 74 | assert_receive :request_made 75 | assert {:ok, %Tesla.Env{status: 500}} = ClientWithCustomSetup.get("/error_500") 76 | assert_receive :request_made 77 | 78 | assert {:ok, %Tesla.Env{status: 500}} = ClientWithCustomSetup.get("/error_500") 79 | assert_receive :request_made 80 | assert {:ok, %Tesla.Env{status: 500}} = ClientWithCustomSetup.get("/error_500") 81 | assert_receive :request_made 82 | end 83 | 84 | test "custom should_melt function - melting 504" do 85 | assert {:ok, %Tesla.Env{status: 504}} = ClientWithCustomSetup.get("/error_504") 86 | assert_receive :request_made 87 | assert {:ok, %Tesla.Env{status: 504}} = ClientWithCustomSetup.get("/error_504") 88 | assert_receive :request_made 89 | assert {:ok, %Tesla.Env{status: 504}} = ClientWithCustomSetup.get("/error_504") 90 | assert_receive :request_made 91 | 92 | assert {:error, :unavailable} = ClientWithCustomSetup.get("/error_504") 93 | refute_receive :request_made 94 | assert {:error, :unavailable} = ClientWithCustomSetup.get("/error_504") 95 | refute_receive :request_made 96 | end 97 | 98 | test "unavailable endpoint" do 99 | assert {:error, :unavailable} = Client.get("/unavailable") 100 | assert_receive :request_made 101 | assert {:error, :unavailable} = Client.get("/unavailable") 102 | assert_receive :request_made 103 | assert {:error, :unavailable} = Client.get("/unavailable") 104 | assert_receive :request_made 105 | 106 | assert {:error, :unavailable} = Client.get("/unavailable") 107 | refute_receive :request_made 108 | assert {:error, :unavailable} = Client.get("/unavailable") 109 | refute_receive :request_made 110 | end 111 | end 112 | -------------------------------------------------------------------------------- /test/tesla/middleware/header_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Tesla.Middleware.HeadersTest do 2 | use ExUnit.Case 3 | alias Tesla.Env 4 | 5 | @middleware Tesla.Middleware.Headers 6 | 7 | test "merge headers" do 8 | assert {:ok, env} = 9 | @middleware.call(%Env{headers: [{"authorization", "secret"}]}, [], [ 10 | {"content-type", "text/plain"} 11 | ]) 12 | 13 | assert env.headers == [{"authorization", "secret"}, {"content-type", "text/plain"}] 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /test/tesla/middleware/json/json_adapter_test.exs: -------------------------------------------------------------------------------- 1 | if Code.ensure_loaded?(JSON) do 2 | defmodule Tesla.Middleware.JSON.JSONAdapterTest do 3 | use ExUnit.Case, async: true 4 | 5 | alias Tesla.Middleware.JSON.JSONAdapter 6 | 7 | describe "encode/2" do 8 | test "encodes as expected with default encoder" do 9 | assert {:ok, ~S({"hello":"world"})} == JSONAdapter.encode(%{hello: "world"}, []) 10 | end 11 | end 12 | 13 | describe "decode/2" do 14 | test "returns {:ok, term} on success" do 15 | assert {:ok, %{"hello" => "world"}} = JSONAdapter.decode(~S({"hello":"world"}), []) 16 | end 17 | 18 | test "returns {:error, reason} on failure" do 19 | assert {:error, {:invalid_byte, _, _}} = JSONAdapter.decode("invalid_json", []) 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /test/tesla/middleware/keep_request_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Tesla.Middleware.KeepRequestTest do 2 | use ExUnit.Case 3 | 4 | @middleware Tesla.Middleware.KeepRequest 5 | 6 | test "put request metadata into opts" do 7 | env = %Tesla.Env{url: "my_url", body: "reqbody", headers: [{"x-request", "header"}]} 8 | assert {:ok, env} = @middleware.call(env, [], []) 9 | assert env.opts[:req_body] == "reqbody" 10 | assert env.opts[:req_headers] == [{"x-request", "header"}] 11 | assert env.opts[:req_url] == "my_url" 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /test/tesla/middleware/message_pack_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Tesla.Middleware.MessagePackTest do 2 | use ExUnit.Case 3 | 4 | describe "Basics" do 5 | defmodule Client do 6 | use Tesla 7 | 8 | plug Tesla.Middleware.MessagePack 9 | 10 | adapter fn env -> 11 | {status, headers, body} = 12 | case env.url do 13 | "/decode" -> 14 | {200, [{"content-type", "application/msgpack"}], Msgpax.pack!(%{"value" => 123})} 15 | 16 | "/encode" -> 17 | {200, [{"content-type", "application/msgpack"}], 18 | env.body |> String.replace("foo", "baz")} 19 | 20 | "/empty" -> 21 | {200, [{"content-type", "application/msgpack"}], nil} 22 | 23 | "/empty-string" -> 24 | {200, [{"content-type", "application/msgpack"}], ""} 25 | 26 | "/invalid-content-type" -> 27 | {200, [{"content-type", "text/plain"}], "hello"} 28 | 29 | "/invalid-msgpack-format" -> 30 | {200, [{"content-type", "application/msgpack"}], "{\"foo\": bar}"} 31 | 32 | "/raw" -> 33 | {200, [], env.body} 34 | end 35 | 36 | {:ok, %{env | status: status, headers: headers, body: body}} 37 | end 38 | end 39 | 40 | test "decode MessagePack body" do 41 | assert {:ok, env} = Client.get("/decode") 42 | assert env.body == %{"value" => 123} 43 | end 44 | 45 | test "encode body as MessagePack" do 46 | body = Msgpax.pack!(%{"foo" => "bar"}, iodata: false) 47 | assert {:ok, env} = Client.post("/encode", body) 48 | assert env.body == %{"baz" => "bar"} 49 | end 50 | 51 | test "do not decode empty body" do 52 | assert {:ok, env} = Client.get("/empty") 53 | assert env.body == nil 54 | end 55 | 56 | test "do not decode empty string body" do 57 | assert {:ok, env} = Client.get("/empty-string") 58 | assert env.body == "" 59 | end 60 | 61 | test "decode only if Content-Type is application/msgpack" do 62 | assert {:ok, env} = Client.get("/invalid-content-type") 63 | assert env.body == "hello" 64 | end 65 | 66 | test "do not encode nil body" do 67 | assert {:ok, env} = Client.post("/raw", nil) 68 | assert env.body == nil 69 | end 70 | 71 | test "do not encode binary body" do 72 | assert {:ok, env} = Client.post("/raw", "raw-string") 73 | assert env.body == "raw-string" 74 | end 75 | 76 | test "return error on encoding error" do 77 | assert {:error, {Tesla.Middleware.MessagePack, :encode, _}} = 78 | Client.post("/encode", %{pid: self()}) 79 | end 80 | 81 | test "return error when decoding invalid msgpack format" do 82 | assert {:error, {Tesla.Middleware.MessagePack, :decode, _}} = 83 | Client.get("/invalid-msgpack-format") 84 | end 85 | end 86 | 87 | describe "Custom content type" do 88 | defmodule CustomContentTypeClient do 89 | use Tesla 90 | 91 | plug Tesla.Middleware.MessagePack, decode_content_types: ["application/x-custom-msgpack"] 92 | 93 | adapter fn env -> 94 | {status, headers, body} = 95 | case env.url do 96 | "/decode" -> 97 | {200, [{"content-type", "application/x-custom-msgpack"}], 98 | Msgpax.pack!(%{"value" => 123})} 99 | end 100 | 101 | {:ok, %{env | status: status, headers: headers, body: body}} 102 | end 103 | end 104 | 105 | test "decode if Content-Type specified in :decode_content_types" do 106 | assert {:ok, env} = CustomContentTypeClient.get("/decode") 107 | assert env.body == %{"value" => 123} 108 | end 109 | end 110 | 111 | describe "EncodeMessagePack / DecodeMessagePack" do 112 | defmodule EncodeDecodeMessagePackClient do 113 | use Tesla 114 | 115 | plug Tesla.Middleware.DecodeMessagePack 116 | plug Tesla.Middleware.EncodeMessagePack 117 | 118 | adapter fn env -> 119 | {status, headers, body} = 120 | case env.url do 121 | "/foo2baz" -> 122 | {200, [{"content-type", "application/msgpack"}], 123 | env.body |> String.replace("foo", "baz")} 124 | end 125 | 126 | {:ok, %{env | status: status, headers: headers, body: body}} 127 | end 128 | end 129 | 130 | test "EncodeMessagePack / DecodeMessagePack work without options" do 131 | body = Msgpax.pack!(%{"foo" => "bar"}, iodata: false) 132 | assert {:ok, env} = EncodeDecodeMessagePackClient.post("/foo2baz", body) 133 | assert env.body == %{"baz" => "bar"} 134 | end 135 | end 136 | end 137 | -------------------------------------------------------------------------------- /test/tesla/middleware/method_override_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Tesla.Middleware.MethodOverrideTest do 2 | use ExUnit.Case 3 | 4 | defmodule Client do 5 | use Tesla 6 | 7 | plug Tesla.Middleware.MethodOverride 8 | 9 | adapter fn env -> 10 | status = 11 | case env do 12 | %{method: :get} -> 200 13 | %{method: :post} -> 201 14 | %{method: _} -> 400 15 | end 16 | 17 | {:ok, %{env | status: status}} 18 | end 19 | end 20 | 21 | test "when method is get" do 22 | assert {:ok, response} = Client.get("/") 23 | 24 | assert response.status == 200 25 | refute Tesla.get_header(response, "x-http-method-override") 26 | end 27 | 28 | test "when method is post" do 29 | assert {:ok, response} = Client.post("/", "") 30 | 31 | assert response.status == 201 32 | refute Tesla.get_header(response, "x-http-method-override") 33 | end 34 | 35 | test "when method isn't get or post" do 36 | assert {:ok, response} = Client.put("/", "") 37 | 38 | assert response.status == 201 39 | assert Tesla.get_header(response, "x-http-method-override") == "put" 40 | end 41 | 42 | defmodule CustomClient do 43 | use Tesla 44 | 45 | plug Tesla.Middleware.MethodOverride, override: ~w(put)a 46 | 47 | adapter fn env -> 48 | status = 49 | case env do 50 | %{method: :get} -> 200 51 | %{method: :post} -> 201 52 | %{method: _} -> 400 53 | end 54 | 55 | {:ok, %{env | status: status}} 56 | end 57 | end 58 | 59 | test "when method in override list" do 60 | assert {:ok, response} = CustomClient.put("/", "") 61 | 62 | assert response.status == 201 63 | assert Tesla.get_header(response, "x-http-method-override") == "put" 64 | end 65 | 66 | test "when method not in override list" do 67 | assert {:ok, response} = CustomClient.patch("/", "") 68 | 69 | assert response.status == 400 70 | refute Tesla.get_header(response, "x-http-method-override") 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /test/tesla/middleware/opts_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Tesla.Middleware.OptsTest do 2 | use ExUnit.Case 3 | 4 | defmodule Client do 5 | use Tesla 6 | @api_key "some_key" 7 | 8 | plug Tesla.Middleware.Opts, attr: %{"Authorization" => @api_key} 9 | plug Tesla.Middleware.Opts, list: ["a", "b", "c"], int: 123 10 | plug Tesla.Middleware.Opts, fun: fn x -> x * 2 end 11 | 12 | adapter fn env -> env end 13 | end 14 | 15 | defmodule BaseProxy do 16 | defmacro __using__(_opts) do 17 | quote do 18 | plug Tesla.Middleware.Opts, 19 | adapter: [ 20 | proxy: "https://proxy.example.com:8080", 21 | proxy_auth: {"admin", "secret"} 22 | ] 23 | end 24 | end 25 | end 26 | 27 | defmodule MergeAdapterOptsClient do 28 | use Tesla 29 | use BaseProxy 30 | 31 | plug Tesla.Middleware.Opts, adapter: [ssl_options: [{:versions, [:tlsv1]}]] 32 | adapter fn env -> env end 33 | end 34 | 35 | test "merge adapter opts" do 36 | env = MergeAdapterOptsClient.get("/") 37 | 38 | assert env.opts == [ 39 | adapter: [ 40 | proxy: "https://proxy.example.com:8080", 41 | proxy_auth: {"admin", "secret"}, 42 | ssl_options: [versions: [:tlsv1]] 43 | ] 44 | ] 45 | end 46 | 47 | test "apply middleware options" do 48 | env = Client.get("/") 49 | 50 | assert env.opts[:attr] == %{"Authorization" => "some_key"} 51 | assert env.opts[:int] == 123 52 | assert env.opts[:list] == ["a", "b", "c"] 53 | assert env.opts[:fun].(4) == 8 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /test/tesla/middleware/query_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Tesla.Middleware.QueryTest do 2 | use ExUnit.Case 3 | alias Tesla.Env 4 | 5 | @middleware Tesla.Middleware.Query 6 | 7 | test "joining default query params" do 8 | assert {:ok, env} = @middleware.call(%Env{}, [], page: 1) 9 | assert env.query == [page: 1] 10 | end 11 | 12 | test "should not override existing key" do 13 | assert {:ok, env} = @middleware.call(%Env{query: [page: 1]}, [], page: 5) 14 | assert env.query == [page: 1, page: 5] 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /test/tesla/middleware/sse_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Tesla.Middleware.SSETest do 2 | use ExUnit.Case 3 | 4 | @env %Tesla.Env{ 5 | status: 200, 6 | headers: [{"content-type", "text/event-stream"}] 7 | } 8 | 9 | describe "Basics" do 10 | test "ignore not matching content-type" do 11 | adapter = fn _env -> 12 | {:ok, %Tesla.Env{headers: [{"content-type", "text/plain"}], body: "test"}} 13 | end 14 | 15 | assert {:ok, env} = Tesla.Middleware.SSE.call(%Tesla.Env{}, [{:fn, adapter}], []) 16 | assert env.body == "test" 17 | end 18 | 19 | test "decode comment" do 20 | adapter = fn _env -> 21 | {:ok, %{@env | body: ": comment"}} 22 | end 23 | 24 | assert {:ok, env} = Tesla.Middleware.SSE.call(%Tesla.Env{}, [{:fn, adapter}], []) 25 | assert env.body == [%{comment: "comment"}] 26 | end 27 | 28 | test "decode multiple messages" do 29 | body = """ 30 | : this is a test stream 31 | 32 | data: some text 33 | 34 | data: another message 35 | data: with two lines 36 | """ 37 | 38 | adapter = fn _env -> 39 | {:ok, %{@env | body: body}} 40 | end 41 | 42 | assert {:ok, env} = Tesla.Middleware.SSE.call(%Tesla.Env{}, [{:fn, adapter}], []) 43 | 44 | assert env.body == [ 45 | %{comment: "this is a test stream"}, 46 | %{data: "some text"}, 47 | %{data: "another message\nwith two lines"} 48 | ] 49 | end 50 | 51 | test "decode named events" do 52 | body = """ 53 | event: userconnect 54 | data: {"username": "bobby", "time": "02:33:48"} 55 | 56 | data: Here's a system message of some kind that will get used 57 | data: to accomplish some task. 58 | 59 | event: usermessage 60 | data: {"username": "bobby", "time": "02:34:11", "text": "Hi everyone."} 61 | """ 62 | 63 | adapter = fn _env -> 64 | {:ok, %{@env | body: body}} 65 | end 66 | 67 | assert {:ok, env} = Tesla.Middleware.SSE.call(%Tesla.Env{}, [{:fn, adapter}], []) 68 | 69 | assert env.body == [ 70 | %{event: "userconnect", data: ~s|{"username": "bobby", "time": "02:33:48"}|}, 71 | %{ 72 | data: 73 | "Here's a system message of some kind that will get used\nto accomplish some task." 74 | }, 75 | %{ 76 | event: "usermessage", 77 | data: ~s|{"username": "bobby", "time": "02:34:11", "text": "Hi everyone."}| 78 | } 79 | ] 80 | end 81 | 82 | test "output only data" do 83 | body = """ 84 | : comment1 85 | 86 | event: userconnect 87 | data: data1 88 | 89 | data: data2 90 | data: data3 91 | 92 | event: usermessage 93 | data: data4 94 | """ 95 | 96 | adapter = fn _env -> 97 | {:ok, %{@env | body: body}} 98 | end 99 | 100 | assert {:ok, env} = Tesla.Middleware.SSE.call(%Tesla.Env{}, [{:fn, adapter}], only: :data) 101 | 102 | assert env.body == ["data1", "data2\ndata3", "data4"] 103 | end 104 | 105 | test "handle stream data" do 106 | adapter = fn _env -> 107 | chunks = [ 108 | ~s|dat|, 109 | ~s|a: dat|, 110 | ~s|a1\n\ndata: data2\n\ndata: d|, 111 | ~s|ata3\n\n| 112 | ] 113 | 114 | stream = Stream.map(chunks, & &1) 115 | 116 | {:ok, %{@env | body: stream}} 117 | end 118 | 119 | assert {:ok, env} = Tesla.Middleware.SSE.call(%Tesla.Env{}, [{:fn, adapter}], []) 120 | 121 | assert Enum.to_list(env.body) == [%{data: "data1"}, %{data: "data2"}, %{data: "data3"}] 122 | end 123 | 124 | test "handle stream data with varying line terminators" do 125 | adapter = fn _env -> 126 | chunks = [ 127 | ~s|data: data1\n|, 128 | ~s|\ndata: data2\r|, 129 | ~s|\r\ndata: data3\r\revent: event4\r|, 130 | ~s|\ndata: data4\n\n| 131 | ] 132 | 133 | stream = Stream.map(chunks, & &1) 134 | 135 | {:ok, %{@env | body: stream}} 136 | end 137 | 138 | assert {:ok, env} = Tesla.Middleware.SSE.call(%Tesla.Env{}, [{:fn, adapter}], []) 139 | 140 | assert Enum.to_list(env.body) == [ 141 | %{data: "data1"}, 142 | %{data: "data2"}, 143 | %{data: "data3"}, 144 | %{event: "event4", data: "data4"} 145 | ] 146 | end 147 | end 148 | end 149 | -------------------------------------------------------------------------------- /test/tesla/middleware/telemetry_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Tesla.Middleware.TelemetryTest do 2 | use ExUnit.Case, async: true 3 | 4 | defmodule Client do 5 | use Tesla 6 | 7 | plug(Tesla.Middleware.KeepRequest) 8 | plug(Tesla.Middleware.Telemetry, metadata: %{custom: "meta"}) 9 | plug(Tesla.Middleware.PathParams) 10 | 11 | adapter(fn env -> 12 | case env.url do 13 | "/telemetry_error" -> {:error, :econnrefused} 14 | "/telemetry_exception" -> raise "some exception" 15 | "/telemetry" <> _ -> {:ok, env} 16 | end 17 | end) 18 | end 19 | 20 | setup do 21 | Application.ensure_all_started(:telemetry) 22 | 23 | on_exit(fn -> 24 | :telemetry.list_handlers([]) 25 | |> Enum.each(&:telemetry.detach(&1.id)) 26 | end) 27 | 28 | :ok 29 | end 30 | 31 | test "events are all emitted properly" do 32 | Enum.each(["/telemetry", "/telemetry_error"], fn path -> 33 | :telemetry.attach("start event", [:tesla, :request, :start], &__MODULE__.echo_event/4, %{ 34 | caller: self() 35 | }) 36 | 37 | :telemetry.attach("stop event", [:tesla, :request, :stop], &__MODULE__.echo_event/4, %{ 38 | caller: self() 39 | }) 40 | 41 | :telemetry.attach("legacy event", [:tesla, :request], &__MODULE__.echo_event/4, %{ 42 | caller: self() 43 | }) 44 | 45 | Client.get(path) 46 | 47 | assert_receive {:event, [:tesla, :request, :start], %{system_time: _time}, metadata} 48 | assert %{env: %Tesla.Env{url: ^path, method: :get}, custom: "meta"} = metadata 49 | 50 | assert_receive {:event, [:tesla, :request, :stop], %{duration: _time}, metadata} 51 | assert %{env: %Tesla.Env{url: ^path, method: :get}, custom: "meta"} = metadata 52 | 53 | assert_receive {:event, [:tesla, :request], %{request_time: _time}, %{result: _result}} 54 | end) 55 | end 56 | 57 | test "with an exception raised" do 58 | :telemetry.attach( 59 | "with_exception", 60 | [:tesla, :request, :exception], 61 | &__MODULE__.echo_event/4, 62 | %{caller: self()} 63 | ) 64 | 65 | assert_raise RuntimeError, fn -> 66 | Client.get("/telemetry_exception") 67 | end 68 | 69 | assert_receive {:event, [:tesla, :request, :exception], %{duration: _time}, metadata} 70 | 71 | assert %{ 72 | env: _env, 73 | kind: _kind, 74 | reason: _reason, 75 | stacktrace: _stacktrace, 76 | custom: "meta" 77 | } = metadata 78 | end 79 | 80 | test "middleware works in tandem with PathParams and KeepRequest" do 81 | path = "/telemetry/:event_id" 82 | 83 | :telemetry.attach("start event", [:tesla, :request, :start], &__MODULE__.echo_event/4, %{ 84 | caller: self() 85 | }) 86 | 87 | :telemetry.attach("stop event", [:tesla, :request, :stop], &__MODULE__.echo_event/4, %{ 88 | caller: self() 89 | }) 90 | 91 | :telemetry.attach("legacy event", [:tesla, :request], &__MODULE__.echo_event/4, %{ 92 | caller: self() 93 | }) 94 | 95 | resolved_path = "/telemetry/my-custom-id" 96 | Client.get(path, opts: [path_params: [event_id: "my-custom-id"]]) 97 | 98 | assert_receive {:event, [:tesla, :request, :start], %{system_time: _time}, metadata} 99 | assert %{env: %Tesla.Env{url: ^path, method: :get}, custom: "meta"} = metadata 100 | 101 | assert_receive {:event, [:tesla, :request, :stop], %{duration: _time}, metadata} 102 | 103 | assert %{ 104 | env: %Tesla.Env{opts: opts, url: ^resolved_path, method: :get}, 105 | custom: "meta" 106 | } = metadata 107 | 108 | # This ensures that the unresolved PathParams template url is available 109 | # for telemetry event metadata handling 110 | assert path == opts[:req_url] 111 | 112 | assert_receive {:event, [:tesla, :request], %{request_time: _time}, %{result: _result}} 113 | end 114 | 115 | def echo_event(event, measurements, metadata, config) do 116 | send(config.caller, {:event, event, measurements, metadata}) 117 | end 118 | end 119 | -------------------------------------------------------------------------------- /test/tesla/middleware/timeout_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Tesla.Middleware.TimeoutTest do 2 | use ExUnit.Case, async: false 3 | 4 | defmodule Client do 5 | use Tesla 6 | 7 | plug Tesla.Middleware.Timeout, timeout: 100 8 | 9 | adapter fn env -> 10 | case env.url do 11 | "/sleep_50ms" -> 12 | Process.sleep(50) 13 | {:ok, %{env | status: 200}} 14 | 15 | "/sleep_150ms" -> 16 | Process.sleep(150) 17 | {:ok, %{env | status: 200}} 18 | 19 | "/error" -> 20 | {:error, :adapter_error} 21 | 22 | "/raise" -> 23 | raise "custom_exception" 24 | 25 | "/throw" -> 26 | throw(:throw_value) 27 | 28 | "/exit" -> 29 | exit(:exit_value) 30 | end 31 | end 32 | end 33 | 34 | defmodule DefaultTimeoutClient do 35 | use Tesla 36 | 37 | plug Tesla.Middleware.Timeout 38 | 39 | adapter fn env -> 40 | case env.url do 41 | "/sleep_950ms" -> 42 | Process.sleep(950) 43 | {:ok, %{env | status: 200}} 44 | 45 | "/sleep_1050ms" -> 46 | Process.sleep(1_050) 47 | {:ok, %{env | status: 200}} 48 | end 49 | end 50 | end 51 | 52 | defmodule OtelTimeoutClient do 53 | use Tesla 54 | 55 | plug Tesla.Middleware.Timeout, 56 | timeout: 100, 57 | task_module: OpentelemetryProcessPropagator.Task 58 | 59 | adapter fn env -> 60 | case env.url do 61 | "/sleep_50ms" -> 62 | Process.sleep(50) 63 | {:ok, %{env | status: 200}} 64 | 65 | "/sleep_150ms" -> 66 | Process.sleep(150) 67 | {:ok, %{env | status: 200}} 68 | end 69 | end 70 | end 71 | 72 | describe "using custom timeout (100ms)" do 73 | test "should return timeout error when the stack timeout" do 74 | assert {:error, :timeout} = Client.get("/sleep_150ms") 75 | end 76 | 77 | test "should return the response when not timeout" do 78 | assert {:ok, %Tesla.Env{status: 200}} = Client.get("/sleep_50ms") 79 | end 80 | 81 | test "should not kill calling process" do 82 | Process.flag(:trap_exit, true) 83 | 84 | pid = 85 | spawn_link(fn -> 86 | assert {:error, :timeout} = Client.get("/sleep_150ms") 87 | end) 88 | 89 | assert_receive {:EXIT, ^pid, :normal}, 200 90 | end 91 | end 92 | 93 | describe "using default timeout (1_000ms)" do 94 | test "should raise a Tesla.Error when the stack timeout" do 95 | assert {:error, :timeout} = DefaultTimeoutClient.get("/sleep_1050ms") 96 | end 97 | 98 | test "should return the response when not timeout" do 99 | assert {:ok, %Tesla.Env{status: 200}} = DefaultTimeoutClient.get("/sleep_950ms") 100 | end 101 | end 102 | 103 | describe "repassing errors and exit" do 104 | test "should repass rescued errors" do 105 | assert_raise RuntimeError, "custom_exception", fn -> 106 | Client.get("/raise") 107 | end 108 | end 109 | 110 | test "should keep original stacktrace information" do 111 | try do 112 | Client.get("/raise") 113 | rescue 114 | _ in RuntimeError -> 115 | [{last_module, _, _, file_info} | _] = __STACKTRACE__ 116 | 117 | assert Tesla.Middleware.TimeoutTest.Client == last_module 118 | assert file_info[:file] == ~c"lib/tesla/builder.ex" 119 | assert file_info[:line] == 23 120 | else 121 | _ -> 122 | flunk("Expected exception to be thrown") 123 | end 124 | end 125 | 126 | test "should add timeout module info to stacktrace" do 127 | try do 128 | Client.get("/raise") 129 | rescue 130 | _ in RuntimeError -> 131 | [_, {timeout_module, _, _, module_file_info} | _] = __STACKTRACE__ 132 | 133 | assert Tesla.Middleware.Timeout == timeout_module 134 | assert module_file_info == [file: ~c"lib/tesla/middleware/timeout.ex", line: 68] 135 | else 136 | _ -> 137 | flunk("Expected exception to be thrown") 138 | end 139 | end 140 | 141 | test "should repass thrown value" do 142 | assert catch_throw(Client.get("/throw")) == :throw_value 143 | end 144 | 145 | test "should repass exit value" do 146 | assert catch_exit(Client.get("/exit")) == :exit_value 147 | end 148 | end 149 | 150 | describe "swapping task_module for OpentelemetryProcessPropagator.Task" do 151 | test "should return timeout error when the stack timeout" do 152 | assert {:error, :timeout} = OtelTimeoutClient.get("/sleep_150ms") 153 | end 154 | 155 | test "should return the response when not timeout" do 156 | assert {:ok, %Tesla.Env{status: 200}} = OtelTimeoutClient.get("/sleep_50ms") 157 | end 158 | 159 | test "should not kill calling process" do 160 | Process.flag(:trap_exit, true) 161 | 162 | pid = 163 | spawn_link(fn -> 164 | assert {:error, :timeout} = Client.get("/sleep_150ms") 165 | end) 166 | 167 | assert_receive {:EXIT, ^pid, :normal}, 200 168 | end 169 | end 170 | end 171 | -------------------------------------------------------------------------------- /test/tesla/mock/global_a_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Tesla.Mock.GlobalATest do 2 | use ExUnit.Case, async: false 3 | 4 | setup_all do 5 | Tesla.Mock.mock_global(fn _env -> %Tesla.Env{status: 200, body: "AAA"} end) 6 | 7 | :ok 8 | end 9 | 10 | test "mock get request" do 11 | assert {:ok, %Tesla.Env{} = env} = MockClient.get("/") 12 | assert env.body == "AAA" 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /test/tesla/mock/global_b_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Tesla.Mock.GlobalBTest do 2 | use ExUnit.Case, async: false 3 | 4 | setup_all do 5 | Tesla.Mock.mock_global(fn _env -> %Tesla.Env{status: 200, body: "BBB"} end) 6 | 7 | :ok 8 | end 9 | 10 | test "mock get request" do 11 | assert {:ok, %Tesla.Env{} = env} = MockClient.get("/") 12 | assert env.body == "BBB" 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /test/tesla/mock/local_a_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Tesla.Mock.LocalATest do 2 | use ExUnit.Case, async: true 3 | 4 | setup do 5 | Tesla.Mock.mock(fn _env -> %Tesla.Env{status: 200, body: "AAA"} end) 6 | 7 | :ok 8 | end 9 | 10 | test "mock get request" do 11 | assert {:ok, %Tesla.Env{} = env} = MockClient.get("/") 12 | assert env.body == "AAA" 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /test/tesla/mock/local_b_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Tesla.Mock.LocalBTest do 2 | use ExUnit.Case, async: true 3 | 4 | setup do 5 | Tesla.Mock.mock(fn _env -> %Tesla.Env{status: 200, body: "BBB"} end) 6 | 7 | :ok 8 | end 9 | 10 | test "mock get request" do 11 | assert {:ok, %Tesla.Env{} = env} = MockClient.get("/") 12 | assert env.body == "BBB" 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /test/tesla/mock_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Tesla.MockTest do 2 | use ExUnit.Case 3 | 4 | defmodule Client do 5 | use Tesla 6 | plug Tesla.Middleware.JSON 7 | end 8 | 9 | Application.put_env(:tesla, Tesla.MockTest.Client, adapter: Tesla.Mock) 10 | 11 | import Tesla.Mock 12 | 13 | defp setup_mock(_) do 14 | mock(fn 15 | %{url: "/ok-tuple"} -> 16 | {:ok, %Tesla.Env{status: 200, body: "hello tuple"}} 17 | 18 | %{url: "/tuple"} -> 19 | {201, [{"content-type", "application/json"}], ~s"{\"id\":42}"} 20 | 21 | %{url: "/env"} -> 22 | %Tesla.Env{status: 200, body: "hello env"} 23 | 24 | %{url: "/error"} -> 25 | {:error, :some_error} 26 | 27 | %{url: "/other"} -> 28 | :econnrefused 29 | 30 | %{url: "/json"} -> 31 | json(%{json: 123}) 32 | 33 | %{method: :post, url: "/match-body", body: ~s({"some":"data"})} -> 34 | {201, [{"content-type", "application/json"}], ~s"{\"id\":42}"} 35 | end) 36 | 37 | :ok 38 | end 39 | 40 | describe "with mock" do 41 | setup :setup_mock 42 | 43 | test "raise on unmocked request" do 44 | assert_raise Tesla.Mock.Error, fn -> 45 | Client.get("/unmocked") 46 | end 47 | end 48 | 49 | test "return {:ok, env} tuple" do 50 | assert {:ok, %Tesla.Env{} = env} = Client.get("/ok-tuple") 51 | assert env.status == 200 52 | assert env.body == "hello tuple" 53 | end 54 | 55 | test "return {status, headers, body} tuple" do 56 | assert {:ok, %Tesla.Env{} = env} = Client.get("/tuple") 57 | assert env.status == 201 58 | assert env.headers == [{"content-type", "application/json"}] 59 | assert env.body == %{"id" => 42} 60 | end 61 | 62 | test "return env" do 63 | assert {:ok, %Tesla.Env{} = env} = Client.get("/env") 64 | assert env.status == 200 65 | assert env.body == "hello env" 66 | end 67 | 68 | test "return {:error, reason} tuple" do 69 | assert {:error, :some_error} = Client.get("/error") 70 | end 71 | 72 | test "return other error" do 73 | assert {:error, :econnrefused} = Client.get("/other") 74 | end 75 | 76 | test "return json" do 77 | assert {:ok, %Tesla.Env{} = env} = Client.get("/json") 78 | assert env.status == 200 79 | assert env.body == %{"json" => 123} 80 | end 81 | 82 | test "mock post request" do 83 | assert {:ok, %Tesla.Env{} = env} = Client.post("/match-body", %{"some" => "data"}) 84 | assert env.status == 201 85 | assert env.body == %{"id" => 42} 86 | end 87 | 88 | test "mock a request inside a child process" do 89 | child_task = 90 | Task.async(fn -> 91 | assert {:ok, %Tesla.Env{} = env} = Client.get("/json") 92 | assert env.status == 200 93 | assert env.body == %{"json" => 123} 94 | end) 95 | 96 | Task.await(child_task) 97 | end 98 | 99 | test "mock a request inside a grandchild process" do 100 | grandchild_task = 101 | Task.async(fn -> 102 | child_task = 103 | Task.async(fn -> 104 | assert {:ok, %Tesla.Env{} = env} = Client.get("/json") 105 | assert env.status == 200 106 | assert env.body == %{"json" => 123} 107 | end) 108 | 109 | Task.await(child_task) 110 | end) 111 | 112 | Task.await(grandchild_task) 113 | end 114 | end 115 | 116 | describe "supervised task" do 117 | test "allows mocking in the caller process" do 118 | # in real apps, task supervisor will be part of the supervision tree 119 | # and it won't be an ancestor of the test process 120 | # to simulate that, we will set the mock in a task 121 | # 122 | # test_process 123 | # |-mocking_task will set the mock and create the supervised task 124 | # `-task supervisor 125 | # `- supervised_task 126 | # this way, mocking_task is not an $ancestor of the supervised_task 127 | # but it is $caller 128 | {:ok, supervisor_pid} = start_supervised(Task.Supervisor, restart: :temporary) 129 | 130 | mocking_task = 131 | Task.async(fn -> 132 | mock(fn 133 | %{url: "/callers-test"} -> 134 | {:ok, %Tesla.Env{status: 200, body: "callers work"}} 135 | end) 136 | 137 | supervised_task = 138 | Task.Supervisor.async(supervisor_pid, fn -> 139 | assert {:ok, %Tesla.Env{} = env} = Client.get("/callers-test") 140 | assert env.status == 200 141 | assert env.body == "callers work" 142 | end) 143 | 144 | Task.await(supervised_task) 145 | end) 146 | 147 | Task.await(mocking_task) 148 | end 149 | end 150 | 151 | describe "agent" do 152 | defmodule MyAgent do 153 | use Agent 154 | 155 | def start_link(_arg) do 156 | Agent.start_link(fn -> Client.get!("/ancestors-test") end, name: __MODULE__) 157 | end 158 | end 159 | 160 | # TODO: the standard way is to just look in $callers. 161 | # However, we were using $ancestors before and users were depending on that behaviour 162 | # https://github.com/elixir-tesla/tesla/issues/765 163 | # To make sure we don't break existing flows, 164 | # we will check mocks *both* in $callers and $ancestors 165 | # We might want to remove checking in $ancestors in a major release 166 | test "allows mocking in the ancestor" do 167 | mock(fn 168 | %{url: "/ancestors-test"} -> 169 | {:ok, %Tesla.Env{status: 200, body: "ancestors work"}} 170 | end) 171 | 172 | {:ok, _pid} = MyAgent.start_link([]) 173 | end 174 | end 175 | 176 | describe "without mock" do 177 | test "raise on unmocked request" do 178 | assert_raise Tesla.Mock.Error, fn -> 179 | Client.get("/return-env") 180 | end 181 | end 182 | end 183 | 184 | describe "json/2" do 185 | test "defaults" do 186 | assert %Tesla.Env{status: 200, headers: [{"content-type", "application/json"}]} = 187 | json("data") 188 | end 189 | 190 | test "custom status" do 191 | assert %Tesla.Env{status: 404} = json("data", status: 404) 192 | end 193 | end 194 | end 195 | -------------------------------------------------------------------------------- /test/tesla/multipart_test_file.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | echo "test multipart file" 3 | -------------------------------------------------------------------------------- /test/tesla/test_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Tesla.TestTest do 2 | use ExUnit.Case, async: true 3 | 4 | require Tesla.Test 5 | 6 | describe "html/2" do 7 | test "sets correct body and content-type header" do 8 | env = Tesla.Test.html(%Tesla.Env{}, "Hello, world!") 9 | assert env.body == "Hello, world!" 10 | assert env.headers == [{"content-type", "text/html; charset=utf-8"}] 11 | end 12 | end 13 | 14 | describe "json/2" do 15 | test "encodes map to JSON and sets correct content-type header" do 16 | env = Tesla.Test.json(%Tesla.Env{}, %{"some" => "data"}) 17 | assert env.body == ~s({"some":"data"}) 18 | assert env.headers == [{"content-type", "application/json; charset=utf-8"}] 19 | end 20 | 21 | test "does not encode string input" do 22 | env = Tesla.Test.json(%Tesla.Env{}, "Hello, world!") 23 | assert env.body == "Hello, world!" 24 | assert env.headers == [{"content-type", "application/json; charset=utf-8"}] 25 | end 26 | end 27 | 28 | describe "text/2" do 29 | test "sets correct body and content-type header" do 30 | env = Tesla.Test.text(%Tesla.Env{}, "Hello, world!") 31 | assert env.body == "Hello, world!" 32 | assert env.headers == [{"content-type", "text/plain; charset=utf-8"}] 33 | end 34 | end 35 | 36 | describe "assert_tesla_env/2" do 37 | test "excludes specified headers" do 38 | given = 39 | %Tesla.Env{} 40 | |> Tesla.Test.html("Hello, world!") 41 | |> Tesla.put_header( 42 | "traceparent", 43 | "00-0af7651916cd432186f12bf56043aa3d-b7ad6b7169203331-01" 44 | ) 45 | 46 | expected = Tesla.Test.html(%Tesla.Env{}, "Hello, world!") 47 | 48 | Tesla.Test.assert_tesla_env(given, expected, exclude_headers: ["traceparent"]) 49 | end 50 | 51 | test "decodes application/json body" do 52 | given = Tesla.Test.json(%Tesla.Env{}, %{some: "data"}) 53 | expected = Tesla.Test.json(%Tesla.Env{}, %{some: "data"}) 54 | Tesla.Test.assert_tesla_env(given, expected) 55 | end 56 | 57 | test "compares JSON string with decoded map" do 58 | given = Tesla.Test.json(%Tesla.Env{}, %{hello: "world"}) 59 | expected = Tesla.Test.json(%Tesla.Env{}, ~s({"hello":"world"})) 60 | Tesla.Test.assert_tesla_env(given, expected) 61 | end 62 | end 63 | 64 | describe "assert_tesla_empty_mailbox/0" do 65 | test "passes when mailbox is empty" do 66 | Tesla.Test.assert_tesla_empty_mailbox() 67 | end 68 | 69 | test "fails when mailbox is not empty" do 70 | send(self(), {Tesla.Test, :operation}) 71 | 72 | assert_raise ExUnit.AssertionError, fn -> 73 | Tesla.Test.assert_tesla_empty_mailbox() 74 | end 75 | end 76 | end 77 | 78 | describe "assert_received_tesla_call/3" do 79 | test "passes when expected message is received" do 80 | send(self(), {Tesla.Test, {Tesla.TeslaMox, :call, [%Tesla.Env{status: 200}, []]}}) 81 | 82 | Tesla.Test.assert_received_tesla_call(given_env, given_opts, adapter: Tesla.TeslaMox) 83 | assert given_env == %Tesla.Env{status: 200} 84 | assert given_opts == [] 85 | end 86 | 87 | test "fails when no message is received" do 88 | assert_raise ExUnit.AssertionError, fn -> 89 | Tesla.Test.assert_received_tesla_call(%Tesla.Env{}, [], adapter: Tesla.TeslaMox) 90 | end 91 | end 92 | 93 | test "fails when received message does not match expected pattern" do 94 | send( 95 | self(), 96 | {Tesla.Test, {Tesla.TeslaMox, :call, [%Tesla.Env{url: "https://example.com"}, []]}} 97 | ) 98 | 99 | assert_raise ExUnit.AssertionError, fn -> 100 | Tesla.Test.assert_received_tesla_call(%Tesla.Env{url: "https://acme.com"}, [], 101 | adapter: Tesla.TeslaMox 102 | ) 103 | end 104 | end 105 | end 106 | end 107 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | clients = [:ibrowse, :hackney, :gun, :finch, :castore, :mint] 2 | Enum.map(clients, &Application.ensure_all_started/1) 3 | ExUnit.start() 4 | --------------------------------------------------------------------------------