From 34291d5bb42c03d92522dfa18d38aa0d22bcb8ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Hoguin?= Date: Wed, 18 Dec 2019 21:13:04 +0100 Subject: Add cow_uri_templates Implements RFC6570 level 4. --- src/cow_uri_templates.erl | 356 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 356 insertions(+) create mode 100644 src/cow_uri_templates.erl (limited to 'src') diff --git a/src/cow_uri_templates.erl b/src/cow_uri_templates.erl new file mode 100644 index 0000000..32ca07c --- /dev/null +++ b/src/cow_uri_templates.erl @@ -0,0 +1,356 @@ +%% Copyright (c) 2019, Loïc Hoguin +%% +%% 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_templates). + +-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(<>, Acc) when C =/= $} -> + parse(R, <>). + +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(<>, Op, List) + when ?IS_ALPHANUM(C) or (C =:= $_) -> + parse_varname(R, Op, List, <>). + +parse_varname(<>, Op, List, Name) + when ?IS_ALPHANUM(C) or (C =:= $_) or (C =:= $.) or (C =:= $%) -> + parse_varname(R, Op, List, <>); +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, <>); +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(<>, Op, List, Name, Acc) + when ?IS_DIGIT(C), byte_size(Acc) < 4 -> + parse_prefix_modifier(R, Op, List, Name, <>); +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(<>, Acc) + when ?IS_URI_UNRESERVED(C) -> + urlencode_unreserved(R, <>); +urlencode_unreserved(<>, Acc) -> + urlencode_unreserved(R, <>); +urlencode_unreserved(<<>>, Acc) -> + Acc. + +urlencode_reserved(<>, Acc) + when ?IS_URI_UNRESERVED(C) or ?IS_URI_GEN_DELIMS(C) or ?IS_URI_SUB_DELIMS(C) -> + urlencode_reserved(R, <>); +urlencode_reserved(<>, Acc) -> + urlencode_reserved(R, <>); +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. -- cgit v1.2.3