%%% @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) -> <>. %% @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 -> <<>>; _ -> <> 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(<>, SafeChars, Acc) -> case lists:member(Char, SafeChars) of true -> encode_chars(Rest, SafeChars, <>); false -> Encoded = percent_encode(Char), encode_chars(Rest, SafeChars, <>) 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, <>) catch _:_ -> decode_percent(Rest, <>) end; decode_percent(<>, Acc) -> decode_percent(Rest, <>). %% @private encode_query_param(Key, Value) -> EncodedKey = encode(to_binary(Key), query), EncodedValue = encode(to_binary(Value), query), <>) 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) -> <> 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.