Skip to main content

src/barrel_mcp_uri_template.erl

%%%-------------------------------------------------------------------
%%% @doc Minimal RFC 6570 Level-1 URI Template matcher and expander.
%%%
%%% Used by the server's `resources/read' handler to route a
%%% concrete URI to a registered `resource_template'. We only
%%% implement Level 1 (`{var}' simple expansion) — that covers
%%% every template the spec's reference examples use, and it's
%%% the level the MCP server registry actually accepts.
%%%
%%% Examples:
%%% ```
%%% match(<<"file:///etc/hosts">>, <<"file:///{path}">>).
%%%   %% => {ok, #{<<"path">> => <<"etc/hosts">>}}
%%%
%%% match(<<"https://api.x/v1/users/42">>,
%%%       <<"https://api.x/v1/{kind}/{id}">>).
%%%   %% => {ok, #{<<"kind">> => <<"users">>,
%%%   %%          <<"id">>   => <<"42">>}}
%%%
%%% expand(<<"file:///{path}">>, #{<<"path">> => <<"etc/hosts">>}).
%%%   %% => {ok, <<"file:///etc/hosts">>}
%%% '''
%%% @end
%%%-------------------------------------------------------------------
-module(barrel_mcp_uri_template).

-export([match/2, expand/2]).

%%====================================================================
%% Public API
%%====================================================================

%% @doc Match `Uri' against `Template'. Returns `{ok, Vars}' where
%% `Vars' is a binary-keyed map of substituted values, or `nomatch'
%% if the URI doesn't fit the template. Variables are matched
%% greedily but stop at literal segments (a value never crosses a
%% literal `/' that the template requires after the variable).
-spec match(binary(), binary()) -> {ok, map()} | nomatch.
match(Uri, Template) when is_binary(Uri), is_binary(Template) ->
    case parse(Template) of
        {ok, Tokens} ->
            do_match(Uri, Tokens, #{});
        {error, _} ->
            nomatch
    end.

%% @doc Expand `Template' by substituting `Vars'. Returns the
%% concrete URI or `{error, {missing_var, Name}}' if a variable
%% in the template has no value in `Vars'.
-spec expand(binary(), map()) -> {ok, binary()} | {error, term()}.
expand(Template, Vars) when is_binary(Template), is_map(Vars) ->
    case parse(Template) of
        {ok, Tokens} ->
            do_expand(Tokens, Vars, []);
        {error, _} = Err ->
            Err
    end.

%%====================================================================
%% Internal — template parser
%%====================================================================

%% Parse a template into a list of `{literal, Bin} | {var, Name}'
%% tokens.
parse(Template) ->
    parse(Template, <<>>, []).

parse(<<>>, Acc, Tokens) ->
    {ok, lists:reverse(emit_literal(Acc, Tokens))};
parse(<<"{", Rest/binary>>, Acc, Tokens) ->
    case binary:split(Rest, <<"}">>) of
        [Name, More] when Name =/= <<>> ->
            Tokens1 = emit_literal(Acc, Tokens),
            parse(More, <<>>, [{var, Name} | Tokens1]);
        _ ->
            {error, {malformed_template, template_for_error(Acc, Rest)}}
    end;
parse(<<C, Rest/binary>>, Acc, Tokens) ->
    parse(Rest, <<Acc/binary, C>>, Tokens).

emit_literal(<<>>, Tokens) -> Tokens;
emit_literal(Bin, Tokens) -> [{literal, Bin} | Tokens].

template_for_error(Acc, Rest) ->
    <<Acc/binary, Rest/binary>>.

%%====================================================================
%% Internal — match
%%====================================================================

do_match(<<>>, [], Vars) ->
    {ok, Vars};
do_match(_, [], _) ->
    nomatch;
do_match(Uri, [{literal, Lit} | Rest], Vars) ->
    case starts_with(Uri, Lit) of
        {true, Tail} -> do_match(Tail, Rest, Vars);
        false -> nomatch
    end;
do_match(Uri, [{var, Name}], Vars) ->
    %% Trailing variable — consume the rest. Empty value is
    %% allowed only if the template clearly expected an empty
    %% suffix; here we require at least one character.
    case Uri of
        <<>> -> nomatch;
        _ -> {ok, Vars#{Name => Uri}}
    end;
do_match(Uri, [{var, Name}, {literal, NextLit} | Rest], Vars) ->
    %% Variable followed by a literal: consume up to the next
    %% occurrence of NextLit.
    case binary:match(Uri, NextLit) of
        nomatch ->
            nomatch;
        {Pos, Len} when Pos > 0 ->
            <<Value:Pos/binary, _:Len/binary, Tail/binary>> = Uri,
            do_match(Tail, Rest, Vars#{Name => Value});
        {0, _} ->
            %% Empty variable value not allowed here.
            nomatch
    end;
do_match(Uri, [{var, Name1}, {var, Name2} | Rest], Vars) ->
    %% Two variables in a row with no literal between them —
    %% ambiguous. RFC 6570 doesn't require us to support this; we
    %% treat the first as everything up to the last separator and
    %% the second as the remainder, but most MCP templates avoid
    %% this shape. Implement simply: split on the next `/'.
    case binary:split(Uri, <<"/">>) of
        [V1, V2] when V1 =/= <<>>, V2 =/= <<>> ->
            do_match(
                <<>>,
                Rest,
                Vars#{Name1 => V1, Name2 => V2}
            );
        _ ->
            nomatch
    end.

starts_with(Bin, Prefix) ->
    case byte_size(Bin) >= byte_size(Prefix) of
        false ->
            false;
        true ->
            PSize = byte_size(Prefix),
            <<Head:PSize/binary, Tail/binary>> = Bin,
            case Head =:= Prefix of
                true -> {true, Tail};
                false -> false
            end
    end.

%%====================================================================
%% Internal — expand
%%====================================================================

do_expand([], _Vars, Acc) ->
    {ok, iolist_to_binary(lists:reverse(Acc))};
do_expand([{literal, Lit} | Rest], Vars, Acc) ->
    do_expand(Rest, Vars, [Lit | Acc]);
do_expand([{var, Name} | Rest], Vars, Acc) ->
    case maps:find(Name, Vars) of
        {ok, V} when is_binary(V) ->
            do_expand(Rest, Vars, [V | Acc]);
        {ok, V} when is_list(V) ->
            do_expand(Rest, Vars, [iolist_to_binary(V) | Acc]);
        error ->
            {error, {missing_var, Name}}
    end.