diff --git a/lib/tesla/middleware/path_params.ex b/lib/tesla/middleware/path_params.ex index 81dc4c21..e9b98b9a 100644 --- a/lib/tesla/middleware/path_params.ex +++ b/lib/tesla/middleware/path_params.ex @@ -1,12 +1,33 @@ defmodule Tesla.Middleware.PathParams do @moduledoc """ - Use templated URLs with separate params. + Use templated URLs with provided parameters in either Phoenix style (`:id`) + or OpenAPI style (`{id}`). - Useful when logging or reporting metric per URL. + Useful when logging or reporting metrics per URL. + + ## Parameter Values + + Parameter values may be `t:struct/0` or must implement the `Enumerable` + protocol and produce `{key, value}` tuples when enumerated. + + ## Parameter Name Restrictions + + Phoenix style parameters may contain letters, numbers, or underscores, + matching this regular expression: + + :[a-zA-Z][_a-zA-Z0-9]*\b + + OpenAPI style parameters may contain letters, numbers, underscores, or + hyphens (`-`), matching this regular expression: + + \{[a-zA-Z][-_a-zA-Z0-9]*\} + + In either case, parameters that begin with underscores (`_`), hyphens (`-`), + or numbers (`0-9`) are ignored and left as-is. ## Examples - ``` + ```elixir defmodule MyClient do use Tesla @@ -16,7 +37,12 @@ defmodule Tesla.Middleware.PathParams do def user(id) do params = [id: id] - get("/users/:id", opts: [path_params: params]) + get("/users/{id}", opts: [path_params: params]) + end + + def posts(id, post_id) do + params = [id: id, post_id: post_id] + get("/users/:id/posts/:post_id", opts: [path_params: params]) end end ``` @@ -24,19 +50,36 @@ defmodule Tesla.Middleware.PathParams do @behaviour Tesla.Middleware - @rx ~r/:([a-zA-Z]{1}[\w_]*)/ - @impl Tesla.Middleware def call(env, next, _) do url = build_url(env.url, env.opts[:path_params]) Tesla.run(%{env | url: url}, next) end + @rx ~r/:([a-zA-Z][a-zA-Z0-9_]*)|[{]([a-zA-Z][-a-zA-Z0-9_]*)[}]/ + defp build_url(url, nil), do: url - defp build_url(url, params) do - Regex.replace(@rx, url, fn match, key -> - to_string(params[String.to_existing_atom(key)] || match) + defp build_url(url, params) when is_struct(params), do: build_url(url, Map.from_struct(params)) + + defp build_url(url, params) when is_map(params) or is_list(params) do + safe_params = Map.new(params, fn {name, value} -> {to_string(name), value} end) + + Regex.replace(@rx, url, fn + # OpenAPI matches + match, "", name -> replace_param(safe_params, name, match) + # Phoenix matches + match, name, _ -> replace_param(safe_params, name, match) end) end + + defp build_url(url, _params), do: url + + defp replace_param(params, name, match) do + case Map.fetch(params, name) do + {:ok, nil} -> match + :error -> match + {:ok, value} -> URI.encode_www_form(to_string(value)) + end + end end diff --git a/test/tesla/middleware/path_params_test.exs b/test/tesla/middleware/path_params_test.exs index abdcaa73..6f0938b2 100644 --- a/test/tesla/middleware/path_params_test.exs +++ b/test/tesla/middleware/path_params_test.exs @@ -1,86 +1,303 @@ defmodule Tesla.Middleware.PathParamsTest do use ExUnit.Case, async: true + alias Tesla.Env @middleware Tesla.Middleware.PathParams - test "no params" do - assert {:ok, env} = @middleware.call(%Env{url: "/users/:id"}, [], nil) - assert env.url == "/users/:id" + defmodule TestUser do + defstruct [:id] end - test "passed params" do - opts = [path_params: [id: 42]] - assert {:ok, env} = @middleware.call(%Env{url: "/users/:id", opts: opts}, [], nil) - assert env.url == "/users/42" - end + describe "Phoenix-style params (:id)" do + test "leaves the identifier with no parameters" do + assert {:ok, env} = @middleware.call(%Env{url: "/users/:id"}, [], nil) + assert env.url == "/users/:id" + end - test "value is not given" do - opts = [path_params: [y: 42]] - assert {:ok, env} = @middleware.call(%Env{url: "/users/:x", opts: opts}, [], nil) - assert env.url == "/users/:x" - end + test "replaces the identifier with passed params" do + opts = [path_params: [id: 42]] + assert {:ok, env} = @middleware.call(%Env{url: "/users/:id", opts: opts}, [], nil) + assert env.url == "/users/42" + end - test "value is nil" do - opts = [path_params: [id: nil]] - assert {:ok, env} = @middleware.call(%Env{url: "/users/:id", opts: opts}, [], nil) - assert env.url == "/users/:id" - end + test "replaces the identifier with empty passed params" do + opts = [path_params: [id: ""]] + assert {:ok, env} = @middleware.call(%Env{url: "/users/:id", opts: opts}, [], nil) + assert env.url == "/users/" + end - test "placeholder contains another placeholder" do - opts = [path_params: [id: 1, id_post: 2]] + test "leaves the identifier if no value is given" do + opts = [path_params: [y: 42]] + assert {:ok, env} = @middleware.call(%Env{url: "/users/:x", opts: opts}, [], nil) + assert env.url == "/users/:x" + end - assert {:ok, env} = @middleware.call(%Env{url: "/users/:id/p/:id_post", opts: opts}, [], nil) + test "leaves the identifier if the value is nil" do + opts = [path_params: [id: nil]] + assert {:ok, env} = @middleware.call(%Env{url: "/users/:id", opts: opts}, [], nil) + assert env.url == "/users/:id" + end - assert env.url == "/users/1/p/2" - end + test "correctly handles shorter identifiers in longer identifiers" do + opts = [path_params: [id: 1, id_post: 2]] - test "placeholder starts by number" do - opts = [path_params: ["1id": 1, id_post: 2]] + assert {:ok, env} = + @middleware.call(%Env{url: "/users/:id/p/:id_post", opts: opts}, [], nil) - assert {:ok, env} = @middleware.call(%Env{url: "/users/:1id/p/:id_post", opts: opts}, [], nil) + assert env.url == "/users/1/p/2" + end - assert env.url == "/users/:1id/p/2" - end + test "correctly handles shorter identifiers in longer identifiers (not provided)" do + opts = [path_params: [id: 1]] - test "placeholder with only 1 number" do - opts = [path_params: ["1": 1, id_post: 2]] + assert {:ok, env} = + @middleware.call(%Env{url: "/users/:id/p/:id_post", opts: opts}, [], nil) - assert {:ok, env} = @middleware.call(%Env{url: "/users/:1/p/:id_post", opts: opts}, [], nil) + assert env.url == "/users/1/p/:id_post" + end - assert env.url == "/users/:1/p/2" - end + test "leaves identifiers that start with a number" do + opts = [path_params: ["1id": 1, id_post: 2]] - test "placeholder with only 1 character" do - opts = [path_params: [i: 1, id_post: 2]] + assert {:ok, env} = + @middleware.call(%Env{url: "/users/:1id/p/:id_post", opts: opts}, [], nil) - assert {:ok, env} = @middleware.call(%Env{url: "/users/:i/p/:id_post", opts: opts}, [], nil) + assert env.url == "/users/:1id/p/2" + end - assert env.url == "/users/1/p/2" - end + test "leaves identifiers that are a single digit" do + opts = [path_params: ["1": 1, id_post: 2]] + + assert {:ok, env} = @middleware.call(%Env{url: "/users/:1/p/:id_post", opts: opts}, [], nil) + + assert env.url == "/users/:1/p/2" + end + + test "replaces identifiers one character long" do + opts = [path_params: [i: 1, id_post: 2]] + + assert {:ok, env} = @middleware.call(%Env{url: "/users/:i/p/:id_post", opts: opts}, [], nil) + + assert env.url == "/users/1/p/2" + end + + test "leaves identifiers that are only numbers" do + opts = [path_params: ["123": 1, id_post: 2]] + + assert {:ok, env} = + @middleware.call(%Env{url: "/users/:123/p/:id_post", opts: opts}, [], nil) + + assert env.url == "/users/:123/p/2" + end + + test "leaves identifiers that start with underscore" do + opts = [path_params: [_id: 1, id_post: 2]] + + assert {:ok, env} = + @middleware.call(%Env{url: "/users/:_id/p/:id_post", opts: opts}, [], nil) - test "placeholder with multiple numbers" do - opts = [path_params: ["123": 1, id_post: 2]] + assert env.url == "/users/:_id/p/2" + end - assert {:ok, env} = @middleware.call(%Env{url: "/users/:123/p/:id_post", opts: opts}, [], nil) + test "replaces any valid identifier" do + opts = [path_params: [id_1_a: 1, id_post: 2]] - assert env.url == "/users/:123/p/2" + assert {:ok, env} = + @middleware.call(%Env{url: "/users/:id_1_a/p/:id_post", opts: opts}, [], nil) + + assert env.url == "/users/1/p/2" + end + + test "replaces identifiers that start with a capital letter" do + opts = [path_params: [id_1_a: 1, IdPost: 2]] + + assert {:ok, env} = + @middleware.call(%Env{url: "/users/:id_1_a/p/:IdPost", opts: opts}, [], nil) + + assert env.url == "/users/1/p/2" + end + + test "replaces identifiers where the path params is a struct" do + opts = [path_params: %TestUser{id: 1}] + + assert {:ok, env} = @middleware.call(%Env{url: "/users/:id", opts: opts}, [], nil) + assert env.url == "/users/1" + end + + test "URI-encodes path parameters with reserved characters" do + opts = [path_params: [id: "user#1", post_id: "post#2"]] + + assert {:ok, env} = + @middleware.call(%Env{url: "/users/:id/p/:post_id", opts: opts}, [], nil) + + assert env.url == "/users/user%231/p/post%232" + end end - test "placeholder starts by underscore" do - opts = [path_params: [_id: 1, id_post: 2]] + describe "OpenAPI-style params ({id})" do + test "leaves the identifier with no parameters" do + assert {:ok, env} = @middleware.call(%Env{url: "/users/{id}"}, [], nil) + assert env.url == "/users/{id}" + end + + test "replaces the identifier with passed params" do + opts = [path_params: [id: 42]] + assert {:ok, env} = @middleware.call(%Env{url: "/users/{id}", opts: opts}, [], nil) + assert env.url == "/users/42" + end + + test "replaces the identifier with empty passed params" do + opts = [path_params: [id: ""]] + assert {:ok, env} = @middleware.call(%Env{url: "/users/{id}", opts: opts}, [], nil) + assert env.url == "/users/" + end + + test "leaves the identifier if no value is given" do + opts = [path_params: [y: 42]] + assert {:ok, env} = @middleware.call(%Env{url: "/users/{x}", opts: opts}, [], nil) + assert env.url == "/users/{x}" + end + + test "leaves the identifier if the value is nil" do + opts = [path_params: [id: nil]] + assert {:ok, env} = @middleware.call(%Env{url: "/users/{id}", opts: opts}, [], nil) + assert env.url == "/users/{id}" + end + + test "leaves identifiers that start with a number" do + opts = [path_params: ["1id": 1, id_post: 2]] + + assert {:ok, env} = + @middleware.call(%Env{url: "/users/{1id}/p/{id_post}", opts: opts}, [], nil) + + assert env.url == "/users/{1id}/p/2" + end + + test "leaves identifiers that are a single digit" do + opts = [path_params: ["1": 1, id_post: 2]] - assert {:ok, env} = @middleware.call(%Env{url: "/users/:_id/p/:id_post", opts: opts}, [], nil) + assert {:ok, env} = + @middleware.call(%Env{url: "/users/{1}/p/{id_post}", opts: opts}, [], nil) - assert env.url == "/users/:_id/p/2" + assert env.url == "/users/{1}/p/2" + end + + test "replaces identifiers one character long" do + opts = [path_params: [i: 1, id_post: 2]] + + assert {:ok, env} = + @middleware.call(%Env{url: "/users/{i}/p/{id_post}", opts: opts}, [], nil) + + assert env.url == "/users/1/p/2" + end + + test "leaves identifiers that are only numbers" do + opts = [path_params: ["123": 1, id_post: 2]] + + assert {:ok, env} = + @middleware.call(%Env{url: "/users/{123}/p/{id_post}", opts: opts}, [], nil) + + assert env.url == "/users/{123}/p/2" + end + + test "leaves identifiers that start with underscore" do + opts = [path_params: [_id: 1, id_post: 2]] + + assert {:ok, env} = + @middleware.call(%Env{url: "/users/{_id}/p/{id_post}", opts: opts}, [], nil) + + assert env.url == "/users/{_id}/p/2" + end + + test "leaves identifiers that start with dash" do + opts = [path_params: ["-id": 1, id_post: 2]] + + assert {:ok, env} = + @middleware.call(%Env{url: "/users/{-id}/p/{id_post}", opts: opts}, [], nil) + + assert env.url == "/users/{-id}/p/2" + end + + test "replaces any valid identifier" do + opts = [path_params: [id_1_a: 1, id_post: 2]] + + assert {:ok, env} = + @middleware.call(%Env{url: "/users/{id_1_a}/p/{id_post}", opts: opts}, [], nil) + + assert env.url == "/users/1/p/2" + end + + test "replaces any valid identifier with hyphens" do + opts = [path_params: [id_1_a: 1, id_post: 2]] + + assert {:ok, env} = + @middleware.call(%Env{url: "/users/{id_1_a}/p/{id_post}", opts: opts}, [], nil) + + assert env.url == "/users/1/p/2" + end + + test "replaces identifiers that start with a capital letter" do + opts = [path_params: [id_1_a: 1, IdPost: 2]] + + assert {:ok, env} = + @middleware.call(%Env{url: "/users/{id_1_a}/p/{IdPost}", opts: opts}, [], nil) + + assert env.url == "/users/1/p/2" + end + + test "replaces identifiers where the path params is a struct" do + opts = [path_params: %TestUser{id: 1}] + + assert {:ok, env} = @middleware.call(%Env{url: "/users/{id}", opts: opts}, [], nil) + assert env.url == "/users/1" + end + + test "URI-encodes path parameters with reserved characters" do + opts = [path_params: [id: "user#1", post_id: "post#2"]] + + assert {:ok, env} = + @middleware.call(%Env{url: "/users/{id}/p/{post_id}", opts: opts}, [], nil) + + assert env.url == "/users/user%231/p/post%232" + end end - test "placeholder with numbers, underscore and characters" do - opts = [path_params: [id_1_a: 1, id_post: 2]] + describe "Mixed params (not recommended, {id} and :id)" do + test "replaces identifiers one character long" do + opts = [path_params: [i: 1, id_post: 2]] + + assert {:ok, env} = + @middleware.call(%Env{url: "/users/:i/p/{id_post}", opts: opts}, [], nil) + + assert env.url == "/users/1/p/2" + end + + test "replaces any valid identifier" do + opts = [path_params: [id_1_a: 1, id_post: 2]] + + assert {:ok, env} = + @middleware.call(%Env{url: "/users/{id_1_a}/p/:id_post", opts: opts}, [], nil) + + assert env.url == "/users/1/p/2" + end + + test "replaces identifiers that start with a capital letter" do + opts = [path_params: [id_1_a: 1, IdPost: 2]] + + assert {:ok, env} = + @middleware.call(%Env{url: "/users/{id_1_a}/p/:IdPost", opts: opts}, [], nil) + + assert env.url == "/users/1/p/2" + end + + test "URI-encodes path parameters with reserved characters" do + opts = [path_params: [id: "user#1", id_post: "post#2"]] - assert {:ok, env} = - @middleware.call(%Env{url: "/users/:id_1_a/p/:id_post", opts: opts}, [], nil) + assert {:ok, env} = + @middleware.call(%Env{url: "/users/{id}/p/:id_post", opts: opts}, [], nil) - assert env.url == "/users/1/p/2" + assert env.url == "/users/user%231/p/post%232" + end end end