Skip to content

Commit 8669daa

Browse files
authored
feat(projecthub-rest-api): add list projects pagination (#302)
## 📝 Description Adds support for pagination in the `GET v1alpha/projects` endpoint. The existing response body format remains unchanged; pagination details are provided via response headers. To see more, check [this task](renderedtext/tasks#7953). ## ✅ Checklist - [x] I have tested this change - [x] ~This change requires documentation update~ - N/A, no existing documentation for the `v1alpha/projects` endpoint
1 parent fd9b893 commit 8669daa

File tree

2 files changed

+246
-13
lines changed

2 files changed

+246
-13
lines changed

projecthub-rest-api/lib/projecthub/http_api.ex

Lines changed: 62 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
defmodule Projecthub.HttpApi do
2-
alias Projecthub.{Auth, Utils, Organization}
2+
alias Projecthub.{Auth, Organization, Utils}
33

44
require Logger
55

@@ -39,15 +39,20 @@ defmodule Projecthub.HttpApi do
3939
#
4040

4141
get "/api/#{@version}/projects" do
42-
org_id = conn.assigns.org_id
42+
case list_projects(conn) do
43+
{:ok, {projects, page, page_size, total, has_more}} ->
44+
conn
45+
|> put_resp_header("x-page", Integer.to_string(page))
46+
|> put_resp_header("x-page-size", Integer.to_string(page_size))
47+
|> put_resp_header("x-total-count", Integer.to_string(total))
48+
|> put_resp_header("x-has-more", to_string(has_more))
49+
|> send_resp(200, Poison.encode!(projects))
4350

44-
projects_rsp = list_projects(conn)
45-
restricted = Organization.restricted?(org_id)
51+
{:error, :not_found} ->
52+
send_resp(conn, 404, Poison.encode!(%{message: "Not found"}))
4653

47-
case projects_rsp do
48-
{:ok, projects} -> send_resp(conn, 200, encode(projects, restricted))
49-
{:error, :not_found} -> send_resp(conn, 404, Poison.encode!(%{message: "Not found"}))
50-
{:error, message} -> send_resp(conn, 400, Poison.encode!(%{message: message}))
54+
{:error, message} ->
55+
send_resp(conn, 400, Poison.encode!(%{message: message}))
5156
end
5257
end
5358

@@ -686,13 +691,26 @@ defmodule Projecthub.HttpApi do
686691
end
687692

688693
defp list_projects(conn) do
694+
org_id = conn.assigns.org_id
695+
restricted = Organization.restricted?(org_id)
696+
697+
with {:ok, page} <- parse_int(conn.params, "page", 1, 1_000_000, 1),
698+
{:ok, page_size} <- parse_int(conn.params, "page_size", 1, 500, 500) do
699+
do_list_projects(conn, org_id, restricted, page, page_size)
700+
else
701+
{:error, reason} ->
702+
{:error, reason}
703+
end
704+
end
705+
706+
defp do_list_projects(conn, org_id, restricted, page, page_size) do
689707
req =
690708
InternalApi.Projecthub.ListRequest.new(
691709
metadata: Utils.construct_req_meta(conn),
692710
pagination:
693711
InternalApi.Projecthub.PaginationRequest.new(
694-
page: 0,
695-
page_size: 500
712+
page: page,
713+
page_size: page_size
696714
)
697715
)
698716

@@ -702,9 +720,40 @@ defmodule Projecthub.HttpApi do
702720
{:ok, res} = InternalApi.Projecthub.ProjectService.Stub.list(channel, req, timeout: 30_000)
703721

704722
case InternalApi.Projecthub.ResponseMeta.Code.key(res.metadata.status.code) do
705-
:OK -> {:ok, Auth.filter_projects(res.projects, conn.assigns.org_id, conn.assigns.user_id)}
706-
:NOT_FOUND -> {:error, :not_found}
707-
_ -> {:error, "Bad Request"}
723+
:OK ->
724+
projects =
725+
res.projects
726+
|> Auth.filter_projects(org_id, conn.assigns.user_id)
727+
|> Enum.map(&encode_project(&1, restricted))
728+
|> Enum.map(&Map.merge(&1, %{"apiVersion" => @version, "kind" => "Project"}))
729+
730+
total = Map.get(res.pagination || %{}, :total_entries, 0)
731+
has_more = (page - 1) * page_size + length(projects) < total
732+
{:ok, {projects, page, page_size, total, has_more}}
733+
734+
:NOT_FOUND ->
735+
{:error, :not_found}
736+
737+
_ ->
738+
{:error, "Bad Request"}
739+
end
740+
end
741+
742+
defp parse_int(params, key, min, max, default) do
743+
case Map.get(params, key) do
744+
nil ->
745+
{:ok, default}
746+
747+
str when is_binary(str) ->
748+
case Integer.parse(str) do
749+
{n, ""} when n >= min and n <= max -> {:ok, n}
750+
{n, ""} when n < min -> {:error, "#{key} must be at least #{min}"}
751+
{n, ""} when n > max -> {:error, "#{key} must be at most #{max}"}
752+
_ -> {:error, "#{key} must be a number"}
753+
end
754+
755+
_ ->
756+
{:error, "#{key} must be a number"}
708757
end
709758
end
710759
end

projecthub-rest-api/test/projecthub/http_api_test.exs

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,190 @@ defmodule Projecthub.HttpApi.Test do
283283
end
284284
end
285285

286+
describe "GET /api/<version>/projects pagination" do
287+
setup do
288+
# Setup three projects to test pagination
289+
p1_id = uuid()
290+
p2_id = uuid()
291+
p3_id = uuid()
292+
p1 = create("project1", p1_id)
293+
p2 = create("project2", p2_id)
294+
p3 = create("project3", p3_id)
295+
296+
FunRegistry.set!(FakeServices.RbacService, :list_accessible_projects, fn _, _ ->
297+
InternalApi.RBAC.ListAccessibleProjectsResponse.new(project_ids: [p1_id, p2_id, p3_id])
298+
end)
299+
300+
FunRegistry.set!(FakeServices.ProjectService, :list, fn req, _ ->
301+
alias InternalApi.Projecthub, as: PH
302+
# Simulate pagination
303+
page = req.pagination.page
304+
page_size = req.pagination.page_size
305+
all_projects = [p1, p2, p3]
306+
projects = Enum.slice(all_projects, (page - 1) * page_size, page_size)
307+
308+
PH.ListResponse.new(
309+
metadata:
310+
PH.ResponseMeta.new(
311+
status: PH.ResponseMeta.Status.new(code: PH.ResponseMeta.Code.value(:OK))
312+
),
313+
projects: projects,
314+
pagination:
315+
PH.PaginationResponse.new(
316+
page_number: page,
317+
page_size: page_size,
318+
total_entries: length(all_projects),
319+
total_pages: div(length(all_projects) + page_size - 1, page_size)
320+
)
321+
)
322+
end)
323+
324+
:ok
325+
end
326+
327+
test "returns correct pagination headers for /projects" do
328+
{:ok, response} =
329+
HTTPoison.get(
330+
"http://localhost:#{@port}/api/#{@version}/projects?page=1&page_size=2",
331+
@headers
332+
)
333+
334+
assert response.status_code == 200
335+
assert response.headers |> Enum.any?(fn {k, v} -> k == "x-page" and v == "1" end)
336+
assert response.headers |> Enum.any?(fn {k, v} -> k == "x-page-size" and v == "2" end)
337+
assert response.headers |> Enum.any?(fn {k, v} -> k == "x-total-count" and v == "3" end)
338+
assert response.headers |> Enum.any?(fn {k, v} -> k == "x-has-more" and v == "true" end)
339+
projects = Poison.decode!(response.body)
340+
assert length(projects) == 2
341+
end
342+
343+
test "returns correct pagination headers for /projects when there are no more projects" do
344+
{:ok, response} =
345+
HTTPoison.get(
346+
"http://localhost:#{@port}/api/#{@version}/projects?page=2&page_size=2",
347+
@headers
348+
)
349+
350+
assert response.status_code == 200
351+
assert response.headers |> Enum.any?(fn {k, v} -> k == "x-page" and v == "2" end)
352+
assert response.headers |> Enum.any?(fn {k, v} -> k == "x-page-size" and v == "2" end)
353+
assert response.headers |> Enum.any?(fn {k, v} -> k == "x-total-count" and v == "3" end)
354+
assert response.headers |> Enum.any?(fn {k, v} -> k == "x-has-more" and v == "false" end)
355+
projects = Poison.decode!(response.body)
356+
assert length(projects) == 1
357+
end
358+
359+
test "returns 404 on out-of-range page" do
360+
FunRegistry.set!(FakeServices.ProjectService, :list, fn _, _ ->
361+
alias InternalApi.Projecthub, as: PH
362+
363+
PH.ListResponse.new(
364+
metadata:
365+
PH.ResponseMeta.new(
366+
status: PH.ResponseMeta.Status.new(code: PH.ResponseMeta.Code.value(:NOT_FOUND))
367+
),
368+
projects: []
369+
)
370+
end)
371+
372+
{:ok, response} =
373+
HTTPoison.get(
374+
"http://localhost:#{@port}/api/#{@version}/projects?page=10&page_size=2",
375+
@headers
376+
)
377+
378+
assert response.status_code == 404
379+
end
380+
381+
test "returns 400 on bad request" do
382+
FunRegistry.set!(FakeServices.ProjectService, :list, fn _, _ ->
383+
alias InternalApi.Projecthub, as: PH
384+
385+
PH.ListResponse.new(
386+
metadata:
387+
PH.ResponseMeta.new(
388+
status:
389+
PH.ResponseMeta.Status.new(code: PH.ResponseMeta.Code.value(:FAILED_PRECONDITION))
390+
),
391+
projects: [],
392+
pagination:
393+
PH.PaginationResponse.new(
394+
total_count: 0,
395+
page_number: 0,
396+
page_size: 0,
397+
total_pages: 0
398+
)
399+
)
400+
end)
401+
402+
{:ok, response} =
403+
HTTPoison.get(
404+
"http://localhost:#{@port}/api/#{@version}/projects?page=foo&page_size=bar",
405+
@headers
406+
)
407+
408+
assert response.status_code == 400
409+
end
410+
411+
test "returns 400 on negative page" do
412+
{:ok, response} =
413+
HTTPoison.get(
414+
"http://localhost:#{@port}/api/#{@version}/projects?page=-1&page_size=2",
415+
@headers
416+
)
417+
418+
assert response.status_code == 400
419+
assert Poison.decode!(response.body)["message"] =~ "page must be at least 1"
420+
end
421+
422+
test "returns 400 on zero page_size" do
423+
{:ok, response} =
424+
HTTPoison.get(
425+
"http://localhost:#{@port}/api/#{@version}/projects?page=1&page_size=0",
426+
@headers
427+
)
428+
429+
assert response.status_code == 400
430+
assert Poison.decode!(response.body)["message"] =~ "page_size must be at least 1"
431+
end
432+
433+
test "returns 400 on too large page" do
434+
{:ok, response} =
435+
HTTPoison.get(
436+
"http://localhost:#{@port}/api/#{@version}/projects?page=9999&page_size=2",
437+
@headers
438+
)
439+
440+
assert response.status_code == 400 or response.status_code == 200
441+
442+
if response.status_code == 400 do
443+
assert Poison.decode!(response.body)["message"] =~ "page must be at most"
444+
end
445+
end
446+
447+
test "returns 400 on too large page_size" do
448+
{:ok, response} =
449+
HTTPoison.get(
450+
"http://localhost:#{@port}/api/#{@version}/projects?page=1&page_size=9999",
451+
@headers
452+
)
453+
454+
assert response.status_code == 400
455+
assert Poison.decode!(response.body)["message"] =~ "page_size must be at most"
456+
end
457+
458+
test "returns 400 on non-numeric page_size" do
459+
{:ok, response} =
460+
HTTPoison.get(
461+
"http://localhost:#{@port}/api/#{@version}/projects?page=1&page_size=abc",
462+
@headers
463+
)
464+
465+
assert response.status_code == 400
466+
assert Poison.decode!(response.body)["message"] =~ "page_size must be a number"
467+
end
468+
end
469+
286470
describe "GET /api/<version>/projects/:name with authorized user" do
287471
setup do
288472
FunRegistry.set!(FakeServices.RbacService, :list_user_permissions, fn _, _ ->

0 commit comments

Comments
 (0)