From 7a605f41f754c147a9b03fe2a3dfdcaa42b8bccd Mon Sep 17 00:00:00 2001 From: Luna Date: Tue, 10 Mar 2026 01:06:54 -0300 Subject: [PATCH] add getEntireGraph --- appview/config/test.exs | 9 +- .../lib/atvouch/lexicons/get_entire_graph.ex | 11 +++ appview/lib/atvouch/vouch.ex | 7 ++ appview/lib/atvouch/xrpc_router.ex | 47 +++++++++++ appview/test/atvouch/xrpc_vouches_test.exs | 84 +++++++++++++++++++ .../dev/atvouch/graph/getEntireGraph.json | 39 +++++++++ 6 files changed, 192 insertions(+), 5 deletions(-) create mode 100644 appview/lib/atvouch/lexicons/get_entire_graph.ex create mode 100644 lexicons/dev/atvouch/graph/getEntireGraph.json diff --git a/appview/config/test.exs b/appview/config/test.exs index 7d21ded..af65af5 100644 --- a/appview/config/test.exs +++ b/appview/config/test.exs @@ -1,15 +1,14 @@ import Config config :atvouch, - port: 4002, + port: 4057, atvouch_did: "did:plc:test", - atvouch_endpoint: "https://localhost:4002" + atvouch_endpoint: "https://localhost:123" -config :atvouch, Atvouch.Repo, - pool: Ecto.Adapters.SQL.Sandbox +config :atvouch, Atvouch.Repo, pool: Ecto.Adapters.SQL.Sandbox config :atvouch, :tap, - uri: "ws://localhost:0/channel", + uri: "ws://localhost:123/channel", password: "123" config :logger, diff --git a/appview/lib/atvouch/lexicons/get_entire_graph.ex b/appview/lib/atvouch/lexicons/get_entire_graph.ex new file mode 100644 index 0000000..f41807e --- /dev/null +++ b/appview/lib/atvouch/lexicons/get_entire_graph.ex @@ -0,0 +1,11 @@ +defmodule Atvouch.Lexicons.Graph.GetEntireGraph do + use Atex.Lexicon + + deflexicon( + Jason.decode!( + File.read!( + Path.join([File.cwd!(), "..", "lexicons", "dev", "atvouch", "graph", "getEntireGraph.json"]) + ) + ) + ) +end diff --git a/appview/lib/atvouch/vouch.ex b/appview/lib/atvouch/vouch.ex index 995b890..bbfedfb 100644 --- a/appview/lib/atvouch/vouch.ex +++ b/appview/lib/atvouch/vouch.ex @@ -56,4 +56,11 @@ defmodule Atvouch.Vouch do from(v in __MODULE__, where: v.target_did == ^did) |> Atvouch.Repo.all() end + + def paginated(limit, offset) do + import Ecto.Query + + from(v in __MODULE__, order_by: [asc: v.at_uri], limit: ^limit, offset: ^offset) + |> Atvouch.Repo.all() + end end diff --git a/appview/lib/atvouch/xrpc_router.ex b/appview/lib/atvouch/xrpc_router.ex index 9e54a0d..8a81e3b 100644 --- a/appview/lib/atvouch/xrpc_router.ex +++ b/appview/lib/atvouch/xrpc_router.ex @@ -13,6 +13,7 @@ defmodule Atvouch.XrpcRouter do alias Atvouch.Lexicons.Graph.Defs alias Atvouch.Lexicons.Graph.GetCurrentUserVouches + alias Atvouch.Lexicons.Graph.GetEntireGraph alias Atvouch.Lexicons.Graph.GetRemoteVouches get "/dev.atvouch.graph.getCurrentUserVouches" do @@ -45,6 +46,34 @@ defmodule Atvouch.XrpcRouter do end end + get "/dev.atvouch.graph.getEntireGraph" do + conn = fetch_query_params(conn) + params = conn.query_params + + with {:ok, limit} <- parse_limit(params), + {:ok, offset} <- parse_cursor(params) do + vouches = + Atvouch.Vouch.paginated(limit, offset) + |> Enum.map(&to_vouch_view/1) + + next_cursor = + if length(vouches) == limit do + Integer.to_string(offset + limit) + end + + output = %GetEntireGraph.Output{vouches: vouches, cursor: next_cursor} + + conn + |> put_resp_content_type("application/json") + |> send_resp(200, Jason.encode!(output)) + else + {:error, message} -> + conn + |> put_resp_content_type("application/json") + |> send_resp(400, Jason.encode!(%{error: "InvalidRequest", message: message})) + end + end + match _ do conn |> put_resp_content_type("application/json") @@ -64,6 +93,24 @@ defmodule Atvouch.XrpcRouter do end end + defp parse_limit(%{"limit" => limit_str}) do + case Integer.parse(limit_str) do + {limit, ""} when limit >= 1 and limit <= 100 -> {:ok, limit} + _ -> {:error, "limit must be an integer between 1 and 100"} + end + end + + defp parse_limit(_), do: {:ok, 50} + + defp parse_cursor(%{"cursor" => cursor_str}) do + case Integer.parse(cursor_str) do + {offset, ""} when offset >= 0 -> {:ok, offset} + _ -> {:error, "cursor must be a valid non-negative integer"} + end + end + + defp parse_cursor(_), do: {:ok, 0} + defp to_vouch_view(vouch) do %Defs.VouchView{ uri: vouch.at_uri, diff --git a/appview/test/atvouch/xrpc_vouches_test.exs b/appview/test/atvouch/xrpc_vouches_test.exs index 3076f10..f4fb667 100644 --- a/appview/test/atvouch/xrpc_vouches_test.exs +++ b/appview/test/atvouch/xrpc_vouches_test.exs @@ -181,4 +181,88 @@ defmodule Atvouch.XrpcVouchesTest do assert conn.status == 401 end end + + describe "GET /xrpc/dev.atvouch.graph.getEntireGraph" do + test "returns all vouches with default limit" do + conn = + conn(:get, "/xrpc/dev.atvouch.graph.getEntireGraph") + |> Atvouch.Router.call(@opts) + + assert conn.status == 200 + body = Jason.decode!(conn.resp_body) + assert length(body["vouches"]) == 3 + refute body["cursor"] + end + + test "paginates with limit" do + conn = + conn(:get, "/xrpc/dev.atvouch.graph.getEntireGraph?limit=2") + |> Atvouch.Router.call(@opts) + + assert conn.status == 200 + body = Jason.decode!(conn.resp_body) + assert length(body["vouches"]) == 2 + assert body["cursor"] + end + + test "cursor returns next page" do + # First page + conn1 = + conn(:get, "/xrpc/dev.atvouch.graph.getEntireGraph?limit=2") + |> Atvouch.Router.call(@opts) + + body1 = Jason.decode!(conn1.resp_body) + assert length(body1["vouches"]) == 2 + cursor = body1["cursor"] + assert cursor + + # Second page + conn2 = + conn(:get, "/xrpc/dev.atvouch.graph.getEntireGraph?limit=2&cursor=#{cursor}") + |> Atvouch.Router.call(@opts) + + body2 = Jason.decode!(conn2.resp_body) + assert length(body2["vouches"]) == 1 + refute body2["cursor"] + + # No overlap between pages + uris1 = Enum.map(body1["vouches"], & &1["uri"]) + uris2 = Enum.map(body2["vouches"], & &1["uri"]) + assert MapSet.disjoint?(MapSet.new(uris1), MapSet.new(uris2)) + end + + test "works without authentication" do + conn = + conn(:get, "/xrpc/dev.atvouch.graph.getEntireGraph") + |> Atvouch.Router.call(@opts) + + assert conn.status == 200 + body = Jason.decode!(conn.resp_body) + assert length(body["vouches"]) == 3 + end + + test "returns 400 with invalid cursor" do + conn = + conn(:get, "/xrpc/dev.atvouch.graph.getEntireGraph?cursor=notanumber") + |> Atvouch.Router.call(@opts) + + assert conn.status == 400 + end + + test "returns 400 with limit over 100" do + conn = + conn(:get, "/xrpc/dev.atvouch.graph.getEntireGraph?limit=101") + |> Atvouch.Router.call(@opts) + + assert conn.status == 400 + end + + test "returns 400 with limit under 1" do + conn = + conn(:get, "/xrpc/dev.atvouch.graph.getEntireGraph?limit=0") + |> Atvouch.Router.call(@opts) + + assert conn.status == 400 + end + end end diff --git a/lexicons/dev/atvouch/graph/getEntireGraph.json b/lexicons/dev/atvouch/graph/getEntireGraph.json new file mode 100644 index 0000000..e80b289 --- /dev/null +++ b/lexicons/dev/atvouch/graph/getEntireGraph.json @@ -0,0 +1,39 @@ +{ + "lexicon": 1, + "id": "dev.atvouch.graph.getEntireGraph", + "defs": { + "main": { + "type": "query", + "description": "Returns every vouch in the graph with cursor-based pagination.", + "parameters": { + "type": "params", + "properties": { + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 50 + }, + "cursor": { "type": "string" } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["vouches"], + "properties": { + "cursor": { "type": "string" }, + "vouches": { + "type": "array", + "items": { + "type": "ref", + "ref": "dev.atvouch.graph.defs#vouchView" + } + } + } + } + } + } + } +} -- 2.51.2