Repository: sissbruecker/linkding

Issue:
[Feature Request] Make API return favicon URLs
Now that favicons can be automatically downloaded for links, it would be nice if those URLs could be exposed via the API. 
[Feature Request] Make API return thumbnail URLs
Similar to #413, it would be great to get access to the thumbnail URL via the API so it can be displayed in an app.


Discussion:
I did this in the PR I raised earlier this week. I also included user info, if the user owns the bookmark and web archive link.
#462 

Yes please



Patch:
diff --git a/bookmarks/api/routes.py b/bookmarks/api/routes.py
index 5249c512..5aa7f92f 100644
--- a/bookmarks/api/routes.py
+++ b/bookmarks/api/routes.py
@@ -52,7 +52,7 @@ def get_queryset(self):
         return Bookmark.objects.all().filter(owner=user)
 
     def get_serializer_context(self):
-        return {"user": self.request.user}
+        return {"request": self.request, "user": self.request.user}
 
     @action(methods=["get"], detail=False)
     def archived(self, request):
@@ -60,8 +60,8 @@ def archived(self, request):
         search = BookmarkSearch.from_request(request.GET)
         query_set = queries.query_archived_bookmarks(user, user.profile, search)
         page = self.paginate_queryset(query_set)
-        serializer = self.get_serializer_class()
-        data = serializer(page, many=True).data
+        serializer = self.get_serializer(page, many=True)
+        data = serializer.data
         return self.get_paginated_response(data)
 
     @action(methods=["get"], detail=False)
@@ -73,8 +73,8 @@ def shared(self, request):
             user, request.user_profile, search, public_only
         )
         page = self.paginate_queryset(query_set)
-        serializer = self.get_serializer_class()
-        data = serializer(page, many=True).data
+        serializer = self.get_serializer(page, many=True)
+        data = serializer.data
         return self.get_paginated_response(data)
 
     @action(methods=["post"], detail=True)
diff --git a/bookmarks/api/serializers.py b/bookmarks/api/serializers.py
index 6e82e339..38f19b9a 100644
--- a/bookmarks/api/serializers.py
+++ b/bookmarks/api/serializers.py
@@ -1,4 +1,5 @@
 from django.db.models import prefetch_related_objects
+from django.templatetags.static import static
 from rest_framework import serializers
 from rest_framework.serializers import ListSerializer
 
@@ -31,6 +32,8 @@ class Meta:
             "website_title",
             "website_description",
             "web_archive_snapshot_url",
+            "favicon_url",
+            "preview_image_url",
             "is_archived",
             "unread",
             "shared",
@@ -42,6 +45,8 @@ class Meta:
             "website_title",
             "website_description",
             "web_archive_snapshot_url",
+            "favicon_url",
+            "preview_image_url",
             "date_added",
             "date_modified",
         ]
@@ -56,6 +61,24 @@ class Meta:
     shared = serializers.BooleanField(required=False, default=False)
     # Override readonly tag_names property to allow passing a list of tag names to create/update
     tag_names = TagListField(required=False, default=[])
+    favicon_url = serializers.SerializerMethodField()
+    preview_image_url = serializers.SerializerMethodField()
+
+    def get_favicon_url(self, obj: Bookmark):
+        if not obj.favicon_file:
+            return None
+        request = self.context.get("request")
+        favicon_file_path = static(obj.favicon_file)
+        favicon_url = request.build_absolute_uri(favicon_file_path)
+        return favicon_url
+
+    def get_preview_image_url(self, obj: Bookmark):
+        if not obj.preview_image_file:
+            return None
+        request = self.context.get("request")
+        preview_image_file_path = static(obj.preview_image_file)
+        preview_image_url = request.build_absolute_uri(preview_image_file_path)
+        return preview_image_url
 
     def create(self, validated_data):
         bookmark = Bookmark()
diff --git a/docs/API.md b/docs/API.md
index 8099f519..1af04a83 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -49,6 +49,8 @@ Example response:
       "website_title": "Website title",
       "website_description": "Website description",
       "web_archive_snapshot_url": "https://web.archive.org/web/20200926094623/https://example.com",
+      "favicon_url": "http://127.0.0.1:8000/static/https_example_com.png",
+      "preview_image_url": "http://127.0.0.1:8000/static/0ac5c53db923727765216a3a58e70522.jpg",
       "is_archived": false,
       "unread": false,
       "shared": false,


Test patch:
diff --git a/bookmarks/tests/helpers.py b/bookmarks/tests/helpers.py
index 4cd160d4..1e8b0923 100644
--- a/bookmarks/tests/helpers.py
+++ b/bookmarks/tests/helpers.py
@@ -87,6 +87,8 @@ def setup_numbered_bookmarks(
         shared: bool = False,
         with_tags: bool = False,
         with_web_archive_snapshot_url: bool = False,
+        with_favicon_file: bool = False,
+        with_preview_image_file: bool = False,
         user: User = None,
     ):
         user = user or self.get_or_create_test_user()
@@ -118,6 +120,12 @@ def setup_numbered_bookmarks(
             web_archive_snapshot_url = ""
             if with_web_archive_snapshot_url:
                 web_archive_snapshot_url = f"https://web.archive.org/web/{i}"
+            favicon_file = ""
+            if with_favicon_file:
+                favicon_file = f"favicon_{i}.png"
+            preview_image_file = ""
+            if with_preview_image_file:
+                preview_image_file = f"preview_image_{i}.png"
             bookmark = self.setup_bookmark(
                 url=url,
                 title=title,
@@ -126,6 +134,8 @@ def setup_numbered_bookmarks(
                 shared=shared,
                 tags=tags,
                 web_archive_snapshot_url=web_archive_snapshot_url,
+                favicon_file=favicon_file,
+                preview_image_file=preview_image_file,
                 user=user,
             )
             bookmarks.append(bookmark)
diff --git a/bookmarks/tests/test_bookmarks_api.py b/bookmarks/tests/test_bookmarks_api.py
index 58448df6..68bd1a27 100644
--- a/bookmarks/tests/test_bookmarks_api.py
+++ b/bookmarks/tests/test_bookmarks_api.py
@@ -36,6 +36,16 @@ def assertBookmarkListEqual(self, data_list, bookmarks):
             expectation["website_title"] = bookmark.website_title
             expectation["website_description"] = bookmark.website_description
             expectation["web_archive_snapshot_url"] = bookmark.web_archive_snapshot_url
+            expectation["favicon_url"] = (
+                f"http://testserver/static/{bookmark.favicon_file}"
+                if bookmark.favicon_file
+                else None
+            )
+            expectation["preview_image_url"] = (
+                f"http://testserver/static/{bookmark.preview_image_file}"
+                if bookmark.preview_image_file
+                else None
+            )
             expectation["is_archived"] = bookmark.is_archived
             expectation["unread"] = bookmark.unread
             expectation["shared"] = bookmark.shared
@@ -65,7 +75,11 @@ def test_list_bookmarks(self):
     def test_list_bookmarks_with_more_details(self):
         self.authenticate()
         bookmarks = self.setup_numbered_bookmarks(
-            5, with_tags=True, with_web_archive_snapshot_url=True
+            5,
+            with_tags=True,
+            with_web_archive_snapshot_url=True,
+            with_favicon_file=True,
+            with_preview_image_file=True,
         )
 
         response = self.get(
@@ -171,6 +185,23 @@ def test_list_archived_bookmarks_does_not_return_unarchived_bookmarks(self):
         )
         self.assertBookmarkListEqual(response.data["results"], archived_bookmarks)
 
+    def test_list_archived_bookmarks_with_more_details(self):
+        self.authenticate()
+        archived_bookmarks = self.setup_numbered_bookmarks(
+            5,
+            archived=True,
+            with_tags=True,
+            with_web_archive_snapshot_url=True,
+            with_favicon_file=True,
+            with_preview_image_file=True,
+        )
+
+        response = self.get(
+            reverse("bookmarks:bookmark-archived"),
+            expected_status_code=status.HTTP_200_OK,
+        )
+        self.assertBookmarkListEqual(response.data["results"], archived_bookmarks)
+
     def test_list_archived_bookmarks_should_filter_by_query(self):
         self.authenticate()
         search_value = self.get_random_string()
@@ -220,6 +251,26 @@ def test_list_shared_bookmarks(self):
         )
         self.assertBookmarkListEqual(response.data["results"], shared_bookmarks)
 
+    def test_list_shared_bookmarks_with_more_details(self):
+        self.authenticate()
+
+        other_user = self.setup_user(enable_sharing=True)
+        shared_bookmarks = self.setup_numbered_bookmarks(
+            5,
+            shared=True,
+            user=other_user,
+            with_tags=True,
+            with_web_archive_snapshot_url=True,
+            with_favicon_file=True,
+            with_preview_image_file=True,
+        )
+
+        response = self.get(
+            reverse("bookmarks:bookmark-shared"),
+            expected_status_code=status.HTTP_200_OK,
+        )
+        self.assertBookmarkListEqual(response.data["results"], shared_bookmarks)
+
     def test_list_only_publicly_shared_bookmarks_when_not_logged_in(self):
         user1 = self.setup_user(enable_sharing=True, enable_public_sharing=True)
         user2 = self.setup_user(enable_sharing=True)
@@ -701,6 +752,8 @@ def test_check_returns_bookmark_if_url_is_bookmarked(self):
             url="https://example.com",
             title="Example title",
             description="Example description",
+            favicon_file="favicon.png",
+            preview_image_file="preview.png",
         )
 
         url = reverse("bookmarks:bookmark-check")
@@ -715,6 +768,12 @@ def test_check_returns_bookmark_if_url_is_bookmarked(self):
         self.assertEqual(bookmark.url, bookmark_data["url"])
         self.assertEqual(bookmark.title, bookmark_data["title"])
         self.assertEqual(bookmark.description, bookmark_data["description"])
+        self.assertEqual(
+            "http://testserver/static/favicon.png", bookmark_data["favicon_url"]
+        )
+        self.assertEqual(
+            "http://testserver/static/preview.png", bookmark_data["preview_image_url"]
+        )
 
     def test_check_returns_existing_metadata_if_url_is_bookmarked(self):
         self.authenticate()