Skip to main content

lib/noizu/mcp/uri_template.ex

defmodule Noizu.MCP.UriTemplate do
  @moduledoc """
  Minimal RFC 6570 (level 1) URI template support: simple `{var}` expressions.

      iex> Noizu.MCP.UriTemplate.match("db://{table}/schema", "db://users/schema")
      {:ok, %{table: "users"}}

      iex> Noizu.MCP.UriTemplate.match("db://{table}/schema", "db://users/data")
      :nomatch
  """

  @doc "Match a URI against a template; returns captured variables atom-keyed."
  @spec match(String.t(), String.t()) :: {:ok, map()} | :nomatch
  def match(template, uri) when is_binary(template) and is_binary(uri) do
    {regex, variables} = compile(template)

    case Regex.run(regex, uri, capture: :all_but_first) do
      nil ->
        :nomatch

      captures ->
        {:ok,
         variables
         |> Enum.zip(captures)
         |> Map.new(fn {variable, value} -> {variable, URI.decode(value)} end)}
    end
  end

  @doc "Variable names (atoms) appearing in a template, in order."
  @spec variables(String.t()) :: [atom()]
  def variables(template) do
    Regex.scan(~r/\{([a-zA-Z0-9_]+)\}/, template, capture: :all_but_first)
    |> Enum.map(fn [name] -> String.to_atom(name) end)
  end

  @doc "Expand a template with the given variables."
  @spec expand(String.t(), map()) :: String.t()
  def expand(template, vars) do
    Regex.replace(~r/\{([a-zA-Z0-9_]+)\}/, template, fn _, name ->
      value = Map.get(vars, String.to_existing_atom(name)) || Map.get(vars, name) || ""
      URI.encode_www_form(to_string(value))
    end)
  end

  defp compile(template) do
    variables = variables(template)

    pattern =
      template
      |> String.split(~r/\{[a-zA-Z0-9_]+\}/, include_captures: true)
      |> Enum.map_join(fn part ->
        if part =~ ~r/^\{[a-zA-Z0-9_]+\}$/ do
          "([^/]+)"
        else
          Regex.escape(part)
        end
      end)

    {Regex.compile!("^" <> pattern <> "$"), variables}
  end
end