Add /api/v1/followed_tags
authorFloatingGhost <hannah@coffee-and-dreams.uk>
Sat, 31 Dec 2022 18:05:21 +0000 (18:05 +0000)
committerFloatingGhost <hannah@coffee-and-dreams.uk>
Sat, 31 Dec 2022 18:09:34 +0000 (18:09 +0000)
CHANGELOG.md
lib/pleroma/pagination.ex
lib/pleroma/user/hashtag_follow.ex
lib/pleroma/web/api_spec/operations/tag_operation.ex
lib/pleroma/web/api_spec/schemas/tag.ex
lib/pleroma/web/mastodon_api/controllers/tag_controller.ex
lib/pleroma/web/mastodon_api/views/tag_view.ex
lib/pleroma/web/router.ex
test/pleroma/web/mastodon_api/controllers/tag_controller_test.exs

index 73563c3128b7b842f5e54df5b26d1db41bd892b2..ee3c288587d9c501d581e2788b6896c25ec84994 100644 (file)
@@ -13,6 +13,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
 - Argon2 password hashing
 - Ability to "verify" links in profile fields via rel=me
 - Mix tasks to dump/load config to/from json for bulk editing
+- Followed hashtag list at /api/v1/followed\_tags, API parity with mastodon
 
 ### Removed
 - Non-finch HTTP adapters
index 33e45a0eb45b8b83a3a00fae90f22dd562c94ed1..28e37933e2abfbaaaa2258c82b65f96f210cb0df 100644 (file)
@@ -88,9 +88,9 @@ defmodule Pleroma.Pagination do
 
   defp cast_params(params) do
     param_types = %{
-      min_id: :string,
-      since_id: :string,
-      max_id: :string,
+      min_id: params[:id_type] || :string,
+      since_id: params[:id_type] || :string,
+      max_id: params[:id_type] || :string,
       offset: :integer,
       limit: :integer,
       skip_extra_order: :boolean,
index 43ed93f4d20d2a7e771ee237105151a29bd4f428..dd0254ef4c22ba194fa1f58bb496906e132fe87d 100644 (file)
@@ -43,7 +43,13 @@ defmodule Pleroma.User.HashtagFollow do
   end
 
   def get_by_user(%User{} = user) do
-    Ecto.assoc(user, :followed_hashtags)
+    user
+    |> followed_hashtags_query()
     |> Repo.all()
   end
+
+  def followed_hashtags_query(%User{} = user) do
+    Ecto.assoc(user, :followed_hashtags)
+    |> Ecto.Query.order_by([h], desc: h.id)
+  end
 end
index e22457159653be66ebeab8efd89611f115f540b5..ce4f4ad5bb1ef2d9ef7b24092cd72515c3fdb22f 100644 (file)
@@ -44,7 +44,7 @@ defmodule Pleroma.Web.ApiSpec.TagOperation do
       tags: ["Tags"],
       summary: "Unfollow a hashtag",
       description: "Unfollow a hashtag",
-      security: [%{"oAuth" => ["write:follow"]}],
+      security: [%{"oAuth" => ["write:follows"]}],
       parameters: [id_param()],
       operationId: "TagController.unfollow",
       responses: %{
@@ -54,6 +54,26 @@ defmodule Pleroma.Web.ApiSpec.TagOperation do
     }
   end
 
+  def show_followed_operation do
+    %Operation{
+      tags: ["Tags"],
+      summary: "Followed hashtags",
+      description: "View a list of hashtags the currently authenticated user is following",
+      parameters: pagination_params(),
+      security: [%{"oAuth" => ["read:follows"]}],
+      operationId: "TagController.show_followed",
+      responses: %{
+        200 =>
+          Operation.response("Hashtags", "application/json", %Schema{
+            type: :array,
+            items: Tag
+          }),
+        403 => Operation.response("Forbidden", "application/json", ApiError),
+        404 => Operation.response("Not Found", "application/json", ApiError)
+      }
+    }
+  end
+
   defp id_param do
     Operation.parameter(
       :id,
@@ -62,4 +82,22 @@ defmodule Pleroma.Web.ApiSpec.TagOperation do
       "Name of the hashtag"
     )
   end
+
+  def pagination_params do
+    [
+      Operation.parameter(:max_id, :query, :integer, "Return items older than this ID"),
+      Operation.parameter(
+        :min_id,
+        :query,
+        :integer,
+        "Return the oldest items newer than this ID"
+      ),
+      Operation.parameter(
+        :limit,
+        :query,
+        %Schema{type: :integer, default: 20},
+        "Maximum number of items to return. Will be ignored if it's more than 40"
+      )
+    ]
+  end
 end
index 41b5e5c785277cdfb971f796037f4a608874c0e9..657fc3d2b8a8c96d35ea94d8573de459b3a87cf9 100644 (file)
@@ -21,6 +21,12 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Tag do
       following: %Schema{
         type: :boolean,
         description: "Whether the authenticated user is following the hashtag"
+      },
+      history: %Schema{
+        type: :array,
+        items: %Schema{type: :string},
+        description:
+          "A list of historical uses of the hashtag (not implemented, for compatibility only)"
       }
     },
     example: %{
index b8995eb00c8f8ae866701ca8850dc10bd75ec01b..ca5ee48ac151ab82470b8d01cb1c17952d9d9a31 100644 (file)
@@ -4,9 +4,24 @@ defmodule Pleroma.Web.MastodonAPI.TagController do
 
   alias Pleroma.User
   alias Pleroma.Hashtag
+  alias Pleroma.Pagination
+
+  import Pleroma.Web.ControllerHelper,
+    only: [
+      add_link_headers: 2
+    ]
 
   plug(Pleroma.Web.ApiSpec.CastAndValidate)
-  plug(Pleroma.Web.Plugs.OAuthScopesPlug, %{scopes: ["read"]} when action in [:show])
+
+  plug(
+    Pleroma.Web.Plugs.OAuthScopesPlug,
+    %{scopes: ["read"]} when action in [:show]
+  )
+
+  plug(
+    Pleroma.Web.Plugs.OAuthScopesPlug,
+    %{scopes: ["read:follows"]} when action in [:show_followed]
+  )
 
   plug(
     Pleroma.Web.Plugs.OAuthScopesPlug,
@@ -44,4 +59,19 @@ defmodule Pleroma.Web.MastodonAPI.TagController do
       _ -> render_error(conn, :not_found, "Hashtag not found")
     end
   end
+
+  def show_followed(conn, params) do
+    with %{assigns: %{user: %User{} = user}} <- conn do
+      params = Map.put(params, :id_type, :integer)
+
+      hashtags =
+        user
+        |> User.HashtagFollow.followed_hashtags_query()
+        |> Pagination.fetch_paginated(params)
+
+      conn
+      |> add_link_headers(hashtags)
+      |> render("index.json", tags: hashtags, for_user: user)
+    end
+  end
 end
index 6e491c261885e2c954d9ad7eb50050e1bd197a2c..e24d423c293d64bd3def06767cf4034891013957 100644 (file)
@@ -3,6 +3,10 @@ defmodule Pleroma.Web.MastodonAPI.TagView do
   alias Pleroma.User
   alias Pleroma.Web.Router.Helpers
 
+  def render("index.json", %{tags: tags, for_user: user}) do
+    safe_render_many(tags, __MODULE__, "show.json", %{for_user: user})
+  end
+
   def render("show.json", %{tag: tag, for_user: user}) do
     following =
       with %User{} <- user do
index f984ad59822d19bbbdaa2cafae0b21cd3bf7b80f..b1433f180c8f94fa6f729e5cf86a89b6954a1a5c 100644 (file)
@@ -606,6 +606,7 @@ defmodule Pleroma.Web.Router do
     get("/tags/:id", TagController, :show)
     post("/tags/:id/follow", TagController, :follow)
     post("/tags/:id/unfollow", TagController, :unfollow)
+    get("/followed_tags", TagController, :show_followed)
   end
 
   scope "/api/web", Pleroma.Web do
index a1b73ad78bcf6d02c9743119409f11795a3e0353..71c8e7fc078404f6a6052fb427d3d601d3177836 100644 (file)
@@ -94,4 +94,66 @@ defmodule Pleroma.Web.MastodonAPI.TagControllerTest do
       assert response["error"] == "Hashtag not found"
     end
   end
+
+  describe "GET /api/v1/followed_tags" do
+    test "should list followed tags" do
+      %{user: user, conn: conn} = oauth_access(["read:follows"])
+
+      response =
+        conn
+        |> get("/api/v1/followed_tags")
+        |> json_response_and_validate_schema(200)
+
+      assert Enum.empty?(response)
+
+      hashtag = insert(:hashtag, name: "jubjub")
+      {:ok, _user} = User.follow_hashtag(user, hashtag)
+
+      response =
+        conn
+        |> get("/api/v1/followed_tags")
+        |> json_response_and_validate_schema(200)
+
+      assert [%{"name" => "jubjub"}] = response
+    end
+
+    test "should include a link header to paginate" do
+      %{user: user, conn: conn} = oauth_access(["read:follows"])
+
+      for i <- 1..21 do
+        hashtag = insert(:hashtag, name: "jubjub#{i}}")
+        {:ok, _user} = User.follow_hashtag(user, hashtag)
+      end
+
+      response =
+        conn
+        |> get("/api/v1/followed_tags")
+
+      json = json_response_and_validate_schema(response, 200)
+      assert Enum.count(json) == 20
+      assert [link_header] = get_resp_header(response, "link")
+      assert link_header =~ "rel=\"next\""
+      next_link = extract_next_link_header(link_header)
+
+      response =
+        conn
+        |> get(next_link)
+        |> json_response_and_validate_schema(200)
+
+      assert Enum.count(response) == 1
+    end
+
+    test "should refuse access without read:follows scope" do
+      %{conn: conn} = oauth_access(["write"])
+
+      conn
+      |> get("/api/v1/followed_tags")
+      |> json_response_and_validate_schema(403)
+    end
+  end
+
+  defp extract_next_link_header(header) do
+    [_, next_link] = Regex.run(~r{<(?<next_link>.*)>; rel="next"}, header)
+    next_link
+  end
 end