Packages

Elixir-inspired standard library modules for Erlang

Current section

Files

Jump to
ex_stdlib src uri.erl
Raw

src/uri.erl

%%% @doc
%%% URI parsing and manipulation module inspired by Elixir's URI module.
%%%
%%% This module provides functions for parsing, manipulating, and encoding URIs.
%%% It handles the standard URI components: scheme, userinfo, host, port, path,
%%% query, and fragment.
%%%
%%% Examples:
%%% ```
%%% URI = uri:parse("https://user:pass@example.com:8080/path?query=value#fragment"),
%%% EncodedPath = uri:encode("/hello world", :path),
%%% QueryString = uri:encode_query([{name, "John Doe"}, {age, 30}]).
%%% '''
%%% @end
-module(uri).
%% API
-export([parse/1, new/1, to_string/1]).
-export([encode/2, decode/1, encode_query/1, decode_query/1]).
-export([merge/2, resolve/2]).
-export([valid/1, scheme/1, userinfo/1, host/1, port/1, path/1, query/1, fragment/1]).
%% Types
-type uri() :: #{
scheme => binary() | undefined,
userinfo => binary() | undefined,
host => binary() | undefined,
port => integer() | undefined,
path => binary() | undefined,
query => binary() | undefined,
fragment => binary() | undefined
}.
-type component() :: scheme | userinfo | host | port | path | query | fragment.
-type encode_type() :: path | query | fragment | userinfo.
-export_type([uri/0, component/0, encode_type/0]).
%%% @doc
%%% Parses a URI string into a URI map.
%%%
%%% Returns a map with the URI components. Missing components are set to undefined.
%%% @end
-spec parse(string() | binary()) -> uri().
parse(URIString) when is_list(URIString) ->
parse(list_to_binary(URIString));
parse(URIString) when is_binary(URIString) ->
parse_uri(URIString).
%%% @doc
%%% Creates a new URI from the given components map.
%%%
%%% Any missing components are set to undefined.
%%% @end
-spec new(map()) -> uri().
new(Components) when is_map(Components) ->
#{
scheme => maps:get(scheme, Components, undefined),
userinfo => maps:get(userinfo, Components, undefined),
host => maps:get(host, Components, undefined),
port => maps:get(port, Components, undefined),
path => maps:get(path, Components, undefined),
query => maps:get(query, Components, undefined),
fragment => maps:get(fragment, Components, undefined)
}.
%%% @doc
%%% Converts a URI map back to a string.
%%% @end
-spec to_string(uri()) -> binary().
to_string(URI) when is_map(URI) ->
build_uri_string(URI).
%%% @doc
%%% Encodes a string for safe use in a URI component.
%%%
%%% The encoding type determines which characters are safe to leave unencoded.
%%% @end
-spec encode(string() | binary(), encode_type()) -> binary().
encode(String, Type) when is_list(String) ->
encode(list_to_binary(String), Type);
encode(Binary, Type) when is_binary(Binary) ->
encode_component(Binary, Type).
%%% @doc
%%% Decodes a percent-encoded string.
%%% @end
-spec decode(string() | binary()) -> binary().
decode(String) when is_list(String) ->
decode(list_to_binary(String));
decode(Binary) when is_binary(Binary) ->
decode_percent(Binary, <<>>).
%%% @doc
%%% Encodes a list of key-value pairs into a query string.
%%%
%%% Keys and values are automatically percent-encoded.
%%% @end
-spec encode_query([{term(), term()}]) -> binary().
encode_query(Params) when is_list(Params) ->
EncodedParams = [encode_query_param(Key, Value) || {Key, Value} <- Params],
join_with_ampersand(EncodedParams).
%%% @doc
%%% Decodes a query string into a list of key-value pairs.
%%%
%%% Returns a list of {Key, Value} tuples where both are binaries.
%%% @end
-spec decode_query(string() | binary()) -> [{binary(), binary()}].
decode_query(Query) when is_list(Query) ->
decode_query(list_to_binary(Query));
decode_query(<<>>) ->
[];
decode_query(Query) when is_binary(Query) ->
Pairs = binary:split(Query, <<"&">>, [global]),
[decode_query_pair(Pair) || Pair <- Pairs, Pair =/= <<>>].
%%% @doc
%%% Merges two URIs, with the second URI taking precedence.
%%%
%%% Components from the second URI override those in the first URI.
%%% @end
-spec merge(uri(), uri()) -> uri().
merge(URI1, URI2) when is_map(URI1), is_map(URI2) ->
maps:merge(URI1, maps:filter(fun(_, V) -> V =/= undefined end, URI2)).
%%% @doc
%%% Resolves a relative URI against a base URI.
%%%
%%% Implements RFC 3986 reference resolution.
%%% @end
-spec resolve(uri(), uri() | string() | binary()) -> uri().
resolve(BaseURI, RelativeURI) when is_map(BaseURI), is_binary(RelativeURI) ->
resolve(BaseURI, parse(RelativeURI));
resolve(BaseURI, RelativeURI) when is_map(BaseURI), is_list(RelativeURI) ->
resolve(BaseURI, parse(RelativeURI));
resolve(BaseURI, RelativeURI) when is_map(BaseURI), is_map(RelativeURI) ->
resolve_reference(BaseURI, RelativeURI).
%%% @doc
%%% Checks if a URI is valid according to basic URI syntax rules.
%%% @end
-spec valid(uri() | string() | binary()) -> boolean().
valid(URI) when is_map(URI) ->
validate_uri_map(URI);
valid(URIString) when is_list(URIString) orelse is_binary(URIString) ->
try
ParsedURI = parse(URIString),
validate_uri_map(ParsedURI)
catch
_:_ -> false
end.
%%% @doc
%%% Extracts the scheme from a URI.
%%% @end
-spec scheme(uri()) -> binary() | undefined.
scheme(URI) when is_map(URI) ->
maps:get(scheme, URI, undefined).
%%% @doc
%%% Extracts the userinfo from a URI.
%%% @end
-spec userinfo(uri()) -> binary() | undefined.
userinfo(URI) when is_map(URI) ->
maps:get(userinfo, URI, undefined).
%%% @doc
%%% Extracts the host from a URI.
%%% @end
-spec host(uri()) -> binary() | undefined.
host(URI) when is_map(URI) ->
maps:get(host, URI, undefined).
%%% @doc
%%% Extracts the port from a URI.
%%% @end
-spec port(uri()) -> integer() | undefined.
port(URI) when is_map(URI) ->
maps:get(port, URI, undefined).
%%% @doc
%%% Extracts the path from a URI.
%%% @end
-spec path(uri()) -> binary() | undefined.
path(URI) when is_map(URI) ->
maps:get(path, URI, undefined).
%%% @doc
%%% Extracts the query from a URI.
%%% @end
-spec query(uri()) -> binary() | undefined.
query(URI) when is_map(URI) ->
maps:get(query, URI, undefined).
%%% @doc
%%% Extracts the fragment from a URI.
%%% @end
-spec fragment(uri()) -> binary() | undefined.
fragment(URI) when is_map(URI) ->
maps:get(fragment, URI, undefined).
%%%=============================================================================
%%% Internal functions
%%%=============================================================================
%% @private
parse_uri(URIString) ->
% Simple regex-based parsing for basic URI components
% This is a simplified implementation
case parse_scheme(URIString) of
{Scheme, Rest} ->
case parse_authority(Rest) of
{Authority, PathAndMore} ->
{Path, QueryAndFragment} = parse_path(PathAndMore),
{Query, Fragment} = parse_query_fragment(QueryAndFragment),
maps:merge(#{
scheme => Scheme,
path => Path,
query => Query,
fragment => Fragment
}, Authority);
no_authority ->
{Path, QueryAndFragment} = parse_path(Rest),
{Query, Fragment} = parse_query_fragment(QueryAndFragment),
#{
scheme => Scheme,
userinfo => undefined,
host => undefined,
port => undefined,
path => Path,
query => Query,
fragment => Fragment
}
end;
no_scheme ->
% No scheme, treat as path
{Path, QueryAndFragment} = parse_path(URIString),
{Query, Fragment} = parse_query_fragment(QueryAndFragment),
#{
scheme => undefined,
userinfo => undefined,
host => undefined,
port => undefined,
path => Path,
query => Query,
fragment => Fragment
}
end.
%% @private
parse_scheme(<<>>) ->
no_scheme;
parse_scheme(URI) ->
case binary:split(URI, <<":">>) of
[Scheme, Rest] when byte_size(Scheme) > 0 ->
{Scheme, Rest};
_ ->
no_scheme
end.
%% @private
parse_authority(<<"//", Rest/binary>>) ->
case binary:split(Rest, <<"/">>) of
[Authority, PathRest] ->
{parse_authority_parts(Authority), <<"/", PathRest/binary>>};
[Authority] ->
{parse_authority_parts(Authority), <<>>}
end;
parse_authority(_Rest) ->
no_authority.
%% @private
parse_authority_parts(Authority) ->
case binary:split(Authority, <<"@">>) of
[UserInfo, HostPort] ->
{Host, Port} = parse_host_port(HostPort),
#{userinfo => UserInfo, host => Host, port => Port};
[HostPort] ->
{Host, Port} = parse_host_port(HostPort),
#{userinfo => undefined, host => Host, port => Port}
end.
%% @private
parse_host_port(HostPort) ->
case binary:split(HostPort, <<":">>) of
[Host, PortBin] ->
try
Port = binary_to_integer(PortBin),
{Host, Port}
catch
_:_ -> {HostPort, undefined}
end;
[Host] ->
{Host, undefined}
end.
%% @private
parse_path(URI) when is_binary(URI) ->
case binary:split(URI, <<"?">>) of
[Path, QueryAndFragment] ->
{Path, <<"?", QueryAndFragment/binary>>};
[PathOrFragment] ->
case binary:split(PathOrFragment, <<"#">>) of
[Path, Fragment] ->
{Path, <<"#", Fragment/binary>>};
[Path] ->
{Path, <<>>}
end
end.
%% @private
parse_query_fragment(<<>>) ->
{undefined, undefined};
parse_query_fragment(<<"?", Rest/binary>>) ->
case binary:split(Rest, <<"#">>) of
[Query, Fragment] ->
{Query, Fragment};
[Query] ->
{Query, undefined}
end;
parse_query_fragment(<<"#", Fragment/binary>>) ->
{undefined, Fragment};
parse_query_fragment(_) ->
{undefined, undefined}.
%% @private
build_uri_string(URI) ->
Parts = [
build_scheme(maps:get(scheme, URI, undefined)),
build_authority(URI),
build_path(maps:get(path, URI, undefined)),
build_query(maps:get(query, URI, undefined)),
build_fragment(maps:get(fragment, URI, undefined))
],
iolist_to_binary([Part || Part <- Parts, Part =/= <<>>]).
%% @private
build_scheme(undefined) -> <<>>;
build_scheme(Scheme) -> <<Scheme/binary, ":">>.
%% @private
build_authority(URI) ->
UserInfo = maps:get(userinfo, URI, undefined),
Host = maps:get(host, URI, undefined),
Port = maps:get(port, URI, undefined),
case Host of
undefined -> <<>>;
_ ->
UserInfoPart = case UserInfo of
undefined -> <<>>;
_ -> <<UserInfo/binary, "@">>
end,
PortPart = case Port of
undefined -> <<>>;
_ -> <<":", (integer_to_binary(Port))/binary>>
end,
<<"//", UserInfoPart/binary, Host/binary, PortPart/binary>>
end.
%% @private
build_path(undefined) -> <<>>;
build_path(Path) -> Path.
%% @private
build_query(undefined) -> <<>>;
build_query(Query) -> <<"?", Query/binary>>.
%% @private
build_fragment(undefined) -> <<>>;
build_fragment(Fragment) -> <<"#", Fragment/binary>>.
%% @private
encode_component(Binary, Type) ->
encode_chars(Binary, get_safe_chars(Type), <<>>).
%% @private
get_safe_chars(path) ->
% RFC 3986 unreserved + sub-delims + : @ /
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~:@!/";
get_safe_chars(query) ->
% RFC 3986 unreserved + sub-delims + : @ ? / =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~:@?/=";
get_safe_chars(fragment) ->
% RFC 3986 unreserved + sub-delims + : @ ? /
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~:@?/";
get_safe_chars(userinfo) ->
% RFC 3986 unreserved + sub-delims + :
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~:".
%% @private
encode_chars(<<>>, _SafeChars, Acc) ->
Acc;
encode_chars(<<Char:8, Rest/binary>>, SafeChars, Acc) ->
case lists:member(Char, SafeChars) of
true ->
encode_chars(Rest, SafeChars, <<Acc/binary, Char>>);
false ->
Encoded = percent_encode(Char),
encode_chars(Rest, SafeChars, <<Acc/binary, Encoded/binary>>)
end.
%% @private
percent_encode(Char) ->
<<"%", (integer_to_binary(Char, 16))/binary>>.
%% @private
decode_percent(<<>>, Acc) ->
Acc;
decode_percent(<<"%", H1, H2, Rest/binary>>, Acc) ->
try
Char = list_to_integer([H1, H2], 16),
decode_percent(Rest, <<Acc/binary, Char>>)
catch
_:_ ->
decode_percent(Rest, <<Acc/binary, "%", H1, H2>>)
end;
decode_percent(<<Char, Rest/binary>>, Acc) ->
decode_percent(Rest, <<Acc/binary, Char>>).
%% @private
encode_query_param(Key, Value) ->
EncodedKey = encode(to_binary(Key), query),
EncodedValue = encode(to_binary(Value), query),
<<EncodedKey/binary, "=", EncodedValue/binary>>.
%% @private
decode_query_pair(Pair) ->
case binary:split(Pair, <<"=">>) of
[Key, Value] ->
{decode(Key), decode(Value)};
[Key] ->
{decode(Key), <<>>}
end.
%% @private
join_with_ampersand([]) ->
<<>>;
join_with_ampersand([First | Rest]) ->
lists:foldl(fun(Param, Acc) ->
<<Acc/binary, "&", Param/binary>>
end, First, Rest).
%% @private
to_binary(Value) when is_binary(Value) -> Value;
to_binary(Value) when is_list(Value) -> list_to_binary(Value);
to_binary(Value) when is_atom(Value) -> atom_to_binary(Value);
to_binary(Value) when is_integer(Value) -> integer_to_binary(Value);
to_binary(Value) -> iolist_to_binary(io_lib:format("~p", [Value])).
%% @private
resolve_reference(BaseURI, RelativeURI) ->
% Simplified reference resolution
case maps:get(scheme, RelativeURI, undefined) of
undefined ->
% Relative reference
merge(BaseURI, RelativeURI);
_ ->
% Absolute reference
RelativeURI
end.
%% @private
validate_uri_map(URI) when is_map(URI) ->
% Basic validation - check that required fields are present and valid types
try
maps:fold(fun
(scheme, V, _) when V =:= undefined; is_binary(V) -> true;
(userinfo, V, _) when V =:= undefined; is_binary(V) -> true;
(host, V, _) when V =:= undefined; is_binary(V) -> true;
(port, V, _) when V =:= undefined; is_integer(V) -> true;
(path, V, _) when V =:= undefined; is_binary(V) -> true;
(query, V, _) when V =:= undefined; is_binary(V) -> true;
(fragment, V, _) when V =:= undefined; is_binary(V) -> true;
(_, _, _) -> throw(invalid)
end, true, URI)
catch
throw:invalid -> false
end.