URI 모듈

URI 모듈

URI를 파싱하거나 쿼리 문자열을 인코딩해야 하는 순간이 왔을 때 쓰는 모듈이 바로 URI예요. 이 모듈은 URI를 다루기 위한 함수들을 모아 놓은 곳으로, 특히 런타임에 URI를 직접 만들고 조작해야 할 때 유용해요.

출처: URI (Elixir v1.20.4)

본문

URI 모듈은 URI를 다루기 위한 함수(예: URI 파싱, 쿼리 문자열 인코딩)를 제공해요. 이 모듈의 함수들은 RFC 3986에 따라 구현됐고, "application/x-www-form-urlencoded" 세그먼트를 처리하는 추가 기능도 제공해요.

추가로 Erlang의 :uri_string 모듈은 RFC 3986을 준수하는 URI 정규화 같은 추가 기능을 제공해요.

URI 구조체 (The URI struct)

[scheme]://[userinfo]@[host]:[port][path]?[query]#[fragment]

필드 이름을 대괄호로 표기한 위 URI 표현에 맞춰 구조체 필드가 정의돼 있어요. 필드들은 URI에 나타나는 그대로의 인코딩된 URI 구성 요소를 담아요. 예를 들어 userinfo 안의 슬래시는 /가 아니라 %2F로 저장해야 해요. parse/1이나 new/1 같은 함수는 그 필드들의 기존 퍼센트 인코딩 시퀀스를 보존하고, to_string/1 같은 함수는 필드들이 이미 필요에 따라 인코딩돼 있다고 가정해요. 필드를 직접 설정하거나 수정할 때는 그에 맞게 인코딩해 줘야 해요.

참고로 authority 필드는 deprecated(더 이상 권장되지 않음) 상태예요. parse/1은 하위 호환성을 위해 여전히 이 필드를 채워 주지만, 일반적으로는 설정하거나 읽는 것을 피해야 해요.

구조체 타입은 다음과 같아요.

@type t() :: %URI{
  authority: authority(),
  fragment: nil | binary(),
  host: nil | binary(),
  path: nil | binary(),
  port: nil | :inet.port_number(),
  query: nil | binary(),
  scheme: nil | binary(),
  userinfo: nil | binary()
}

추가로 authority() 타입은 deprecated 상태예요.

@type authority() :: nil | binary()

append_path(uri, path) (since 1.15.0)

주어진 uripath를 이어 붙여요.

@spec append_path(t(), String.t()) :: t()

path/로 시작해야 하고, 프래그먼트나 쿼리 문자열 같은 추가 URL 구성 요소를 담을 수 없어요. 이 함수는 나아가 그 경로가 유효하며 쿼리 문자열이나 프래그먼트 부분을 포함하지 않는다고 가정해요.

iex> URI.append_path(URI.parse("http://example.com/foo/?x=1"), "/my-path") |> URI.to_string()
"http://example.com/foo/my-path?x=1"

iex> URI.append_path(URI.parse("http://example.com"), "my-path")
** (ArgumentError) path must start with "/", got: "my-path"

append_query(uri, query) (since 1.14.0)

주어진 uriquery를 이어 붙여요.

@spec append_query(t(), binary()) :: t()

주어진 query는 자동으로 인코딩되지 않으니, 필요하다면 encode/2encode_www_form/1을 사용해요.

iex> URI.append_query(URI.parse("http://example.com/"), "x=1") |> URI.to_string()
"http://example.com/?x=1"

iex> URI.append_query(URI.parse("http://example.com/?x=1"), "y=2") |> URI.to_string()
"http://example.com/?x=1&y=2"

iex> URI.append_query(URI.parse("http://example.com/?x=1"), "x=2") |> URI.to_string()
"http://example.com/?x=1&x=2"

char_reserved?(character)

URI에서 character가 예약 문자(reserved)인지 확인해요.

@spec char_reserved?(byte()) :: boolean()

RFC 3986 섹션 2.2에 명시된 대로, 다음 문자들이 예약 문자예요: :, /, ?, #, [, ], @, !, $, &, ', (, ), *, +, ,, ;, =

iex> URI.char_reserved?(?+)
true

char_unescaped?(character)

URI에서 character가 이스케이프 없이 허용되는 문자인지 확인해요.

@spec char_unescaped?(byte()) :: boolean()

이것은 URI.encode/2가 기본으로 사용하는 기준이에요. 예약 문자와 비예약 문자 모두 이스케이프 없이 유지돼요.

iex> URI.char_unescaped?(?{)
false

char_unreserved?(character)

URI에서 character가 비예약 문자(unreserved)인지 확인해요.

@spec char_unreserved?(byte()) :: boolean()

RFC 3986 섹션 2.3에 명시된 대로, 다음 문자들이 비예약 문자예요.

  • 영숫자 문자: A-Z, a-z, 0-9
  • ~, _, -, .
iex> URI.char_unreserved?(?_)
true

decode(uri)

URI의 퍼센트 인코딩을 풀어요(Percent-unescape).

@spec decode(binary()) :: binary()
iex> URI.decode("https%3A%2F%2Felixir-lang.org")
"https://elixir-lang.org"

decode_query(query, map \ %{}, encoding \ :www_form)

query를 맵으로 디코딩해요.

@spec decode_query(binary(), %{optional(binary()) => binary()}, :rfc3986 | :www_form) ::
  %{
    optional(binary()) => binary()
  }

key1=value1&key2=value2... 형태의 쿼리 문자열이 주어지면, 이 함수는 쿼리 문자열의 각 키-값 쌍을 주어진 map의 한 항목으로 넣어요. 결과 맵의 키와 값은 바이너리이고, 퍼센트 이스케이프가 풀려요.

다음 encoding 옵션 중 하나를 지정할 수 있어요.

  • :www_form - (기본값, v1.12.0부터) decode_www_form/1에 따라 키와 값을 디코딩해요. 브라우저가 쿼리 문자열과 폼 데이터에 보통 사용하는 형식이에요. + (공백)으로 디코딩해요.
  • :rfc3986 - (v1.12.0부터) decode/1에 따라 키와 값을 디코딩해요. 결과는 :www_form과 같지만, RFC 3986에 맞춰 +를 그대로 남겨 둬요.

인코딩은 하위 호환성을 위해 기본값이 :www_form이에요. 각 값을 직접 순회하고 싶다면 query_decoder/1을 사용하면 돼요.

iex> URI.decode_query("foo=1&bar=2")
%{"bar" => "2", "foo" => "1"}

iex> URI.decode_query("percent=oh+yes%21", %{"starting" => "map"})
%{"percent" => "oh yes!", "starting" => "map"}

iex> URI.decode_query("percent=oh+yes%21", %{}, :rfc3986)
%{"percent" => "oh+yes!"}

decode_www_form(string)

string을 "x-www-form-urlencoded"로 디코딩해요.

@spec decode_www_form(binary()) :: binary()

참고로 "x-www-form-urlencoded"는 RFC 3986의 일부로 명시되지는 않아요. 다만 브라우저가 쿼리 문자열과 폼 데이터를 인코딩할 때 흔히 쓰는 형식이에요.

iex> URI.decode_www_form("%3Call+in%2F")
"<all in/"

default_port(scheme)

주어진 scheme에 대한 기본 포트를 반환해요.

@spec default_port(binary()) :: nil | non_neg_integer()

그 스킴이 URI 모듈에 알려지지 않았다면 nil을 반환해요. 어떤 스킴에 대한 기본 포트든 default_port/2로 전역 설정할 수 있어요.

iex> URI.default_port("ftp")
21

iex> URI.default_port("ponzi")
nil

default_port(scheme, port)

주어진 scheme에 대한 기본 port를 등록해요.

@spec default_port(binary(), non_neg_integer()) :: :ok

이 함수를 호출하면 주어진 스킴 scheme에 대해 default_port/1port를 반환해요. 이 함수가 주어진 scheme의 기본 포트를 전역적으로(모든 애플리케이션에 대해) 바꾼다는 점을 주의하세요.

새 URI를 등록하고 싶다면 애플리케이션의 시작(start) 콜백에서 이 함수를 호출하길 권장해요.

encode(string, predicate \ &char_unescaped?/1)

string에서 이스케이프가 필요한 모든 문자를 퍼센트 인코딩해요.

@spec encode(binary(), (byte() -> as_boolean(term()))) :: binary()

선택적 predicate 인자는 string의 어떤 바이트를 이스케이프해야 하는지 판별하는 함수를 지정해요.

  • 함수가 truthy 값을 반환하면 그 바이트는 그대로 유지돼요.
  • 함수가 falsy 값을 반환하면 그 바이트는 이스케이프돼요.

predicate 인자에는 몇 가지 내장 함수를 쓸 수 있어요.

  • URI.char_unescaped?/1 (기본값) - 예약 문자(:/ 같은)나 비예약 문자(문자·숫자 같은)를 그대로 유지해요. 보통 URI 전체를 인코딩할 때 사용해요.
  • URI.char_unreserved?/1 - 비예약 문자(문자·숫자 같은)를 그대로 유지해요. 보통 쿼리나 프래그먼트 같은 URI의 구성 요소를 인코딩할 때 사용해요.
  • URI.char_reserved?/1 - 예약 문자(:/ 같은)를 그대로 유지해요.

물론 커스텀 함수를 사용할 수도 있어요. string을 "x-www-form-urlencoded"로 인코딩하는 데 관심이 있다면 encode_www_form/1을 참고하세요.

iex> URI.encode("ftp://s-ite.tld/?value=put it+й")
"ftp://s-ite.tld/?value=put%20it+%D0%B9"

iex> URI.encode("a string", &(&1 != ?i))
"a str%69ng"

encode_query(enumerable, encoding \ :www_form)

enumerableencoding을 사용해 쿼리 문자열로 인코딩해요.

@spec encode_query(Enumerable.t(), :rfc3986 | :www_form) :: binary()

두 요소짜리 튜플의 리스트로 열거되는 enumerable(예: 맵이나 키워드 리스트)을 받아 key1=value1&key2=value2... 형태의 문자열을 반환해요.

키와 값은 String.Chars 프로토콜을 구현하는 어떤 term이든 될 수 있는데, 리스트는 명시적으로 금지돼요.

다음 encoding 전략 중 하나를 지정할 수 있어요.

  • :www_form - (기본값, v1.12.0부터) encode_www_form/1에 따라 키와 값을 URL 인코딩해요. 브라우저가 쿼리 문자열과 폼 데이터에 보통 사용하는 형식이에요. (공백)을 +로 인코딩해요.
  • :rfc3986 - (v1.12.0부터) :www_form과 같지만, RFC 3986에 따라 (공백)을 %20으로 인코딩해요. 브라우저가 아닌 환경에서 인코딩한다면 가장 좋은 선택이에요. 공백을 +로 인코딩하면 URI 파서에 모호함을 줄 수 있고, 공백이 리터럴 플러스 기호로 해석될 수 있기 때문이에요.

인코딩은 하위 호환성을 위해 기본값이 :www_form이에요.

iex> query = %{"foo" => 1, "bar" => 2}
iex> URI.encode_query(query)
"bar=2&foo=1"

iex> query = %{"key" => "value with spaces"}
iex> URI.encode_query(query)
"key=value+with+spaces"

iex> query = %{"key" => "value with spaces"}
iex> URI.encode_query(query, :rfc3986)
"key=value%20with%20spaces"

iex> URI.encode_query(%{key: [:a, :list]})
** (ArgumentError) encode_query/2 values cannot be lists, got: [:a, :list]

encode_www_form(string)

string을 "x-www-form-urlencoded"로 인코딩해요.

@spec encode_www_form(binary()) :: binary()

참고로 "x-www-form-urlencoded"는 RFC 3986의 일부로 명시되지는 않아요. 다만 브라우저가 쿼리 문자열과 폼 데이터를 인코딩할 때 흔히 쓰는 형식이에요.

iex> URI.encode_www_form("put: it+й")
"put%3A+it%2B%D0%B9"

merge(uri, rel)

두 URI를 합쳐요.

@spec merge(t() | binary(), t() | binary()) :: t()

이 함수는 RFC 3986 섹션 5.2에 따라 두 URI를 병합해요.

iex> URI.merge(URI.parse("http://google.com"), "/query") |> to_string()
"http://google.com/query"

iex> URI.merge("http://example.com", "http://google.com") |> to_string()
"http://google.com"

new(uri) (since 1.13.0)

문자열을 파싱·검증하거나 기존 URI로부터 새 URI 구조체를 만들어요.

@spec new(t() | String.t()) :: {:ok, t()} | {:error, String.t()}

%URI{} 구조체가 주어지면 그대로 {:ok, uri}를 반환해요. 문자열이 주어지면 파싱하고 검증해요. 문자열이 유효하면 {:ok, uri}를, 그렇지 않으면 URI의 잘못된 부분과 함께 {:error, part}를 반환해요. 추가 검증 없이 URI를 파싱하려면 parse/1을 참고하세요.

이 함수는 절대 URL과 상대 URL을 모두 파싱할 수 있어요. scheme 필드가 nil인지 아닌지로 URI가 절대인지 상대인지 확인할 수 있어요.

포트 없이 URI가 주어지면 그 URI 스킴에 대한 URI.default_port/1이 반환하는 값이 :port 필드에 사용돼요. 스킴도 소문자로 정규화돼요.

브라우저 호환성 (Browser compatibility)

이 함수는 브라우저와 같은 파싱 규칙을 따르지 않아요. 브라우저는 WHATWG URL 표준을 따르는 반면, 이 함수는 RFC 3986을 구현해요. 둘 사이의 동작 차이를 예상해야 해요. 이 함수는 브라우저가 허용하는 URL(브라우저는 관대한 경향이 있음)을 종종 거부하기도 해요. 흔한 예로 쿼리 문자열의 인코딩되지 않은 대괄호(foo[bar]=baz 같은)와 경로 구분자로 쓰인 백슬래시가 있어요.

iex> URI.new("https://elixir-lang.org/")
{:ok, %URI{
  fragment: nil,
  host: "elixir-lang.org",
  path: "/",
  port: 443,
  query: nil,
  scheme: "https",
  userinfo: nil
}}

iex> URI.new("//elixir-lang.org/")
{:ok, %URI{
  fragment: nil,
  host: "elixir-lang.org",
  path: "/",
  port: nil,
  query: nil,
  scheme: nil,
  userinfo: nil
}}

iex> URI.new("/foo/bar")
{:ok, %URI{
  fragment: nil,
  host: nil,
  path: "/foo/bar",
  port: nil,
  query: nil,
  scheme: nil,
  userinfo: nil
}}

iex> URI.new("foo/bar")
{:ok, %URI{
  fragment: nil,
  host: nil,
  path: "foo/bar",
  port: nil,
  query: nil,
  scheme: nil,
  userinfo: nil
}}

iex> URI.new("//[fe80::]/")
{:ok, %URI{
  fragment: nil,
  host: "fe80::",
  path: "/",
  port: nil,
  query: nil,
  scheme: nil,
  userinfo: nil
}}

iex> URI.new("https:?query")
{:ok, %URI{
  fragment: nil,
  host: nil,
  path: nil,
  port: 443,
  query: "query",
  scheme: "https",
  userinfo: nil
}}

iex> URI.new("/invalid_greater_than_in_path/>")
{:error, ">"}

기존 URI를 주면 그냥 튜플에 감싸 반환해요.

iex> {:ok, uri} = URI.new("https://elixir-lang.org/")
iex> URI.new(uri)
{:ok, %URI{
  fragment: nil,
  host: "elixir-lang.org",
  path: "/",
  port: 443,
  query: nil,
  scheme: "https",
  userinfo: nil
}}

new!(uri) (since 1.13.0)

new/1과 비슷하지만, 유효하지 않은 문자열이 주어지면 URI.Error를 던져요.

@spec new!(t() | String.t()) :: t()
iex> URI.new!("https://elixir-lang.org/")
%URI{
  fragment: nil,
  host: "elixir-lang.org",
  path: "/",
  port: 443,
  query: nil,
  scheme: "https",
  userinfo: nil
}

iex> URI.new!("/invalid_greater_than_in_path/>")
** (URI.Error) cannot parse due to reason invalid_uri: ">"

기존 URI를 주면 그냥 반환해요.

iex> uri = URI.new!("https://elixir-lang.org/")
iex> URI.new!(uri)
%URI{
  fragment: nil,
  host: "elixir-lang.org",
  path: "/",
  port: 443,
  query: nil,
  scheme: "https",
  userinfo: nil
}

parse(uri)

추가 검증 없이 URI를 구성 요소로 파싱해요.

@spec parse(t() | binary()) :: t()

이 함수는 절대 URL과 상대 URL을 모두 파싱할 수 있어요. scheme 필드가 nil인지 아닌지로 URI가 절대인지 상대인지 확인할 수 있어요.

이 함수는 절대·상대 URI 모두 잘 구성돼 있다고 가정하며, 어떤 검증도 수행하지 않아요. 단순히 URL을 부분으로 쪼갤 뿐이에요. 파싱 후 URI 필드를 검증하려면 new/1을 사용하세요.

포트 없이 URI가 주어지면 그 URI 스킴에 대한 URI.default_port/1이 반환하는 값이 :port 필드에 사용돼요. 스킴도 소문자로 정규화돼요.

이 함수에 URI 구조체가 주어지면 수정하지 않고 그대로 반환해요.

브라우저 호환성 (Browser compatibility)

이 함수는 브라우저와 같은 파싱 규칙을 따르지 않아요. 브라우저는 WHATWG URL 표준을 따르는 반면, 이 함수는 RFC 3986을 구현해요. 특히 모서리 케이스에서 둘 사이의 동작 차이를 예상해야 해요.

:authority 필드

이 함수는 하위 호환성 때문에 deprecated 필드인 :authority를 설정해요.

iex> URI.parse("https://elixir-lang.org/")
%URI{
  authority: "elixir-lang.org",
  fragment: nil,
  host: "elixir-lang.org",
  path: "/",
  port: 443,
  query: nil,
  scheme: "https",
  userinfo: nil
}

iex> URI.parse("//elixir-lang.org/")
%URI{
  authority: "elixir-lang.org",
  fragment: nil,
  host: "elixir-lang.org",
  path: "/",
  port: nil,
  query: nil,
  scheme: nil,
  userinfo: nil
}

iex> URI.parse("/foo/bar")
%URI{
  fragment: nil,
  host: nil,
  path: "/foo/bar",
  port: nil,
  query: nil,
  scheme: nil,
  userinfo: nil
}

iex> URI.parse("foo/bar")
%URI{
  fragment: nil,
  host: nil,
  path: "foo/bar",
  port: nil,
  query: nil,
  scheme: nil,
  userinfo: nil
}

URI.new/1과 달리 이 함수는 검증을 수행하지 않으므로, 잘못 구성된 URI도 받아들여 구성 요소로 쪼개요. 예를 들어:

iex> URI.parse("/invalid_greater_than_in_path/>")
%URI{
  fragment: nil,
  host: nil,
  path: "/invalid_greater_than_in_path/>",
  port: nil,
  query: nil,
  scheme: nil,
  userinfo: nil
}

또 다른 예로 쿼리 문자열에 대괄호가 있는 URI가 있어요. 위와 같은 이유(검증 없음)로 parse/1은 받아들이지만 new/1은 거부해요.

iex> URI.parse("/?foo[bar]=baz")
%URI{
  fragment: nil,
  host: nil,
  path: "/",
  port: nil,
  query: "foo[bar]=baz",
  scheme: nil,
  userinfo: nil
}

일반적으로 말해 대괄호는 브라우저가 쓰는 WHATWG URL 표준에서도 엄격히 유효하지 않지만, 브라우저는 관대해서 유효하지 않은 문자가 그대로 통과하도록 허용하는 경우가 많아요.

query_decoder(query, encoding \ :www_form)

주어진 query의 키-값 쌍을 나타내는 두 요소 튜플의 스트림을 반환해요.

@spec query_decoder(binary(), :rfc3986 | :www_form) :: Enumerable.t()

각 튜플의 키와 값은 바이너리이고 퍼센트 이스케이프가 풀려요.

다음 encoding 옵션 중 하나를 지정할 수 있어요.

  • :www_form - (기본값, v1.12.0부터) decode_www_form/1에 따라 키와 값을 디코딩해요. 브라우저가 쿼리 문자열과 폼 데이터에 보통 사용하는 형식이에요. + (공백)으로 디코딩해요.
  • :rfc3986 - (v1.12.0부터) decode/1에 따라 키와 값을 디코딩해요. 결과는 :www_form과 같지만, RFC 3986에 맞춰 +를 그대로 남겨 둬요.

인코딩은 하위 호환성을 위해 기본값이 :www_form이에요.

iex> URI.query_decoder("foo=1&bar=2") |> Enum.to_list()
[{"foo", "1"}, {"bar", "2"}]

iex> URI.query_decoder("food=bread%26butter&drinks=tap%20water+please") |> Enum.to_list()
[{"food", "bread&butter"}, {"drinks", "tap water please"}]

iex> URI.query_decoder("food=bread%26butter&drinks=tap%20water+please", :rfc3986) |> Enum.to_list()
[{"food", "bread&butter"}, {"drinks", "tap water+please"}]

to_string(uri)

주어진 URI 구조체의 문자열 표현을 반환해요.

@spec to_string(t()) :: binary()

이 함수는 각 필드가 유효하고 parse/1new/1이 하는 것처럼 이스케이프됐다고 가정하고 URI 구성 요소를 문자열로 조립해요.

iex> uri = URI.parse("http://google.com")
iex> URI.to_string(uri)
"http://google.com"

iex> uri = URI.parse("foo://bar.baz")
iex> URI.to_string(uri)
"foo://bar.baz"

더 알아보기