aboutsummaryrefslogblamecommitdiffstats
path: root/src/cow_uri_template.erl
blob: eac784fd8d1f0769bfb67c218fa677069b4e20f0 (plain) (tree)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
















                                                                           
                          

















































































































































































































































































































































                                                                                                                      
%% Copyright (c) 2019, Loïc Hoguin <[email protected]>
%%
%% Permission to use, copy, modify, and/or distribute this software for any
%% purpose with or without fee is hereby granted, provided that the above
%% copyright notice and this permission notice appear in all copies.
%%
%% THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
%% WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
%% MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
%% ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
%% WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
%% ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
%% OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.

%% This is a full level 4 implementation of URI Templates
%% as defined by RFC6570.

-module(cow_uri_template).

-export([parse/1]).
-export([expand/2]).

-type op() :: simple_string_expansion
	| reserved_expansion
	| fragment_expansion
	| label_expansion_with_dot_prefix
	| path_segment_expansion
	| path_style_parameter_expansion
	| form_style_query_expansion
	| form_style_query_continuation.

-type var_list() :: [
	{no_modifier, binary()}
	| {{prefix_modifier, pos_integer()}, binary()}
	| {explode_modifier, binary()}
].

-type uri_template() :: [
	binary() | {expr, op(), var_list()}
].
-export_type([uri_template/0]).

-type variables() :: #{
	binary() => binary()
		| integer()
		| float()
		| [binary()]
		| #{binary() => binary()}
}.

-include("cow_inline.hrl").
-include("cow_parse.hrl").

%% Parse a URI template.

-spec parse(binary()) -> uri_template().
parse(URITemplate) ->
	parse(URITemplate, <<>>).

parse(<<>>, <<>>) ->
	[];
parse(<<>>, Acc) ->
	[Acc];
parse(<<${,R/bits>>, <<>>) ->
	parse_expr(R);
parse(<<${,R/bits>>, Acc) ->
	[Acc|parse_expr(R)];
%% @todo Probably should reject unallowed characters so that
%% we don't produce invalid URIs.
parse(<<C,R/bits>>, Acc) when C =/= $} ->
	parse(R, <<Acc/binary, C>>).

parse_expr(<<$+,R/bits>>) ->
	parse_var_list(R, reserved_expansion, []);
parse_expr(<<$#,R/bits>>) ->
	parse_var_list(R, fragment_expansion, []);
parse_expr(<<$.,R/bits>>) ->
	parse_var_list(R, label_expansion_with_dot_prefix, []);
parse_expr(<<$/,R/bits>>) ->
	parse_var_list(R, path_segment_expansion, []);
parse_expr(<<$;,R/bits>>) ->
	parse_var_list(R, path_style_parameter_expansion, []);
parse_expr(<<$?,R/bits>>) ->
	parse_var_list(R, form_style_query_expansion, []);
parse_expr(<<$&,R/bits>>) ->
	parse_var_list(R, form_style_query_continuation, []);
parse_expr(R) ->
	parse_var_list(R, simple_string_expansion, []).

parse_var_list(<<C,R/bits>>, Op, List)
		when ?IS_ALPHANUM(C) or (C =:= $_) ->
	parse_varname(R, Op, List, <<C>>).

parse_varname(<<C,R/bits>>, Op, List, Name)
		when ?IS_ALPHANUM(C) or (C =:= $_) or (C =:= $.) or (C =:= $%) ->
	parse_varname(R, Op, List, <<Name/binary,C>>);
parse_varname(<<$:,C,R/bits>>, Op, List, Name)
		when (C =:= $1) or (C =:= $2) or (C =:= $3) or (C =:= $4) or (C =:= $5)
			or (C =:= $6) or (C =:= $7) or (C =:= $8) or (C =:= $9) ->
	parse_prefix_modifier(R, Op, List, Name, <<C>>);
parse_varname(<<$*,$,,R/bits>>, Op, List, Name) ->
	parse_var_list(R, Op, [{explode_modifier, Name}|List]);
parse_varname(<<$*,$},R/bits>>, Op, List, Name) ->
	[{expr, Op, lists:reverse([{explode_modifier, Name}|List])}|parse(R, <<>>)];
parse_varname(<<$,,R/bits>>, Op, List, Name) ->
	parse_var_list(R, Op, [{no_modifier, Name}|List]);
parse_varname(<<$},R/bits>>, Op, List, Name) ->
	[{expr, Op, lists:reverse([{no_modifier, Name}|List])}|parse(R, <<>>)].

parse_prefix_modifier(<<C,R/bits>>, Op, List, Name, Acc)
		when ?IS_DIGIT(C), byte_size(Acc) < 4 ->
	parse_prefix_modifier(R, Op, List, Name, <<Acc/binary,C>>);
parse_prefix_modifier(<<$,,R/bits>>, Op, List, Name, Acc) ->
	parse_var_list(R, Op, [{{prefix_modifier, binary_to_integer(Acc)}, Name}|List]);
parse_prefix_modifier(<<$},R/bits>>, Op, List, Name, Acc) ->
	[{expr, Op, lists:reverse([{{prefix_modifier, binary_to_integer(Acc)}, Name}|List])}|parse(R, <<>>)].

%% Expand a URI template (after parsing it if necessary).

-spec expand(binary() | uri_template(), variables()) -> iodata().
expand(URITemplate, Vars) when is_binary(URITemplate) ->
	expand(parse(URITemplate), Vars);
expand(URITemplate, Vars) ->
	expand1(URITemplate, Vars).

expand1([], _) ->
	[];
expand1([Literal|Tail], Vars) when is_binary(Literal) ->
	[Literal|expand1(Tail, Vars)];
expand1([{expr, simple_string_expansion, VarList}|Tail], Vars) ->
	[simple_string_expansion(VarList, Vars)|expand1(Tail, Vars)];
expand1([{expr, reserved_expansion, VarList}|Tail], Vars) ->
	[reserved_expansion(VarList, Vars)|expand1(Tail, Vars)];
expand1([{expr, fragment_expansion, VarList}|Tail], Vars) ->
	[fragment_expansion(VarList, Vars)|expand1(Tail, Vars)];
expand1([{expr, label_expansion_with_dot_prefix, VarList}|Tail], Vars) ->
	[label_expansion_with_dot_prefix(VarList, Vars)|expand1(Tail, Vars)];
expand1([{expr, path_segment_expansion, VarList}|Tail], Vars) ->
	[path_segment_expansion(VarList, Vars)|expand1(Tail, Vars)];
expand1([{expr, path_style_parameter_expansion, VarList}|Tail], Vars) ->
	[path_style_parameter_expansion(VarList, Vars)|expand1(Tail, Vars)];
expand1([{expr, form_style_query_expansion, VarList}|Tail], Vars) ->
	[form_style_query_expansion(VarList, Vars)|expand1(Tail, Vars)];
expand1([{expr, form_style_query_continuation, VarList}|Tail], Vars) ->
	[form_style_query_continuation(VarList, Vars)|expand1(Tail, Vars)].

simple_string_expansion(VarList, Vars) ->
	lists:join($,, [
		apply_modifier(Modifier, unreserved, $,, Value)
	|| {Modifier, _Name, Value} <- lookup_variables(VarList, Vars)]).

reserved_expansion(VarList, Vars) ->
	lists:join($,, [
		apply_modifier(Modifier, reserved, $,, Value)
	|| {Modifier, _Name, Value} <- lookup_variables(VarList, Vars)]).

fragment_expansion(VarList, Vars) ->
	case reserved_expansion(VarList, Vars) of
		[] -> [];
		Expanded -> [$#, Expanded]
	end.

label_expansion_with_dot_prefix(VarList, Vars) ->
	segment_expansion(VarList, Vars, $.).

path_segment_expansion(VarList, Vars) ->
	segment_expansion(VarList, Vars, $/).

segment_expansion(VarList, Vars, Sep) ->
	Expanded = lists:join(Sep, [
		apply_modifier(Modifier, unreserved, Sep, Value)
	|| {Modifier, _Name, Value} <- lookup_variables(VarList, Vars)]),
	case Expanded of
		[] -> [];
		[[]] -> [];
		_ -> [Sep, Expanded]
	end.

path_style_parameter_expansion(VarList, Vars) ->
	parameter_expansion(VarList, Vars, $;, $;, trim).

form_style_query_expansion(VarList, Vars) ->
	parameter_expansion(VarList, Vars, $?, $&, no_trim).

form_style_query_continuation(VarList, Vars) ->
	parameter_expansion(VarList, Vars, $&, $&, no_trim).

parameter_expansion(VarList, Vars, LeadingSep, Sep, Trim) ->
	Expanded = lists:join(Sep, [
		apply_parameter_modifier(Modifier, unreserved, Sep, Trim, Name, Value)
	|| {Modifier, Name, Value} <- lookup_variables(VarList, Vars)]),
	case Expanded of
		[] -> [];
		[[]] -> [];
		_ -> [LeadingSep, Expanded]
	end.

lookup_variables([], _) ->
	[];
lookup_variables([{Modifier, Name}|Tail], Vars) ->
	case Vars of
		#{Name := Value} -> [{Modifier, Name, Value}|lookup_variables(Tail, Vars)];
		_ -> lookup_variables(Tail, Vars)
	end.

apply_modifier(no_modifier, AllowedChars, _, List) when is_list(List) ->
	lists:join($,, [urlencode(Value, AllowedChars) || Value <- List]);
apply_modifier(explode_modifier, AllowedChars, ExplodeSep, List) when is_list(List) ->
	lists:join(ExplodeSep, [urlencode(Value, AllowedChars) || Value <- List]);
apply_modifier(Modifier, AllowedChars, ExplodeSep, Map) when is_map(Map) ->
	{JoinSep, KVSep} = case Modifier of
		no_modifier -> {$,, $,};
		explode_modifier -> {ExplodeSep, $=}
	end,
	lists:reverse(lists:join(JoinSep,
		maps:fold(fun(Key, Value, Acc) ->
			[[
				urlencode(Key, AllowedChars),
				KVSep,
				urlencode(Value, AllowedChars)
			]|Acc]
		end, [], Map)
	));
apply_modifier({prefix_modifier, MaxLen}, AllowedChars, _, Value) ->
	urlencode(string:slice(binarize(Value), 0, MaxLen), AllowedChars);
apply_modifier(_, AllowedChars, _, Value) ->
	urlencode(binarize(Value), AllowedChars).

apply_parameter_modifier(_, _, _, _, _, []) ->
	[];
apply_parameter_modifier(_, _, _, _, _, Map) when Map =:= #{} ->
	[];
apply_parameter_modifier(no_modifier, AllowedChars, _, _, Name, List) when is_list(List) ->
	[
		Name,
		$=,
		lists:join($,, [urlencode(Value, AllowedChars) || Value <- List])
	];
apply_parameter_modifier(explode_modifier, AllowedChars, ExplodeSep, _, Name, List) when is_list(List) ->
	lists:join(ExplodeSep, [[
		Name,
		$=,
		urlencode(Value, AllowedChars)
	] || Value <- List]);
apply_parameter_modifier(Modifier, AllowedChars, ExplodeSep, _, Name, Map) when is_map(Map) ->
	{JoinSep, KVSep} = case Modifier of
		no_modifier -> {$,, $,};
		explode_modifier -> {ExplodeSep, $=}
	end,
	[
		case Modifier of
			no_modifier ->
				[
					Name,
					$=
				];
			explode_modifier ->
				[]
		end,
		lists:reverse(lists:join(JoinSep,
			maps:fold(fun(Key, Value, Acc) ->
				[[
					urlencode(Key, AllowedChars),
					KVSep,
					urlencode(Value, AllowedChars)
				]|Acc]
			end, [], Map)
		))
	];
apply_parameter_modifier(Modifier, AllowedChars, _, Trim, Name, Value0) ->
	Value1 = binarize(Value0),
	Value = case Modifier of
		{prefix_modifier, MaxLen} ->
			string:slice(Value1, 0, MaxLen);
		no_modifier ->
			Value1
	end,
	[
		Name,
		case Value of
			<<>> when Trim =:= trim ->
				[];
			<<>> when Trim =:= no_trim ->
				$=;
			_ ->
				[
					$=,
					urlencode(Value, AllowedChars)
				]
		end
	].

binarize(Value) when is_integer(Value) ->
	integer_to_binary(Value);
binarize(Value) when is_float(Value) ->
	float_to_binary(Value, [{decimals, 10}, compact]);
binarize(Value) ->
	Value.

urlencode(Value, unreserved) ->
	urlencode_unreserved(Value, <<>>);
urlencode(Value, reserved) ->
	urlencode_reserved(Value, <<>>).

urlencode_unreserved(<<C,R/bits>>, Acc)
		when ?IS_URI_UNRESERVED(C) ->
	urlencode_unreserved(R, <<Acc/binary,C>>);
urlencode_unreserved(<<C,R/bits>>, Acc) ->
	urlencode_unreserved(R, <<Acc/binary,$%,?HEX(C)>>);
urlencode_unreserved(<<>>, Acc) ->
	Acc.

urlencode_reserved(<<C,R/bits>>, Acc)
		when ?IS_URI_UNRESERVED(C) or ?IS_URI_GEN_DELIMS(C) or ?IS_URI_SUB_DELIMS(C) ->
	urlencode_reserved(R, <<Acc/binary,C>>);
urlencode_reserved(<<C,R/bits>>, Acc) ->
	urlencode_reserved(R, <<Acc/binary,$%,?HEX(C)>>);
urlencode_reserved(<<>>, Acc) ->
	Acc.

-ifdef(TEST).
expand_uritemplate_test_() ->
	Files = filelib:wildcard("deps/uritemplate-tests/*.json"),
	lists:flatten([begin
		{ok, JSON} = file:read_file(File),
		Tests = jsx:decode(JSON, [return_maps]),
		[begin
			%% Erlang doesn't have a NULL value.
			Vars = maps:remove(<<"undef">>, Vars0),
			[
				{iolist_to_binary(io_lib:format("~s - ~s: ~s => ~s",
					[filename:basename(File), Section, URITemplate,
						if
							is_list(Expected) -> lists:join(<<" OR ">>, Expected);
							true -> Expected
						end
					])),
					fun() ->
						case Expected of
							false ->
								{'EXIT', _} = (catch expand(URITemplate, Vars));
							[_|_] ->
								Result = iolist_to_binary(expand(URITemplate, Vars)),
								io:format("~p", [Result]),
								true = lists:member(Result, Expected);
							_ ->
								Expected = iolist_to_binary(expand(URITemplate, Vars))
						end
					end}
			|| [URITemplate, Expected] <- Cases]
		end || {Section, #{
			<<"variables">> := Vars0,
			<<"testcases">> := Cases
		}} <- maps:to_list(Tests)]
	end || File <- Files]).
-endif.