Browse Source

Update code to pass tests

And update GET filter with query string to accept single-letter tags as query params

And enforce bounds on the limit query param for filter GET endpoint
master
buttercat1791 2 months ago
parent
commit
0c3920f3e9
  1. 25
      lib/gc_index_relay/nostr.ex
  2. 2
      lib/gc_index_relay/nostr/event.ex
  3. 24
      lib/gc_index_relay/nostr/filter.ex
  4. 2
      lib/gc_index_relay/nostr/tag.ex
  5. 16
      lib/gc_index_relay_web/controllers/event_controller.ex
  6. 4
      lib/gc_index_relay_web/controllers/event_json.ex
  7. 22
      lib/gc_index_relay_web/controllers/fallback_controller.ex
  8. 41
      lib/gc_index_relay_web/controllers/filter_controller.ex
  9. 4
      priv/repo/migrations/20260129043804_add_nostr_events.exs
  10. 2
      test/gc_index_relay_web/controllers/filter_controller_test.exs

25
lib/gc_index_relay/nostr.ex

@ -23,10 +23,13 @@ defmodule GcIndexRelay.Nostr do @@ -23,10 +23,13 @@ defmodule GcIndexRelay.Nostr do
"""
@spec get_event(binary()) :: {:ok, PubEvent.t()} | {:error, :not_found}
def get_event(id) when is_binary(id) do
Event
|> Repo.get(id)
|> Repo.preload(:tags)
|> PubEvent.from_db()
with {:ok, binary_id} <- Base.decode16(id, case: :lower),
%Event{} = event <- Repo.get(Event, binary_id),
event_with_tags <- Repo.preload(event, :tags) do
PubEvent.from_db(event_with_tags)
else
_ -> {:error, :not_found}
end
end
@doc """
@ -56,9 +59,11 @@ defmodule GcIndexRelay.Nostr do @@ -56,9 +59,11 @@ defmodule GcIndexRelay.Nostr do
with {:ok, event} <- Validator.validate_id(event),
{:ok, event} <- Validator.validate_signature(event) do
db_event = PubEvent.to_db(event)
tags_as_maps = Enum.map(db_event.tags, &Map.from_struct/1)
attrs = db_event |> Map.from_struct() |> Map.put(:tags, tags_as_maps)
%Event{}
|> Event.changeset(Map.from_struct(db_event))
|> Event.changeset(attrs)
|> Repo.insert()
end
end
@ -68,10 +73,10 @@ defmodule GcIndexRelay.Nostr do @@ -68,10 +73,10 @@ defmodule GcIndexRelay.Nostr do
"""
@spec delete_event(PubEvent.t()) :: {:ok, Ecto.Schema.t()} | {:error, Ecto.Changeset.t()}
def delete_event(event) when is_struct(event, PubEvent) do
db_event = PubEvent.to_db(event)
%Event{}
|> Event.changeset(Map.from_struct(db_event))
|> Repo.delete()
with {:ok, binary_id} <- Base.decode16(event.id, case: :lower) do
Repo.delete(%Event{id: binary_id})
else
_ -> {:error, :not_found}
end
end
end

2
lib/gc_index_relay/nostr/event.ex

@ -19,7 +19,7 @@ defmodule GcIndexRelay.Nostr.Event do @@ -19,7 +19,7 @@ defmodule GcIndexRelay.Nostr.Event do
alias GcIndexRelay.Nostr.Tag
@primary_key {:id, :binary_id, autogenerate: false}
@primary_key {:id, :binary, autogenerate: false}
schema "events" do
field :pubkey, :binary
field :created_at, :utc_datetime

24
lib/gc_index_relay/nostr/filter.ex

@ -248,12 +248,20 @@ defmodule GcIndexRelay.Nostr.Filter do @@ -248,12 +248,20 @@ defmodule GcIndexRelay.Nostr.Filter do
@spec apply_ids(Ecto.Query.t(), [String.t()] | nil) :: Ecto.Query.t()
defp apply_ids(query, nil), do: query
defp apply_ids(query, []), do: query
defp apply_ids(query, ids), do: where(query, [e], e.id in ^ids)
defp apply_ids(query, ids) do
binary_ids = Enum.map(ids, &Base.decode16!(&1, case: :lower))
where(query, [e], e.id in ^binary_ids)
end
@spec apply_authors(Ecto.Query.t(), [String.t()] | nil) :: Ecto.Query.t()
defp apply_authors(query, nil), do: query
defp apply_authors(query, []), do: query
defp apply_authors(query, authors), do: where(query, [e], e.pubkey in ^authors)
defp apply_authors(query, authors) do
binary_authors = Enum.map(authors, &Base.decode16!(&1, case: :lower))
where(query, [e], e.pubkey in ^binary_authors)
end
@spec apply_kinds(Ecto.Query.t(), [integer()] | nil) :: Ecto.Query.t()
defp apply_kinds(query, nil), do: query
@ -262,11 +270,19 @@ defmodule GcIndexRelay.Nostr.Filter do @@ -262,11 +270,19 @@ defmodule GcIndexRelay.Nostr.Filter do
@spec apply_since(Ecto.Query.t(), integer() | nil) :: Ecto.Query.t()
defp apply_since(query, nil), do: query
defp apply_since(query, since), do: where(query, [e], e.created_at >= ^since)
defp apply_since(query, since) when is_integer(since) do
datetime = DateTime.from_unix!(since)
where(query, [e], e.created_at >= ^datetime)
end
@spec apply_until(Ecto.Query.t(), integer() | nil) :: Ecto.Query.t()
defp apply_until(query, nil), do: query
defp apply_until(query, until), do: where(query, [e], e.created_at <= ^until)
defp apply_until(query, until) when is_integer(until) do
datetime = DateTime.from_unix!(until)
where(query, [e], e.created_at <= ^datetime)
end
@spec apply_tags(Ecto.Query.t(), map() | nil) :: Ecto.Query.t()
defp apply_tags(query, nil), do: query

2
lib/gc_index_relay/nostr/tag.ex

@ -6,7 +6,7 @@ defmodule GcIndexRelay.Nostr.Tag do @@ -6,7 +6,7 @@ defmodule GcIndexRelay.Nostr.Tag do
field :name, :string
field :value, :string
field :additional_values, {:array, :string}
belongs_to :event, GcIndexRelay.Nostr.Event
belongs_to :event, GcIndexRelay.Nostr.Event, type: :binary
end
@doc false

16
lib/gc_index_relay_web/controllers/event_controller.ex

@ -20,12 +20,14 @@ defmodule GcIndexRelayWeb.EventController do @@ -20,12 +20,14 @@ defmodule GcIndexRelayWeb.EventController do
end
def create(conn, %{"event" => event_params}) do
# TODO: Add 400 for invalid event in FallbackController
with {:ok, %Event{} = event} <- Nostr.create_event(event_params) do
atoms_map = Map.new(event_params, fn {k, v} -> {String.to_existing_atom(k), v} end)
pub_event = struct(PubEvent, atoms_map)
with {:ok, _event} <- Nostr.create_event(pub_event) do
conn
|> put_status(:created)
|> put_resp_header("location", ~p"/api/events/#{event.id}")
|> render(:show, event: event)
|> put_resp_header("location", ~p"/api/events/#{pub_event.id}")
|> render(:show, event: pub_event)
end
end
@ -45,9 +47,9 @@ defmodule GcIndexRelayWeb.EventController do @@ -45,9 +47,9 @@ defmodule GcIndexRelayWeb.EventController do
end
def show(conn, %{"id" => id}) do
# TODO: Add 404 for event not found in FallbackController
event = Nostr.get_event(id)
render(conn, :show, event: event)
with {:ok, pub_event} <- Nostr.get_event(id) do
render(conn, :show, event: pub_event)
end
end
swagger_path :delete do

4
lib/gc_index_relay_web/controllers/event_json.ex

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
defmodule GcIndexRelayWeb.EventJSON do
alias GcIndexRelay.Nostr.Event
alias GcIndexRelay.Nostr.PubEvent
@doc """
Renders a single event.
@ -8,7 +8,7 @@ defmodule GcIndexRelayWeb.EventJSON do @@ -8,7 +8,7 @@ defmodule GcIndexRelayWeb.EventJSON do
%{data: data(event)}
end
defp data(%Event{} = event) do
defp data(%PubEvent{} = event) do
%{
id: event.id,
pubkey: event.pubkey,

22
lib/gc_index_relay_web/controllers/fallback_controller.ex

@ -7,11 +7,18 @@ defmodule GcIndexRelayWeb.FallbackController do @@ -7,11 +7,18 @@ defmodule GcIndexRelayWeb.FallbackController do
use GcIndexRelayWeb, :controller
# This clause handles errors returned by Ecto's insert/update/delete.
# Returns 409 Conflict for duplicate event IDs, 422 for other changeset errors.
def call(conn, {:error, %Ecto.Changeset{} = changeset}) do
conn
|> put_status(:unprocessable_entity)
|> put_view(json: GcIndexRelayWeb.ChangesetJSON)
|> render(:error, changeset: changeset)
if duplicate_id_error?(changeset) do
conn
|> put_status(:conflict)
|> json(%{errors: %{detail: "Event already exists"}})
else
conn
|> put_status(:unprocessable_entity)
|> put_view(json: GcIndexRelayWeb.ChangesetJSON)
|> render(:error, changeset: changeset)
end
end
# This clause handles string error messages (e.g., from filter validation).
@ -28,4 +35,11 @@ defmodule GcIndexRelayWeb.FallbackController do @@ -28,4 +35,11 @@ defmodule GcIndexRelayWeb.FallbackController do
|> put_view(html: GcIndexRelayWeb.ErrorHTML, json: GcIndexRelayWeb.ErrorJSON)
|> render(:"404")
end
defp duplicate_id_error?(%Ecto.Changeset{errors: errors}) do
Enum.any?(errors, fn
{:id, {_, opts}} -> Keyword.get(opts, :constraint) == :unique
_ -> false
end)
end
end

41
lib/gc_index_relay_web/controllers/filter_controller.ex

@ -83,7 +83,7 @@ defmodule GcIndexRelayWeb.FilterController do @@ -83,7 +83,7 @@ defmodule GcIndexRelayWeb.FilterController do
@spec validate_param_values(map()) :: {:ok, map()} | {:error, String.t()}
def validate_param_values(params) do
%{:limit => limit} = params
%{"limit" => limit} = params
if limit < 1 or limit > 100 do
{:error, "The filter limit must be between 1 and 100."}
@ -123,7 +123,9 @@ defmodule GcIndexRelayWeb.FilterController do @@ -123,7 +123,9 @@ defmodule GcIndexRelayWeb.FilterController do
params
|> Map.keys()
|> Enum.reject(fn key ->
key in known_keys or String.starts_with?(key, "#")
key in known_keys or String.starts_with?(key, "#") or
(byte_size(key) == 1 and
((key >= "a" and key <= "z") or (key >= "A" and key <= "Z")))
end)
case unknown_keys do
@ -135,16 +137,22 @@ defmodule GcIndexRelayWeb.FilterController do @@ -135,16 +137,22 @@ defmodule GcIndexRelayWeb.FilterController do
# 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
params
|> Enum.reduce_while({:ok, %{}}, fn {key, value}, {:ok, acc} ->
case parse_param(key, value) do
{:ok, parsed_value} ->
out_key =
if byte_size(key) == 1 and
((key >= "a" and key <= "z") or (key >= "A" and key <= "Z")),
do: "#" <> key,
else: key
{:cont, {:ok, Map.put(acc, out_key, parsed_value)}}
{:error, _} = error ->
{:halt, error}
end
end)
end
# Parse individual parameter based on its key
@ -183,7 +191,8 @@ defmodule GcIndexRelayWeb.FilterController do @@ -183,7 +191,8 @@ defmodule GcIndexRelayWeb.FilterController do
defp parse_param("limit", value) do
case Integer.parse(value) do
{int, ""} -> {:ok, int}
{int, ""} when int >= 1 and int <= 100 -> {:ok, int}
{int, ""} when is_integer(int) -> {:error, "The limit must be between 1 and 100."}
_ -> {:error, "Invalid limit value: '#{value}' must be an integer"}
end
end
@ -192,4 +201,10 @@ defmodule GcIndexRelayWeb.FilterController do @@ -192,4 +201,10 @@ defmodule GcIndexRelayWeb.FilterController do
defp parse_param("#" <> _tag_name, value) do
{:ok, String.split(value, ",")}
end
# Handle single-letter tag filters without "#" prefix (e.g., "p" instead of "#p")
# The "#" is trimmed by URL fragment parsing; bare single-letter keys are treated as tag filters
defp parse_param(<<letter>> = _key, value) when letter in ?a..?z do
{:ok, String.split(value, ",")}
end
end

4
priv/repo/migrations/20260129043804_add_nostr_events.exs

@ -3,7 +3,7 @@ defmodule GcIndexRelay.Repo.Migrations.AddNostrEvents do @@ -3,7 +3,7 @@ defmodule GcIndexRelay.Repo.Migrations.AddNostrEvents do
def change do
create table(:events, primary_key: false) do
add :id, :binary_id, primary_key: true
add :id, :binary, primary_key: true
add :pubkey, :binary, null: false
add :created_at, :utc_datetime, null: false
add :kind, :integer, null: false
@ -18,7 +18,7 @@ defmodule GcIndexRelay.Repo.Migrations.AddNostrEvents do @@ -18,7 +18,7 @@ defmodule GcIndexRelay.Repo.Migrations.AddNostrEvents do
add :name, :string, null: false
add :value, :string, null: false
add :additional_values, {:array, :string}
add :event_id, references(:events, type: :binary_id, on_delete: :delete_all), null: false
add :event_id, references(:events, type: :binary, on_delete: :delete_all), null: false
end
create index(:tags, [:event_id])

2
test/gc_index_relay_web/controllers/filter_controller_test.exs

@ -73,7 +73,7 @@ defmodule GcIndexRelayWeb.FilterControllerTest do @@ -73,7 +73,7 @@ defmodule GcIndexRelayWeb.FilterControllerTest do
{:ok, _} = GcIndexRelay.Nostr.create_event(tagged_pub_event)
event_fixture(%{kind: 2, created_at: 1_640_000_001})
conn = get(conn, ~p"/api/events?since=0&until=9999999999&limit=10&#p=abc123def456")
conn = get(conn, ~p"/api/events?since=0&until=9999999999&limit=10&p=abc123def456")
assert %{"data" => events} = json_response(conn, 200)
assert length(events) == 1

Loading…
Cancel
Save