dev vouch dev on at. thats about it atvouch.dev
8
fork

Configure Feed

Select the types of activity you want to include in your feed.

appview: add support for pagination on the main xrpcs

Luna 649ae0ab 8391f46f

+341 -17
+36
appview/lib/atvouch/vouch.ex
··· 51 51 |> Atvouch.Repo.replica().all() 52 52 end 53 53 54 + def paginated_from_did(did, limit, offset) do 55 + import Ecto.Query 56 + 57 + from(v in __MODULE__, 58 + where: v.creator_did == ^did, 59 + order_by: [asc: v.at_uri], 60 + limit: ^limit, 61 + offset: ^offset 62 + ) 63 + |> Atvouch.Repo.replica().all() 64 + end 65 + 66 + def count_from_did(did) do 67 + import Ecto.Query 68 + from(v in __MODULE__, where: v.creator_did == ^did, select: count(v.at_uri)) 69 + |> Atvouch.Repo.replica().one() 70 + end 71 + 54 72 def all_targeting_did(did) do 55 73 import Ecto.Query 56 74 from(v in __MODULE__, where: v.target_did == ^did) 57 75 |> Atvouch.Repo.replica().all() 76 + end 77 + 78 + def paginated_targeting_did(did, limit, offset) do 79 + import Ecto.Query 80 + 81 + from(v in __MODULE__, 82 + where: v.target_did == ^did, 83 + order_by: [asc: v.at_uri], 84 + limit: ^limit, 85 + offset: ^offset 86 + ) 87 + |> Atvouch.Repo.replica().all() 88 + end 89 + 90 + def count_targeting_did(did) do 91 + import Ecto.Query 92 + from(v in __MODULE__, where: v.target_did == ^did, select: count(v.at_uri)) 93 + |> Atvouch.Repo.replica().one() 58 94 end 59 95 60 96 def count do
+47 -12
appview/lib/atvouch/xrpc_router.ex
··· 17 17 alias Atvouch.Lexicons.Graph.GetRemoteVouches 18 18 19 19 get "/dev.atvouch.graph.getCurrentUserVouches" do 20 - with {:ok, did} <- require_auth(conn) do 20 + conn = fetch_query_params(conn) 21 + params = conn.query_params 22 + 23 + with {:ok, limit} <- parse_limit(params), 24 + {:ok, offset} <- parse_cursor(params), 25 + {:ok, did} <- require_auth(conn) do 21 26 vouches = 22 - did 23 - |> Atvouch.Vouch.all_from_did() 27 + Atvouch.Vouch.paginated_from_did(did, limit, offset) 24 28 |> Enum.map(&to_vouch_view/1) 25 29 26 - output = %GetCurrentUserVouches.Output{vouches: vouches} 30 + total = Atvouch.Vouch.count_from_did(did) 31 + 32 + next_cursor = next_cursor(vouches, limit, offset) 33 + 34 + output = %GetCurrentUserVouches.Output{vouches: vouches, cursor: next_cursor, total: total} 27 35 28 36 conn 29 37 |> put_resp_content_type("application/json") 30 38 |> send_resp(200, Jason.encode!(output)) 39 + else 40 + {:error, message} -> 41 + conn 42 + |> put_resp_content_type("application/json") 43 + |> send_resp(400, Jason.encode!(%{error: "InvalidRequest", message: message})) 44 + 45 + %Plug.Conn{halted: true} = halted_conn -> 46 + halted_conn 31 47 end 32 48 end 33 49 34 50 get "/dev.atvouch.graph.getRemoteVouches" do 35 - with {:ok, did} <- require_auth(conn) do 51 + conn = fetch_query_params(conn) 52 + params = conn.query_params 53 + 54 + with {:ok, limit} <- parse_limit(params), 55 + {:ok, offset} <- parse_cursor(params), 56 + {:ok, did} <- require_auth(conn) do 36 57 vouches = 37 - did 38 - |> Atvouch.Vouch.all_targeting_did() 58 + Atvouch.Vouch.paginated_targeting_did(did, limit, offset) 39 59 |> Enum.map(&to_vouch_view/1) 40 60 41 - output = %GetRemoteVouches.Output{vouches: vouches} 61 + total = Atvouch.Vouch.count_targeting_did(did) 62 + 63 + next_cursor = next_cursor(vouches, limit, offset) 64 + 65 + output = %GetRemoteVouches.Output{vouches: vouches, cursor: next_cursor, total: total} 42 66 43 67 conn 44 68 |> put_resp_content_type("application/json") 45 69 |> send_resp(200, Jason.encode!(output)) 70 + else 71 + {:error, message} -> 72 + conn 73 + |> put_resp_content_type("application/json") 74 + |> send_resp(400, Jason.encode!(%{error: "InvalidRequest", message: message})) 75 + 76 + %Plug.Conn{halted: true} = halted_conn -> 77 + halted_conn 46 78 end 47 79 end 48 80 ··· 66 98 67 99 total = Atvouch.Vouch.count() 68 100 69 - next_cursor = 70 - if length(vouches) == limit do 71 - Integer.to_string(offset + limit) 72 - end 101 + next_cursor = next_cursor(vouches, limit, offset) 73 102 74 103 output = %GetEntireGraph.Output{vouches: vouches, cursor: next_cursor, total: total} 75 104 ··· 122 151 end 123 152 124 153 defp parse_cursor(_), do: {:ok, 0} 154 + 155 + defp next_cursor(vouches, limit, offset) do 156 + if length(vouches) == limit do 157 + Integer.to_string(offset + limit) 158 + end 159 + end 125 160 126 161 defp to_vouch_view(vouch) do 127 162 %Defs.VouchView{
+226 -1
appview/test/atvouch/xrpc_vouches_test.exs
··· 111 111 112 112 assert conn.status == 401 113 113 end 114 + 115 + test "returns pagination data and behaves with limit" do 116 + conn = 117 + conn(:get, "/xrpc/dev.atvouch.graph.getCurrentUserVouches?limit=1") 118 + |> put_req_header("authorization", "Bearer valid-token:did:plc:alice") 119 + |> Atvouch.Router.call(@opts) 120 + 121 + assert conn.status == 200 122 + body = Jason.decode!(conn.resp_body) 123 + assert length(body["vouches"]) == 1 124 + assert body["cursor"] 125 + assert body["total"] == 2 126 + end 127 + 128 + test "cursor returns next page" do 129 + # Add a third vouch for Alice so we can paginate with limit=2 130 + {:ok, _} = Atvouch.Identity.create(%{did: "did:plc:dave", handle: "dave.test"}) 131 + 132 + {:ok, _} = 133 + Atvouch.Vouch.create(%{ 134 + at_uri: "at://did:plc:alice/dev.atvouch.graph.vouch/did:plc:dave", 135 + creator_did: "did:plc:alice", 136 + target_did: "did:plc:dave", 137 + original_created_at: "2026-03-04T00:00:00Z", 138 + remote_created_at: "2026-03-04T00:00:00Z", 139 + at_cid: "bafyabc111", 140 + live: true 141 + }) 142 + 143 + # Alice now has 3 vouches, fetch with limit=2 144 + conn1 = 145 + conn(:get, "/xrpc/dev.atvouch.graph.getCurrentUserVouches?limit=2") 146 + |> put_req_header("authorization", "Bearer valid-token:did:plc:alice") 147 + |> Atvouch.Router.call(@opts) 148 + 149 + body1 = Jason.decode!(conn1.resp_body) 150 + assert length(body1["vouches"]) == 2 151 + cursor = body1["cursor"] 152 + assert cursor 153 + 154 + conn2 = 155 + conn(:get, "/xrpc/dev.atvouch.graph.getCurrentUserVouches?limit=2&cursor=#{cursor}") 156 + |> put_req_header("authorization", "Bearer valid-token:did:plc:alice") 157 + |> Atvouch.Router.call(@opts) 158 + 159 + body2 = Jason.decode!(conn2.resp_body) 160 + assert length(body2["vouches"]) == 1 161 + refute body2["cursor"] 162 + 163 + uris1 = Enum.map(body1["vouches"], & &1["uri"]) 164 + uris2 = Enum.map(body2["vouches"], & &1["uri"]) 165 + assert MapSet.disjoint?(MapSet.new(uris1), MapSet.new(uris2)) 166 + end 167 + 168 + test "no cursor when all results fit in one page" do 169 + conn = 170 + conn(:get, "/xrpc/dev.atvouch.graph.getCurrentUserVouches") 171 + |> put_req_header("authorization", "Bearer valid-token:did:plc:alice") 172 + |> Atvouch.Router.call(@opts) 173 + 174 + assert conn.status == 200 175 + body = Jason.decode!(conn.resp_body) 176 + refute body["cursor"] 177 + end 178 + 179 + test "returns 400 with invalid cursor" do 180 + conn = 181 + conn(:get, "/xrpc/dev.atvouch.graph.getCurrentUserVouches?cursor=notanumber") 182 + |> put_req_header("authorization", "Bearer valid-token:did:plc:alice") 183 + |> Atvouch.Router.call(@opts) 184 + 185 + assert conn.status == 400 186 + end 187 + 188 + test "returns 400 with limit over 100" do 189 + conn = 190 + conn(:get, "/xrpc/dev.atvouch.graph.getCurrentUserVouches?limit=101") 191 + |> put_req_header("authorization", "Bearer valid-token:did:plc:alice") 192 + |> Atvouch.Router.call(@opts) 193 + 194 + assert conn.status == 400 195 + end 196 + 197 + test "returns 400 with limit under 1" do 198 + conn = 199 + conn(:get, "/xrpc/dev.atvouch.graph.getCurrentUserVouches?limit=0") 200 + |> put_req_header("authorization", "Bearer valid-token:did:plc:alice") 201 + |> Atvouch.Router.call(@opts) 202 + 203 + assert conn.status == 400 204 + end 114 205 end 115 206 116 207 describe "GET /xrpc/dev.atvouch.graph.getRemoteVouches" do ··· 180 271 181 272 assert conn.status == 401 182 273 end 274 + 275 + test "returns total count" do 276 + # Bob has 2 vouches targeting him: from Alice (bob) and we check with bob who has 1 277 + conn = 278 + conn(:get, "/xrpc/dev.atvouch.graph.getRemoteVouches") 279 + |> put_req_header("authorization", "Bearer valid-token:did:plc:bob") 280 + |> Atvouch.Router.call(@opts) 281 + 282 + assert conn.status == 200 283 + body = Jason.decode!(conn.resp_body) 284 + assert body["total"] == 1 285 + end 286 + 287 + test "paginates with limit" do 288 + # Alice has 1 remote vouch (from Bob), use bob who also has 1 289 + # To test pagination properly, we need a user with >1 remote vouches 290 + # Carol vouches for Bob too so bob has 2 targeting him 291 + {:ok, _} = Atvouch.Identity.create(%{did: "did:plc:dave", handle: "dave.test"}) 292 + 293 + {:ok, _} = 294 + Atvouch.Vouch.create(%{ 295 + at_uri: "at://did:plc:carol/dev.atvouch.graph.vouch/did:plc:bob", 296 + creator_did: "did:plc:carol", 297 + target_did: "did:plc:bob", 298 + original_created_at: "2026-03-04T00:00:00Z", 299 + remote_created_at: "2026-03-04T00:00:00Z", 300 + at_cid: "bafyabc999", 301 + live: true 302 + }) 303 + 304 + conn = 305 + conn(:get, "/xrpc/dev.atvouch.graph.getRemoteVouches?limit=1") 306 + |> put_req_header("authorization", "Bearer valid-token:did:plc:bob") 307 + |> Atvouch.Router.call(@opts) 308 + 309 + assert conn.status == 200 310 + body = Jason.decode!(conn.resp_body) 311 + assert length(body["vouches"]) == 1 312 + assert body["cursor"] 313 + assert body["total"] == 2 314 + end 315 + 316 + test "cursor returns next page" do 317 + # Add 2 more remote vouches for Alice (she already has 1 from Bob) 318 + {:ok, _} = 319 + Atvouch.Vouch.create(%{ 320 + at_uri: "at://did:plc:carol/dev.atvouch.graph.vouch/did:plc:alice", 321 + creator_did: "did:plc:carol", 322 + target_did: "did:plc:alice", 323 + original_created_at: "2026-03-05T00:00:00Z", 324 + remote_created_at: "2026-03-05T00:00:00Z", 325 + at_cid: "bafyabc888", 326 + live: true 327 + }) 328 + 329 + {:ok, _} = Atvouch.Identity.create(%{did: "did:plc:eve", handle: "eve.test"}) 330 + 331 + {:ok, _} = 332 + Atvouch.Vouch.create(%{ 333 + at_uri: "at://did:plc:eve/dev.atvouch.graph.vouch/did:plc:alice", 334 + creator_did: "did:plc:eve", 335 + target_did: "did:plc:alice", 336 + original_created_at: "2026-03-06T00:00:00Z", 337 + remote_created_at: "2026-03-06T00:00:00Z", 338 + at_cid: "bafyabc777", 339 + live: true 340 + }) 341 + 342 + # Alice now has 3 remote vouches, fetch with limit=2 343 + conn1 = 344 + conn(:get, "/xrpc/dev.atvouch.graph.getRemoteVouches?limit=2") 345 + |> put_req_header("authorization", "Bearer valid-token:did:plc:alice") 346 + |> Atvouch.Router.call(@opts) 347 + 348 + body1 = Jason.decode!(conn1.resp_body) 349 + assert length(body1["vouches"]) == 2 350 + cursor = body1["cursor"] 351 + assert cursor 352 + 353 + conn2 = 354 + conn(:get, "/xrpc/dev.atvouch.graph.getRemoteVouches?limit=2&cursor=#{cursor}") 355 + |> put_req_header("authorization", "Bearer valid-token:did:plc:alice") 356 + |> Atvouch.Router.call(@opts) 357 + 358 + body2 = Jason.decode!(conn2.resp_body) 359 + assert length(body2["vouches"]) == 1 360 + refute body2["cursor"] 361 + 362 + uris1 = Enum.map(body1["vouches"], & &1["uri"]) 363 + uris2 = Enum.map(body2["vouches"], & &1["uri"]) 364 + assert MapSet.disjoint?(MapSet.new(uris1), MapSet.new(uris2)) 365 + end 366 + 367 + test "no cursor when all results fit in one page" do 368 + conn = 369 + conn(:get, "/xrpc/dev.atvouch.graph.getRemoteVouches") 370 + |> put_req_header("authorization", "Bearer valid-token:did:plc:alice") 371 + |> Atvouch.Router.call(@opts) 372 + 373 + assert conn.status == 200 374 + body = Jason.decode!(conn.resp_body) 375 + refute body["cursor"] 376 + end 377 + 378 + test "returns 400 with invalid cursor" do 379 + conn = 380 + conn(:get, "/xrpc/dev.atvouch.graph.getRemoteVouches?cursor=notanumber") 381 + |> put_req_header("authorization", "Bearer valid-token:did:plc:alice") 382 + |> Atvouch.Router.call(@opts) 383 + 384 + assert conn.status == 400 385 + end 386 + 387 + test "returns 400 with limit over 100" do 388 + conn = 389 + conn(:get, "/xrpc/dev.atvouch.graph.getRemoteVouches?limit=101") 390 + |> put_req_header("authorization", "Bearer valid-token:did:plc:alice") 391 + |> Atvouch.Router.call(@opts) 392 + 393 + assert conn.status == 400 394 + end 395 + 396 + test "returns 400 with limit under 1" do 397 + conn = 398 + conn(:get, "/xrpc/dev.atvouch.graph.getRemoteVouches?limit=0") 399 + |> put_req_header("authorization", "Bearer valid-token:did:plc:alice") 400 + |> Atvouch.Router.call(@opts) 401 + 402 + assert conn.status == 400 403 + end 183 404 end 184 405 185 406 describe "GET /xrpc/dev.atvouch.graph.getEntireGraph" do ··· 223 444 224 445 assert conn.status == 200 225 446 assert get_resp_header(conn, "access-control-allow-origin") == ["*"] 226 - assert "GET" in String.split(hd(get_resp_header(conn, "access-control-allow-methods")), ", ") 447 + 448 + assert "GET" in String.split( 449 + hd(get_resp_header(conn, "access-control-allow-methods")), 450 + ", " 451 + ) 227 452 end 228 453 229 454 test "responds to CORS preflight OPTIONS request" do
+16 -2
lexicons/dev/atvouch/graph/getCurrentUserVouches.json
··· 4 4 "defs": { 5 5 "main": { 6 6 "type": "query", 7 - "description": "Returns all vouches created by the currently authenticated user.", 7 + "description": "Returns vouches created by the currently authenticated user, with cursor-based pagination.", 8 + "parameters": { 9 + "type": "params", 10 + "properties": { 11 + "limit": { 12 + "type": "integer", 13 + "minimum": 1, 14 + "maximum": 100, 15 + "default": 50 16 + }, 17 + "cursor": { "type": "string" } 18 + } 19 + }, 8 20 "output": { 9 21 "encoding": "application/json", 10 22 "schema": { 11 23 "type": "object", 12 - "required": ["vouches"], 24 + "required": ["vouches", "total"], 13 25 "properties": { 26 + "cursor": { "type": "string" }, 27 + "total": { "type": "integer" }, 14 28 "vouches": { 15 29 "type": "array", 16 30 "items": {
+16 -2
lexicons/dev/atvouch/graph/getRemoteVouches.json
··· 4 4 "defs": { 5 5 "main": { 6 6 "type": "query", 7 - "description": "Returns all vouches targeting the currently authenticated user.", 7 + "description": "Returns vouches targeting the currently authenticated user, with cursor-based pagination.", 8 + "parameters": { 9 + "type": "params", 10 + "properties": { 11 + "limit": { 12 + "type": "integer", 13 + "minimum": 1, 14 + "maximum": 100, 15 + "default": 50 16 + }, 17 + "cursor": { "type": "string" } 18 + } 19 + }, 8 20 "output": { 9 21 "encoding": "application/json", 10 22 "schema": { 11 23 "type": "object", 12 - "required": ["vouches"], 24 + "required": ["vouches", "total"], 13 25 "properties": { 26 + "cursor": { "type": "string" }, 27 + "total": { "type": "integer" }, 14 28 "vouches": { 15 29 "type": "array", 16 30 "items": {