Browse Source

add search

test/local-setup
Silberengel 7 days ago
parent
commit
da45891548
  1. 150
      lib/gc_index_relay/nostr/publication_search.ex
  2. 5
      lib/gc_index_relay_web/controllers/api_controller.ex
  3. 1
      lib/gc_index_relay_web/controllers/page_html/home.html.heex
  4. 57
      lib/gc_index_relay_web/controllers/publication_search_controller.ex
  5. 19
      lib/gc_index_relay_web/controllers/publication_search_json.ex
  6. 1
      lib/gc_index_relay_web/router.ex
  7. 42
      priv/static/swagger.json
  8. 37
      test/gc_index_relay/nostr/publication_search_query_needles_test.exs
  9. 76
      test/gc_index_relay/nostr/publication_search_test.exs
  10. 78
      test/gc_index_relay_web/controllers/publication_search_controller_test.exs

150
lib/gc_index_relay/nostr/publication_search.ex

@ -0,0 +1,150 @@ @@ -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

5
lib/gc_index_relay_web/controllers/api_controller.ex

@ -19,6 +19,11 @@ defmodule GcIndexRelayWeb.ApiController do @@ -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"},

1
lib/gc_index_relay_web/controllers/page_html/home.html.heex

@ -65,6 +65,7 @@ @@ -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"},

57
lib/gc_index_relay_web/controllers/publication_search_controller.ex

@ -0,0 +1,57 @@ @@ -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

19
lib/gc_index_relay_web/controllers/publication_search_json.ex

@ -0,0 +1,19 @@ @@ -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

1
lib/gc_index_relay_web/router.ex

@ -28,6 +28,7 @@ defmodule GcIndexRelayWeb.Router do @@ -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

42
priv/static/swagger.json

@ -6,6 +6,14 @@ @@ -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 @@ @@ -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 @@ @@ -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"
]
}

37
test/gc_index_relay/nostr/publication_search_query_needles_test.exs

@ -0,0 +1,37 @@ @@ -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

76
test/gc_index_relay/nostr/publication_search_test.exs

@ -0,0 +1,76 @@ @@ -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

78
test/gc_index_relay_web/controllers/publication_search_controller_test.exs

@ -0,0 +1,78 @@ @@ -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
Loading…
Cancel
Save