diff --git a/lib/gc_index_relay/nostr/publication_search.ex b/lib/gc_index_relay/nostr/publication_search.ex new file mode 100644 index 0000000..9e219a4 --- /dev/null +++ b/lib/gc_index_relay/nostr/publication_search.ex @@ -0,0 +1,150 @@ +defmodule GcIndexRelay.Nostr.PublicationSearch do + @moduledoc """ + Exact-match search over kind **30040** publication index metadata (`d`, `title`, `author`, `source`). + + Matches jumble's `publicationFieldExactMatch/2` normalization (case-insensitive, hyphen/space). + """ + + import Ecto.Query, warn: false + + alias GcIndexRelay.Nostr.Event + alias GcIndexRelay.Nostr.PubEvent + alias GcIndexRelay.Nostr.Tag + alias GcIndexRelay.Repo + + @publication_kind 30_040 + @search_tag_names ~w(d title author source) + + @doc """ + Search kind-30040 events by exact metadata match. Returns newest first. + """ + @spec search(String.t(), keyword()) :: {:ok, [PubEvent.t()]} | {:error, String.t()} + def search(query, opts \\ []) when is_binary(query) do + needles = query_needles(query) + + if needles == [] do + {:ok, []} + else + limit = opts |> Keyword.get(:limit, 25) |> clamp_limit() + do_search(needles, limit) + end + end + + defp clamp_limit(limit) when is_integer(limit), do: limit |> max(1) |> min(100) + defp clamp_limit(_), do: 25 + + defp do_search(needles, limit) do + spaced_needles = Enum.map(needles, &spaced_form/1) |> Enum.uniq() + + tag_match = + Enum.reduce(needles, dynamic(false), fn needle, acc -> + spaced = spaced_form(needle) + + dynamic( + [t], + ^acc or + fragment("LOWER(TRIM(?)) = ?", t.value, ^needle) or + fragment("LOWER(TRIM(REPLACE(?, '-', ' '))) = ?", t.value, ^spaced) + ) + end) + + # Also match when the stored value's spaced form equals any spaced needle. + tag_match = + Enum.reduce(spaced_needles, tag_match, fn spaced, acc -> + dynamic( + [t], + ^acc or fragment("LOWER(TRIM(REPLACE(?, '-', ' '))) = ?", t.value, ^spaced) + ) + end) + + event_ids = + from(t in Tag, + inner_join: e in Event, + on: t.event_id == e.id, + where: e.kind == ^@publication_kind, + where: t.name in ^@search_tag_names, + where: ^tag_match, + distinct: e.id, + order_by: [desc: e.created_at], + limit: ^limit, + select: e.id + ) + |> Repo.all() + + events = + from(e in Event, + where: e.id in ^event_ids, + order_by: [desc: e.created_at], + preload: [:tags] + ) + |> Repo.all() + + pub_events_from_db(events) + end + + defp pub_events_from_db(events) do + Enum.reduce_while(events, {:ok, []}, fn event, {:ok, acc} -> + case PubEvent.from_db(event) do + {:ok, pub_event} -> {:cont, {:ok, [pub_event | acc]}} + {:error, _} = err -> {:halt, err} + end + end) + |> case do + {:ok, list} -> {:ok, Enum.reverse(list)} + {:error, _} = err -> err + end + end + + @doc false + def query_needles(query) do + query + |> strip_quotes() + |> String.trim() + |> case do + "" -> + [] + + raw -> + lower = String.downcase(raw) + normalized = lower |> String.replace(~r/\s+/, " ") |> String.trim() + + hyphen = + lower + |> String.replace(~r/\s+/, "-") + |> String.replace(~r/-+/, "-") + |> String.trim("-") + + [lower, normalized, hyphen] + |> Enum.reject(&(&1 == "")) + |> Enum.uniq() + end + end + + defp strip_quotes(raw) do + trimmed = String.trim(raw) + + pairs = [ + {"\"", "\""}, + {"'", "'"}, + {"“", "”"}, + {"‘", "’"} + ] + + Enum.reduce(pairs, trimmed, fn {open, close}, acc -> + if String.length(acc) >= 2 and String.starts_with?(acc, open) and + String.ends_with?(acc, close) do + acc |> String.slice(1..-2//1) |> String.trim() + else + acc + end + end) + end + + defp spaced_form(value) do + value + |> String.downcase() + |> String.replace("-", " ") + |> String.replace(~r/\s+/, " ") + |> String.trim() + end +end diff --git a/lib/gc_index_relay_web/controllers/api_controller.ex b/lib/gc_index_relay_web/controllers/api_controller.ex index 1532d61..351e57e 100644 --- a/lib/gc_index_relay_web/controllers/api_controller.ex +++ b/lib/gc_index_relay_web/controllers/api_controller.ex @@ -19,6 +19,11 @@ defmodule GcIndexRelayWeb.ApiController do path: "/api/events/filter", description: "Query events with a NIP-01 filter body" }, + %{ + method: "POST", + path: "/api/publications/search", + description: "Exact metadata search for kind-30040 publication indexes" + }, %{method: "GET", path: "/api/events/:id", description: "Get a single event by ID"}, %{method: "POST", path: "/api/events", description: "Publish a new event"}, %{method: "DELETE", path: "/api/events/:id", description: "Delete an event by ID"}, diff --git a/lib/gc_index_relay_web/controllers/page_html/home.html.heex b/lib/gc_index_relay_web/controllers/page_html/home.html.heex index 368818c..4d40d6d 100644 --- a/lib/gc_index_relay_web/controllers/page_html/home.html.heex +++ b/lib/gc_index_relay_web/controllers/page_html/home.html.heex @@ -65,6 +65,7 @@ {"GET", "/api", "List available endpoints"}, {"GET", "/api/events", "Cacheable query (since, until, limit)"}, {"POST", "/api/events/filter", "Filter query with JSON body"}, + {"POST", "/api/publications/search", "Publication metadata search (kind 30040)"}, {"GET", "/api/events/:id", "Fetch a single event by ID"}, {"POST", "/api/events", "Publish a new event"}, {"DELETE", "/api/events/:id", "Delete an event by ID"}, diff --git a/lib/gc_index_relay_web/controllers/publication_search_controller.ex b/lib/gc_index_relay_web/controllers/publication_search_controller.ex new file mode 100644 index 0000000..18830d1 --- /dev/null +++ b/lib/gc_index_relay_web/controllers/publication_search_controller.ex @@ -0,0 +1,57 @@ +defmodule GcIndexRelayWeb.PublicationSearchController do + use GcIndexRelayWeb, :controller + use PhoenixSwagger + + alias GcIndexRelay.Nostr.PublicationSearch + + action_fallback GcIndexRelayWeb.FallbackController + + swagger_path :search do + post("/api/publications/search") + summary("Search kind-30040 publication indexes by metadata") + + description(""" + Exact-match search over publication index metadata tags: `d`, `title`, `author`, and `source`. + Matching is case-insensitive and treats hyphens and spaces as equivalent. Partial substring + matches are not returned. + """) + + tag("Publications") + operation_id("search_publications") + response(200, "OK", Schema.ref(:PubEventList)) + response(400, "Bad Request") + end + + @doc """ + POST /api/publications/search — exact metadata search for kind-30040 publication indexes. + """ + def search(conn, params) do + with {:ok, query} <- fetch_query(params), + {:ok, limit} <- parse_limit(Map.get(params, "limit", 25)), + :ok <- validate_limit(limit), + {:ok, events} <- PublicationSearch.search(query, limit: limit) do + render(conn, :index, events: events) + end + end + + defp fetch_query(%{"q" => q}) when is_binary(q) do + trimmed = String.trim(q) + if trimmed == "", do: {:error, "Query q must not be empty."}, else: {:ok, trimmed} + end + + defp fetch_query(_), do: {:error, "Missing required field: q"} + + defp parse_limit(v) when is_integer(v), do: {:ok, v} + + defp parse_limit(v) when is_binary(v) do + case Integer.parse(v) do + {int, ""} -> {:ok, int} + _ -> {:error, "Invalid limit: must be an integer between 1 and 100"} + end + end + + defp parse_limit(_), do: {:error, "Invalid limit: must be an integer between 1 and 100"} + + defp validate_limit(limit) when is_integer(limit) and limit >= 1 and limit <= 100, do: :ok + defp validate_limit(_), do: {:error, "The limit must be between 1 and 100."} +end diff --git a/lib/gc_index_relay_web/controllers/publication_search_json.ex b/lib/gc_index_relay_web/controllers/publication_search_json.ex new file mode 100644 index 0000000..4cd1b9d --- /dev/null +++ b/lib/gc_index_relay_web/controllers/publication_search_json.ex @@ -0,0 +1,19 @@ +defmodule GcIndexRelayWeb.PublicationSearchJSON do + alias GcIndexRelay.Nostr.PubEvent + + 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 2017efc..711d753 100644 --- a/lib/gc_index_relay_web/router.ex +++ b/lib/gc_index_relay_web/router.ex @@ -28,6 +28,7 @@ defmodule GcIndexRelayWeb.Router do get "/", ApiController, :index get "/events", FilterController, :index post "/events/filter", FilterController, :query + post "/publications/search", PublicationSearchController, :search resources "/events", EventController, only: [:show, :create, :delete] end diff --git a/priv/static/swagger.json b/priv/static/swagger.json index 1e0c16a..c1f9270 100644 --- a/priv/static/swagger.json +++ b/priv/static/swagger.json @@ -6,6 +6,14 @@ }, "host": "localhost:4000", "definitions": { + "PubEventList": { + "description": "A list of Nostr events", + "items": { + "$ref": "#/definitions/PubEvent" + }, + "title": "PubEventList", + "type": "array" + }, "PubEvent": { "description": "A signed Nostr event", "example": { @@ -64,14 +72,6 @@ ], "title": "PubEvent", "type": "object" - }, - "PubEventList": { - "description": "A list of Nostr events", - "items": { - "$ref": "#/definitions/PubEvent" - }, - "title": "PubEventList", - "type": "array" } }, "schemes": [ @@ -225,13 +225,35 @@ "Events" ] } + }, + "/api/publications/search": { + "post": { + "description": "Exact-match search over publication index metadata tags: `d`, `title`, `author`, and `source`.\nMatching is case-insensitive and treats hyphens and spaces as equivalent. Partial substring\nmatches are not returned.\n", + "operationId": "search_publications", + "parameters": [], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/PubEventList" + } + }, + "400": { + "description": "Bad Request" + } + }, + "summary": "Search kind-30040 publication indexes by metadata", + "tags": [ + "Publications" + ] + } } }, "swagger": "2.0", - "produces": [ + "consumes": [ "application/json" ], - "consumes": [ + "produces": [ "application/json" ] } \ No newline at end of file diff --git a/test/gc_index_relay/nostr/publication_search_query_needles_test.exs b/test/gc_index_relay/nostr/publication_search_query_needles_test.exs new file mode 100644 index 0000000..14018cb --- /dev/null +++ b/test/gc_index_relay/nostr/publication_search_query_needles_test.exs @@ -0,0 +1,37 @@ +defmodule GcIndexRelay.Nostr.PublicationSearchQueryNeedlesTest do + use ExUnit.Case, async: true + + alias GcIndexRelay.Nostr.PublicationSearch + + @moduletag :unit + + test "query_needles returns empty list for blank input" do + assert PublicationSearch.query_needles("") == [] + assert PublicationSearch.query_needles(" ") == [] + end + + test "query_needles normalizes case, spaces, and hyphens" do + assert PublicationSearch.query_needles("Pride and Prejudice") == [ + "pride and prejudice", + "pride-and-prejudice" + ] + end + + test "query_needles strips surrounding quotes" do + assert PublicationSearch.query_needles(~s("Jane Eyre")) == ["jane eyre", "jane-eyre"] + assert PublicationSearch.query_needles("'Jane Eyre'") == ["jane eyre", "jane-eyre"] + end + + test "query_needles collapses repeated whitespace and hyphens" do + assert PublicationSearch.query_needles("pride and prejudice") == [ + "pride and prejudice", + "pride and prejudice", + "pride-and-prejudice" + ] + + assert PublicationSearch.query_needles("pg1342--pride--and--prejudice") == [ + "pg1342--pride--and--prejudice", + "pg1342-pride-and-prejudice" + ] + end +end diff --git a/test/gc_index_relay/nostr/publication_search_test.exs b/test/gc_index_relay/nostr/publication_search_test.exs new file mode 100644 index 0000000..26cf17e --- /dev/null +++ b/test/gc_index_relay/nostr/publication_search_test.exs @@ -0,0 +1,76 @@ +defmodule GcIndexRelay.Nostr.PublicationSearchTest do + use GcIndexRelay.DataCase + + import GcIndexRelay.NostrFixtures + + alias GcIndexRelay.Nostr + alias GcIndexRelay.Nostr.PublicationSearch + + @moduletag :integration + + defp insert_publication!(d, title, author, source, created_at \\ nil) do + attrs = %{ + kind: 30_040, + content: "", + tags: [ + ["d", d], + ["title", title], + ["author", author], + ["source", source] + ] + } + + attrs = if created_at, do: Map.put(attrs, :created_at, created_at), else: attrs + + event = valid_pub_event_fixture(attrs) + assert {:ok, _} = Nostr.create_event(event) + event + end + + test "search finds exact title match" do + insert_publication!( + "pg1342-pride-and-prejudice", + "Pride and Prejudice", + "Jane Austen", + "https://www.gutenberg.org/ebooks/1342", + 1_700_000_100 + ) + + insert_publication!( + "other-book", + "Other Book", + "Someone", + "https://example.com/1", + 1_700_000_200 + ) + + assert {:ok, results} = PublicationSearch.search("pride and prejudice", limit: 10) + assert length(results) == 1 + assert hd(results).kind == 30_040 + assert Enum.any?(hd(results).tags, fn ["d", v] -> v == "pg1342-pride-and-prejudice" end) + end + + test "search finds exact d-tag match" do + insert_publication!( + "pg1342-pride-and-prejudice", + "Pride and Prejudice", + "Jane Austen", + "https://www.gutenberg.org/ebooks/1342" + ) + + assert {:ok, results} = PublicationSearch.search("pg1342-pride-and-prejudice", limit: 10) + assert length(results) == 1 + end + + test "search rejects partial substring matches" do + insert_publication!( + "pg1342-pride-and-prejudice", + "Pride and Prejudice", + "Jane Austen", + "https://www.gutenberg.org/ebooks/1342" + ) + + assert {:ok, []} = PublicationSearch.search("pg1342", limit: 10) + assert {:ok, []} = PublicationSearch.search("pride-and", limit: 10) + end +end diff --git a/test/gc_index_relay_web/controllers/publication_search_controller_test.exs b/test/gc_index_relay_web/controllers/publication_search_controller_test.exs new file mode 100644 index 0000000..49b3a38 --- /dev/null +++ b/test/gc_index_relay_web/controllers/publication_search_controller_test.exs @@ -0,0 +1,78 @@ +defmodule GcIndexRelayWeb.PublicationSearchControllerTest do + use GcIndexRelayWeb.ConnCase + + import GcIndexRelay.NostrFixtures + + alias GcIndexRelay.Nostr + + @moduletag :integration + + setup %{conn: conn} do + conn = + conn + |> put_req_header("accept", "application/json") + |> put_req_header("content-type", "application/json") + + {:ok, conn: conn} + end + + defp insert_publication!(d, title, author) do + event = + valid_pub_event_fixture(%{ + kind: 30_040, + content: "", + tags: [ + ["d", d], + ["title", title], + ["author", author] + ] + }) + + assert {:ok, _} = Nostr.create_event(event) + event + end + + describe "POST /api/publications/search" do + test "returns matching kind-30040 events", %{conn: conn} do + pub_event = + insert_publication!("pg1342-pride-and-prejudice", "Pride and Prejudice", "Jane Austen") + + conn = + post(conn, ~p"/api/publications/search", %{ + "q" => "pride and prejudice", + "limit" => 10 + }) + + assert %{"data" => [event]} = json_response(conn, 200) + assert event["id"] == pub_event.id + assert event["kind"] == 30_040 + end + + test "returns empty list when nothing matches", %{conn: conn} do + insert_publication!("pg1342-pride-and-prejudice", "Pride and Prejudice", "Jane Austen") + + conn = post(conn, ~p"/api/publications/search", %{"q" => "pg1342", "limit" => 10}) + + assert %{"data" => []} = json_response(conn, 200) + end + + test "returns 400 when q is missing", %{conn: conn} do + conn = post(conn, ~p"/api/publications/search", %{"limit" => 10}) + + assert %{"errors" => %{"detail" => "Missing required field: q"}} = json_response(conn, 400) + end + + test "returns 400 when q is empty", %{conn: conn} do + conn = post(conn, ~p"/api/publications/search", %{"q" => " ", "limit" => 10}) + + assert %{"errors" => %{"detail" => "Query q must not be empty."}} = json_response(conn, 400) + end + + test "returns 400 when limit is out of range", %{conn: conn} do + conn = post(conn, ~p"/api/publications/search", %{"q" => "book", "limit" => 0}) + + assert %{"errors" => %{"detail" => "The limit must be between 1 and 100."}} = + json_response(conn, 400) + end + end +end