5 changed files with 185 additions and 0 deletions
@ -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 |
||||||
@ -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 |
||||||
Loading…
Reference in new issue