diff --git a/lib/gc_index_relay/nostr.ex b/lib/gc_index_relay/nostr.ex index edd257b..1ab2725 100644 --- a/lib/gc_index_relay/nostr.ex +++ b/lib/gc_index_relay/nostr.ex @@ -12,6 +12,7 @@ defmodule GcIndexRelay.Nostr do import Ecto.Query, warn: false alias GcIndexRelay.Nostr.Validator alias GcIndexRelay.Nostr.PubEvent + alias GcIndexRelay.Nostr.Filter alias GcIndexRelay.Repo alias GcIndexRelay.Nostr.Event @@ -28,6 +29,26 @@ defmodule GcIndexRelay.Nostr do |> PubEvent.from_db() end + @doc """ + Queries Nostr events using a NIP-01 filter. + + Returns a list of `GcIndexRelay.Nostr.PubEvent` structs matching the filter criteria. + """ + @spec query_events(map()) :: {:ok, [PubEvent.t()]} | {:error, String.t()} + def query_events(filter_map) when is_map(filter_map) do + with {:ok, filter} <- Filter.from_map(filter_map), + events <- + from(e in Event) + |> Filter.apply(filter), + pub_events <- + Enum.map(events, fn event -> + {:ok, pub_event} = PubEvent.from_db(event) + pub_event + end) do + {:ok, pub_events} + end + end + @doc """ Writes a `GcIndexRelay.Nostr.PubEvent` to the database. """ diff --git a/lib/gc_index_relay_web/controllers/fallback_controller.ex b/lib/gc_index_relay_web/controllers/fallback_controller.ex index b980357..44b8aa8 100644 --- a/lib/gc_index_relay_web/controllers/fallback_controller.ex +++ b/lib/gc_index_relay_web/controllers/fallback_controller.ex @@ -14,6 +14,13 @@ defmodule GcIndexRelayWeb.FallbackController do |> render(:error, changeset: changeset) end + # This clause handles string error messages (e.g., from filter validation). + def call(conn, {:error, message}) when is_binary(message) do + conn + |> put_status(:bad_request) + |> json(%{errors: %{detail: message}}) + end + # This clause is an example of how to handle resources that cannot be found. def call(conn, {:error, :not_found}) do conn diff --git a/lib/gc_index_relay_web/controllers/filter_controller.ex b/lib/gc_index_relay_web/controllers/filter_controller.ex new file mode 100644 index 0000000..af6786d --- /dev/null +++ b/lib/gc_index_relay_web/controllers/filter_controller.ex @@ -0,0 +1,133 @@ +defmodule GcIndexRelayWeb.FilterController do + use GcIndexRelayWeb, :controller + + alias GcIndexRelay.Nostr + + action_fallback GcIndexRelayWeb.FallbackController + + @doc """ + GET /api/events - Query events by specifying NIP-01 filter parameters in the URL query string. + + # Required Parameters + + The `since`, `until`, and `limit` parameters are required. This ensures every query generates a + unique, repeatable response. Queries that do not specify `since`, `until`, or `limit` should be + made against POST /api/events/filter. + """ + def index(conn, params) do + with {:ok, filter_map} <- parse_query_params(params), + {:ok, events} <- Nostr.query_events(filter_map) do + render(conn, :index, events: events) + end + end + + @doc """ + POST /api/events/filter - Query events using a JSON filter in the request body. + """ + def query(conn, filter_params) do + with {:ok, events} <- Nostr.query_events(filter_params) do + render(conn, :index, events: events) + end + end + + # Parse query parameters into a NIP-01 filter map + @spec parse_query_params(map()) :: {:ok, map()} | {:error, String.t()} + defp parse_query_params(params) do + # Require since, until, and limit for client-side caching and predictable pagination + with :ok <- require_param(params, "since"), + :ok <- require_param(params, "until"), + :ok <- require_param(params, "limit"), + {:ok, recognized_params} <- validate_known_params(params) do + parse_params(recognized_params) + end + end + + # Ensure a required parameter is present + @spec require_param(map(), String.t()) :: :ok | {:error, String.t()} + defp require_param(params, key) do + if Map.has_key?(params, key) do + :ok + else + {:error, "Missing required parameter: '#{key}'"} + end + end + + # Validate that only known NIP-01 filter keys are present + @spec validate_known_params(map()) :: {:ok, map()} | {:error, String.t()} + defp validate_known_params(params) do + known_keys = ["ids", "authors", "kinds", "since", "until", "limit"] + + unknown_keys = + params + |> Map.keys() + |> Enum.reject(fn key -> + key in known_keys or String.starts_with?(key, "#") + end) + + case unknown_keys do + [] -> {:ok, params} + [key | _] -> {:error, "Unknown query parameter: '#{key}'"} + end + end + + # Parse individual parameters from strings to proper types + @spec parse_params(map()) :: {:ok, map()} | {:error, String.t()} + defp parse_params(params) do + result = + params + |> Enum.reduce_while({:ok, %{}}, fn {key, value}, {:ok, acc} -> + case parse_param(key, value) do + {:ok, parsed_value} -> {:cont, {:ok, Map.put(acc, key, parsed_value)}} + {:error, _} = error -> {:halt, error} + end + end) + + result + end + + # Parse individual parameter based on its key + @spec parse_param(String.t(), String.t()) :: {:ok, any()} | {:error, String.t()} + defp parse_param("ids", value), do: {:ok, String.split(value, ",")} + defp parse_param("authors", value), do: {:ok, String.split(value, ",")} + + defp parse_param("kinds", value) do + value + |> String.split(",") + |> Enum.reduce_while({:ok, []}, fn kind_str, {:ok, acc} -> + case Integer.parse(kind_str) do + {kind, ""} -> {:cont, {:ok, [kind | acc]}} + _ -> {:halt, {:error, "Invalid kind value: '#{kind_str}' must be an integer"}} + end + end) + |> case do + {:ok, kinds} -> {:ok, Enum.reverse(kinds)} + error -> error + end + end + + defp parse_param("since", value) do + case Integer.parse(value) do + {int, ""} -> {:ok, int} + _ -> {:error, "Invalid since value: '#{value}' must be an integer"} + end + end + + defp parse_param("until", value) do + case Integer.parse(value) do + {int, ""} -> {:ok, int} + _ -> {:error, "Invalid until value: '#{value}' must be an integer"} + end + end + + defp parse_param("limit", value) do + case Integer.parse(value) do + {int, ""} -> {:ok, int} + _ -> {:error, "Invalid limit value: '#{value}' must be an integer"} + end + end + + # Handle tag parameters (keys starting with "#") + defp parse_param("#" <> _tag_name, value) do + {:ok, String.split(value, ",")} + end +end diff --git a/lib/gc_index_relay_web/controllers/filter_json.ex b/lib/gc_index_relay_web/controllers/filter_json.ex new file mode 100644 index 0000000..d96711a --- /dev/null +++ b/lib/gc_index_relay_web/controllers/filter_json.ex @@ -0,0 +1,22 @@ +defmodule GcIndexRelayWeb.FilterJSON do + alias GcIndexRelay.Nostr.PubEvent + + @doc """ + Renders a list of events. + """ + def index(%{events: events}) do + %{data: Enum.map(events, &data/1)} + end + + defp data(%PubEvent{} = event) do + %{ + id: event.id, + pubkey: event.pubkey, + created_at: event.created_at, + kind: event.kind, + content: event.content, + sig: event.sig, + tags: event.tags + } + end +end diff --git a/lib/gc_index_relay_web/router.ex b/lib/gc_index_relay_web/router.ex index 36d4d05..9e3bbb0 100644 --- a/lib/gc_index_relay_web/router.ex +++ b/lib/gc_index_relay_web/router.ex @@ -23,6 +23,8 @@ defmodule GcIndexRelayWeb.Router do scope "/api", GcIndexRelayWeb do pipe_through :api + get "/events", FilterController, :index + post "/events/filter", FilterController, :query resources "/events", EventController, only: [:show, :create, :delete] end