diff --git a/lib/bob/artifacts.ex b/lib/bob/artifacts.ex index 68712b1f..da42fbe4 100644 --- a/lib/bob/artifacts.ex +++ b/lib/bob/artifacts.ex @@ -222,6 +222,13 @@ defmodule Bob.Artifacts do |> Repo.aggregate(:count, :id) end + def artifact_page(filters, limit, offset) do + %{ + results: search_artifacts(filters, limit, offset), + total: count_artifacts(filters) + } + end + def search_docker_tags(filters \\ %{}, limit \\ 100, offset \\ 0) do filters |> docker_tag_search_query() @@ -237,6 +244,30 @@ defmodule Bob.Artifacts do |> Repo.aggregate(:count, :id) end + @doc """ + Returns a page of matching Docker tags together with the total match count. + + The page and the count are separate queries, so the total is kept coherent + with the page if tags change between them, and the count is skipped entirely + when the first page comes back empty. + """ + def docker_tag_page(filters, limit, offset) do + results = search_docker_tags(filters, limit, offset) + + total = + if offset == 0 and results == [] do + 0 + else + filters + |> count_docker_tags() + # The page and count are separate queries, so keep the pager coherent if + # tags change between them. + |> max(offset + length(results)) + end + + %{results: results, total: total} + end + def distinct_kinds(), do: distinct_values(Artifact, :kind) def distinct_arches(), do: distinct_values(Artifact, :arch) def distinct_oses(), do: distinct_values(Artifact, :os) diff --git a/lib/bob/artifacts/artifact_search.ex b/lib/bob/artifacts/artifact_search.ex new file mode 100644 index 00000000..ad465844 --- /dev/null +++ b/lib/bob/artifacts/artifact_search.ex @@ -0,0 +1,23 @@ +defmodule Bob.Artifacts.ArtifactSearch do + @page 100 + @filter_keys ~w(query kind arch os)a + + def page_size(), do: @page + def filter_keys(), do: @filter_keys + + def parse_filters(params) do + %{ + query: params["query"] || "", + kind: params["kind"] || "", + arch: params["arch"] || "", + os: params["os"] || "" + } + end + + def parse_offset(params) do + case Integer.parse(params["offset"] || "") do + {offset, ""} when offset > 0 -> offset + _ -> 0 + end + end +end diff --git a/lib/bob/artifacts/docker_tag_search.ex b/lib/bob/artifacts/docker_tag_search.ex index ddda1c9f..85e05dca 100644 --- a/lib/bob/artifacts/docker_tag_search.ex +++ b/lib/bob/artifacts/docker_tag_search.ex @@ -8,10 +8,40 @@ defmodule Bob.Artifacts.DockerTagSearch do @elixir_tag_regex ~r/^(.+)-erlang-(.+)-(alpine|ubuntu|debian)-(.+)$/ @version_regex ~r/^\d+(?:\.\d+)*(?:-[0-9A-Za-z][0-9A-Za-z.-]*)?$/ + @page 100 + @filter_keys ~w(repo tag arch elixir_version erlang_version os os_version)a + def repos(), do: Enum.sort(@erlang_repos ++ @elixir_repos) def arches(), do: @archs def oses(), do: @oses + def page_size(), do: @page + def filter_keys(), do: @filter_keys + + def parse_filters(params) do + tag = params["tag"] || "" + + %{ + repo: params["repo"] || "", + tag: tag, + arch: params["arch"] || "", + elixir_version: structured_param(params, "elixir_version", tag), + erlang_version: structured_param(params, "erlang_version", tag), + os: structured_param(params, "os", tag), + os_version: structured_param(params, "os_version", tag) + } + end + + def parse_offset(params) do + case Integer.parse(params["offset"] || "") do + {offset, ""} when offset > 0 -> offset + _ -> 0 + end + end + + defp structured_param(_params, _key, tag) when tag != "", do: "" + defp structured_param(params, key, _tag), do: params[key] || "" + def metadata(repo, tag) when repo in @erlang_repos do case Regex.run(@erlang_tag_regex, tag, capture: :all_but_first) do [erlang_version, os, os_version] -> diff --git a/lib/bob_web/live/artifacts_live.ex b/lib/bob_web/live/artifacts_live.ex index 3e3c1bcf..410ab79c 100644 --- a/lib/bob_web/live/artifacts_live.ex +++ b/lib/bob_web/live/artifacts_live.ex @@ -1,7 +1,10 @@ defmodule BobWeb.ArtifactsLive do use BobWeb, :live_view - @page 100 + alias Bob.Artifacts.ArtifactSearch + + @page ArtifactSearch.page_size() + @filter_keys ArtifactSearch.filter_keys() @impl true def mount(_params, _session, socket) do @@ -35,14 +38,7 @@ defmodule BobWeb.ArtifactsLive do def handle_event("page", %{"dir" => dir}, socket) do offset = max(socket.assigns.offset + step(dir), 0) - - filters = [ - query: socket.assigns.query, - kind: socket.assigns.kind, - arch: socket.assigns.arch, - os: socket.assigns.os - ] - + filters = Enum.map(@filter_keys, &{&1, socket.assigns[&1]}) {:noreply, push_patch(socket, to: ~p"/artifacts?#{query(filters, offset)}")} end @@ -50,13 +46,9 @@ defmodule BobWeb.ArtifactsLive do defp step("prev"), do: -@page defp filter_assigns(params) do - [ - query: params["query"] || "", - kind: params["kind"] || "", - arch: params["arch"] || "", - os: params["os"] || "", - offset: parse_offset(params) - ] + filters = ArtifactSearch.parse_filters(params) + + Enum.map(@filter_keys, &{&1, filters[&1]}) ++ [offset: ArtifactSearch.parse_offset(params)] end defp query(filters, offset) do @@ -64,13 +56,6 @@ defmodule BobWeb.ArtifactsLive do if offset > 0, do: filters ++ [offset: offset], else: filters end - defp parse_offset(params) do - case Integer.parse(params["offset"] || "") do - {offset, ""} when offset > 0 -> offset - _ -> 0 - end - end - defp load_options(socket) do assign(socket, kinds: Bob.Artifacts.distinct_kinds(), @@ -84,14 +69,9 @@ defmodule BobWeb.ArtifactsLive do filters = %{query: q, kind: kind, arch: arch, os: os} - results = - Bob.Artifacts.search_artifacts(filters, @page, offset) + %{results: results, total: total} = Bob.Artifacts.artifact_page(filters, @page, offset) - assign(socket, - results: results, - total: Bob.Artifacts.count_artifacts(filters), - loading: false - ) + assign(socket, results: results, total: total, loading: false) end defp fmt(nil), do: "—" diff --git a/lib/bob_web/live/docker_tags_live.ex b/lib/bob_web/live/docker_tags_live.ex index 8e37811f..8f930aa4 100644 --- a/lib/bob_web/live/docker_tags_live.ex +++ b/lib/bob_web/live/docker_tags_live.ex @@ -1,8 +1,10 @@ defmodule BobWeb.DockerTagsLive do use BobWeb, :live_view - @page 100 - @filter_keys ~w(repo tag arch elixir_version erlang_version os os_version)a + alias Bob.Artifacts.DockerTagSearch + + @page DockerTagSearch.page_size() + @filter_keys DockerTagSearch.filter_keys() @impl true def mount(_params, _session, socket) do @@ -45,28 +47,9 @@ defmodule BobWeb.DockerTagsLive do defp step("prev"), do: -@page defp filter_assigns(params) do - tag = params["tag"] || "" - - [ - repo: params["repo"] || "", - tag: tag, - arch: params["arch"] || "", - elixir_version: structured_param(params, "elixir_version", tag), - erlang_version: structured_param(params, "erlang_version", tag), - os: structured_param(params, "os", tag), - os_version: structured_param(params, "os_version", tag), - offset: parse_offset(params) - ] - end - - defp structured_param(_params, _key, tag) when tag != "", do: "" - defp structured_param(params, key, _tag), do: params[key] || "" + filters = DockerTagSearch.parse_filters(params) - defp parse_offset(params) do - case Integer.parse(params["offset"] || "") do - {offset, ""} when offset > 0 -> offset - _ -> 0 - end + Enum.map(@filter_keys, &{&1, filters[&1]}) ++ [offset: DockerTagSearch.parse_offset(params)] end defp query(filters, offset) do @@ -96,19 +79,7 @@ defmodule BobWeb.DockerTagsLive do os_version: os_version } - results = - Bob.Artifacts.search_docker_tags(filters, @page, offset) - - total = - if offset == 0 and results == [] do - 0 - else - filters - |> Bob.Artifacts.count_docker_tags() - # The page and count are separate queries, so keep the pager coherent if - # tags change between them. - |> max(offset + length(results)) - end + %{results: results, total: total} = Bob.Artifacts.docker_tag_page(filters, @page, offset) assign(socket, results: results, total: total, loading: false) end diff --git a/lib/bob_web/plugs/search_json.ex b/lib/bob_web/plugs/search_json.ex new file mode 100644 index 00000000..33c77fe6 --- /dev/null +++ b/lib/bob_web/plugs/search_json.ex @@ -0,0 +1,83 @@ +defmodule BobWeb.Plugs.SearchJson do + @moduledoc """ + Serves the artifact and Docker tag search LiveViews as JSON for API clients + that send `Accept: application/json`, mirroring the LiveView for the same + query parameters. Other requests, and any other path, fall through untouched + to the regular browser pipeline and the LiveView. + + This runs ahead of the `:accepts` plug so a JSON request is answered and + halted before that plug would reject it as not HTML. + """ + + import Plug.Conn + import Phoenix.Controller, only: [json: 2] + + alias Bob.Artifacts + alias Bob.Artifacts.{ArtifactSearch, DockerTagSearch} + + def init(opts), do: opts + + def call(conn, _opts) do + case conn.request_path do + "/artifacts" -> maybe_json(conn, &artifacts/1) + "/docker" -> maybe_json(conn, &docker_tags/1) + _other -> conn + end + end + + defp maybe_json(conn, build) do + if json_requested?(conn) do + body = build.(fetch_query_params(conn).query_params) + conn |> json(body) |> halt() + else + conn + end + end + + defp artifacts(params) do + offset = ArtifactSearch.parse_offset(params) + filters = ArtifactSearch.parse_filters(params) + page = Artifacts.artifact_page(filters, ArtifactSearch.page_size(), offset) + + envelope(page, :artifacts, offset, ArtifactSearch.page_size(), &artifact/1) + end + + defp docker_tags(params) do + offset = DockerTagSearch.parse_offset(params) + filters = DockerTagSearch.parse_filters(params) + page = Artifacts.docker_tag_page(filters, DockerTagSearch.page_size(), offset) + + envelope(page, :tags, offset, DockerTagSearch.page_size(), &docker_tag/1) + end + + defp envelope(%{results: results, total: total}, key, offset, page_size, row) do + %{key => Enum.map(results, row), total: total, offset: offset, page_size: page_size} + end + + defp artifact(artifact) do + %{ + kind: artifact.kind, + arch: artifact.arch, + os: artifact.os, + name: artifact.name, + ref: artifact.ref, + sha256: artifact.sha256, + built_at: artifact.built_at && DateTime.to_iso8601(artifact.built_at) + } + end + + defp docker_tag(tag) do + %{ + repo: tag.repo, + tag: tag.tag, + archs: tag.archs, + built_at: tag.built_at && DateTime.to_iso8601(tag.built_at) + } + end + + defp json_requested?(conn) do + conn + |> get_req_header("accept") + |> Enum.any?(&String.contains?(&1, "application/json")) + end +end diff --git a/lib/bob_web/router.ex b/lib/bob_web/router.ex index 9059f0ba..d13eb432 100644 --- a/lib/bob_web/router.ex +++ b/lib/bob_web/router.ex @@ -4,6 +4,10 @@ defmodule BobWeb.Router do import BobWeb.UserAuth pipeline :browser do + # Answers the search LiveViews as JSON for Accept: application/json before + # :accepts would reject the request as not HTML; a no-op for every other + # request, which falls through to the LiveView. + plug(BobWeb.Plugs.SearchJson) plug(:accepts, ["html"]) plug(:fetch_session) plug(:fetch_live_flash) diff --git a/test/bob_web/artifacts_json_test.exs b/test/bob_web/artifacts_json_test.exs new file mode 100644 index 00000000..e2da64ee --- /dev/null +++ b/test/bob_web/artifacts_json_test.exs @@ -0,0 +1,64 @@ +defmodule BobWeb.ArtifactsJsonTest do + use BobWeb.ConnCase + + setup %{conn: conn} do + Bob.Artifacts.upsert(%{ + kind: "otp", + arch: "amd64", + os: "ubuntu-24.04", + name: "OTP-27.0", + ref: "aaa", + sha256: "deadbeef", + built_at: ~U[2026-01-01 00:00:00Z] + }) + + Bob.Artifacts.upsert(%{ + kind: "otp", + arch: "arm64", + os: "ubuntu-22.04", + name: "OTP-26.2", + ref: "bbb", + built_at: ~U[2026-02-01 00:00:00Z] + }) + + {:ok, conn: put_req_header(conn, "accept", "application/json")} + end + + test "returns all artifacts as JSON", %{conn: conn} do + body = conn |> get(~p"/artifacts") |> json_response(200) + + assert body["total"] == 2 + assert body["offset"] == 0 + assert body["page_size"] == 100 + + names = Enum.map(body["artifacts"], & &1["name"]) + assert "OTP-27.0" in names + assert "OTP-26.2" in names + + artifact = Enum.find(body["artifacts"], &(&1["name"] == "OTP-27.0")) + assert artifact["kind"] == "otp" + assert artifact["arch"] == "amd64" + assert artifact["os"] == "ubuntu-24.04" + assert artifact["ref"] == "aaa" + assert artifact["sha256"] == "deadbeef" + assert artifact["built_at"] =~ ~r/^2026-01-01T/ + end + + test "filters by free-text query like the LiveView", %{conn: conn} do + body = conn |> get(~p"/artifacts?query=27.0") |> json_response(200) + + assert body["total"] == 1 + assert Enum.map(body["artifacts"], & &1["name"]) == ["OTP-27.0"] + end + + test "filters by kind, arch and os", %{conn: conn} do + body = conn |> get(~p"/artifacts?arch=arm64") |> json_response(200) + + assert Enum.map(body["artifacts"], & &1["name"]) == ["OTP-26.2"] + end + + test "browsers still get the LiveView HTML", %{conn: conn} do + conn = conn |> delete_req_header("accept") |> get(~p"/artifacts") + assert html_response(conn, 200) =~ "Build artifacts" + end +end diff --git a/test/bob_web/docker_tags_json_test.exs b/test/bob_web/docker_tags_json_test.exs new file mode 100644 index 00000000..9570c42c --- /dev/null +++ b/test/bob_web/docker_tags_json_test.exs @@ -0,0 +1,72 @@ +defmodule BobWeb.DockerTagsJsonTest do + use BobWeb.ConnCase + + setup %{conn: conn} do + Bob.Artifacts.add_docker_tag("hexpm/erlang", "27.0-ubuntu-noble-20250101", [ + "amd64", + "arm64" + ]) + + Bob.Artifacts.add_docker_tag( + "hexpm/elixir", + "1.18.0-erlang-27.0-ubuntu-noble-20250101", + ["amd64", "arm64"] + ) + + Bob.Artifacts.add_docker_tag( + "hexpm/elixir", + "1.17.3-erlang-26.2-debian-bookworm-20250113-slim", + ["amd64"] + ) + + {:ok, conn: put_req_header(conn, "accept", "application/json")} + end + + test "returns all tags as JSON", %{conn: conn} do + body = conn |> get(~p"/docker") |> json_response(200) + + assert body["total"] == 3 + assert body["offset"] == 0 + assert body["page_size"] == 100 + + tags = Enum.map(body["tags"], & &1["tag"]) + assert "27.0-ubuntu-noble-20250101" in tags + assert "1.18.0-erlang-27.0-ubuntu-noble-20250101" in tags + assert "1.17.3-erlang-26.2-debian-bookworm-20250113-slim" in tags + + tag = Enum.find(body["tags"], &(&1["tag"] == "27.0-ubuntu-noble-20250101")) + assert tag["repo"] == "hexpm/erlang" + assert tag["archs"] == ["amd64", "arm64"] + assert tag["built_at"] =~ ~r/^\d{4}-\d{2}-\d{2}T/ + end + + test "filters by tag prefix like the LiveView", %{conn: conn} do + body = conn |> get(~p"/docker?tag=1.18") |> json_response(200) + + assert body["total"] == 1 + assert Enum.map(body["tags"], & &1["tag"]) == ["1.18.0-erlang-27.0-ubuntu-noble-20250101"] + end + + test "filters by structured params", %{conn: conn} do + body = + conn + |> get(~p"/docker?repo=hexpm/elixir&os=ubuntu&erlang_version=27") + |> json_response(200) + + assert Enum.map(body["tags"], & &1["tag"]) == ["1.18.0-erlang-27.0-ubuntu-noble-20250101"] + end + + test "a tag prefix drops the structured filters, matching the form", %{conn: conn} do + body = + conn + |> get(~p"/docker?tag=1&erlang_version=26&os=debian") + |> json_response(200) + + assert body["total"] == 2 + end + + test "browsers still get the LiveView HTML", %{conn: conn} do + conn = conn |> delete_req_header("accept") |> get(~p"/docker") + assert html_response(conn, 200) =~ "Docker tags" + end +end