Skip to content

Commit

Permalink
Do not clear fields in POST requests (API behavior change)
Browse files Browse the repository at this point in the history
  • Loading branch information
sissbruecker committed Sep 24, 2024
1 parent 23ad52f commit d15c385
Show file tree
Hide file tree
Showing 5 changed files with 114 additions and 61 deletions.
40 changes: 13 additions & 27 deletions bookmarks/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,22 +49,17 @@ class Meta:
"web_archive_snapshot_url",
"favicon_url",
"preview_image_url",
"tag_names",
"date_added",
"date_modified",
"website_title",
"website_description",
]
list_serializer_class = BookmarkListSerializer

# Override optional char fields to provide default value
title = serializers.CharField(required=False, allow_blank=True, default="")
description = serializers.CharField(required=False, allow_blank=True, default="")
notes = serializers.CharField(required=False, allow_blank=True, default="")
is_archived = serializers.BooleanField(required=False, default=False)
unread = serializers.BooleanField(required=False, default=False)
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=[])
# Custom tag_names field to allow passing a list of tag names to create/update
tag_names = TagListField(required=False)
# Custom fields to return URLs for favicon and preview image
favicon_url = serializers.SerializerMethodField()
preview_image_url = serializers.SerializerMethodField()
# Add dummy website title and description fields for backwards compatibility but keep them empty
Expand Down Expand Up @@ -94,15 +89,9 @@ def get_website_description(self, obj: Bookmark):
return None

def create(self, validated_data):
bookmark = Bookmark()
bookmark.url = validated_data["url"]
bookmark.title = validated_data["title"]
bookmark.description = validated_data["description"]
bookmark.notes = validated_data["notes"]
bookmark.is_archived = validated_data["is_archived"]
bookmark.unread = validated_data["unread"]
bookmark.shared = validated_data["shared"]
tag_string = build_tag_string(validated_data["tag_names"])
tag_names = validated_data.pop("tag_names", [])
tag_string = build_tag_string(tag_names)
bookmark = Bookmark(**validated_data)

saved_bookmark = create_bookmark(bookmark, tag_string, self.context["user"])
# Unless scraping is explicitly disabled, enhance bookmark with website
Expand All @@ -113,15 +102,12 @@ def create(self, validated_data):
return saved_bookmark

def update(self, instance: Bookmark, validated_data):
# Update fields if they were provided in the payload
for key in ["url", "title", "description", "notes", "unread", "shared"]:
if key in validated_data:
setattr(instance, key, validated_data[key])

# Use tag string from payload, or use bookmark's current tags as fallback
tag_string = build_tag_string(instance.tag_names)
if "tag_names" in validated_data:
tag_string = build_tag_string(validated_data["tag_names"])
tag_names = validated_data.pop("tag_names", instance.tag_names)
tag_string = build_tag_string(tag_names)

for field_name, field in self.fields.items():
if not field.read_only and field_name in validated_data:
setattr(instance, field_name, validated_data[field_name])

return update_bookmark(instance, tag_string, self.context["user"])

Expand Down
1 change: 0 additions & 1 deletion bookmarks/services/bookmarks.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,6 @@ def update_bookmark(bookmark: Bookmark, tag_string, current_user: User):
if has_url_changed:
# Update web archive snapshot, if URL changed
tasks.create_web_archive_snapshot(current_user, bookmark, True)
bookmark.save()

return bookmark

Expand Down
85 changes: 77 additions & 8 deletions bookmarks/tests/test_bookmarks_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -480,7 +480,21 @@ def test_create_bookmark_minimal_payload(self):
self.authenticate()

data = {"url": "https://example.com/"}
self.post(reverse("bookmarks:bookmark-list"), data, status.HTTP_201_CREATED)
self.post(
reverse("bookmarks:bookmark-list") + "?disable_scraping",
data,
status.HTTP_201_CREATED,
)

bookmark = Bookmark.objects.get(url=data["url"])
self.assertEqual(data["url"], bookmark.url)
self.assertEqual("", bookmark.title)
self.assertEqual("", bookmark.description)
self.assertEqual("", bookmark.notes)
self.assertFalse(bookmark.is_archived)
self.assertFalse(bookmark.unread)
self.assertFalse(bookmark.shared)
self.assertBookmarkListEqual([], bookmark.tag_names)

def test_create_archived_bookmark(self):
self.authenticate()
Expand Down Expand Up @@ -586,6 +600,28 @@ def test_update_bookmark(self):
updated_bookmark = Bookmark.objects.get(id=bookmark.id)
self.assertEqual(updated_bookmark.url, data["url"])

def test_update_bookmark_ignores_readonly_fields(self):
self.authenticate()
bookmark = self.setup_bookmark()

data = {
"url": "https://example.com/updated",
"web_archive_snapshot_url": "test",
"website_title": "test",
"website_description": "test",
}
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])
self.put(url, data, expected_status_code=status.HTTP_200_OK)
updated_bookmark = Bookmark.objects.get(id=bookmark.id)
self.assertEqual(data["url"], updated_bookmark.url)
self.assertNotEqual(
data["web_archive_snapshot_url"], updated_bookmark.web_archive_snapshot_url
)
self.assertNotEqual(data["website_title"], updated_bookmark.website_title)
self.assertNotEqual(
data["website_description"], updated_bookmark.website_description
)

def test_update_bookmark_fails_without_required_fields(self):
self.authenticate()
bookmark = self.setup_bookmark()
Expand All @@ -594,19 +630,24 @@ def test_update_bookmark_fails_without_required_fields(self):
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])
self.put(url, data, expected_status_code=status.HTTP_400_BAD_REQUEST)

def test_update_bookmark_with_minimal_payload_clears_all_fields(self):
def test_update_bookmark_with_minimal_payload_does_not_modify_bookmark(self):
self.authenticate()
bookmark = self.setup_bookmark()
bookmark = self.setup_bookmark(
is_archived=True, unread=True, shared=True, tags=[self.setup_tag()]
)

data = {"url": "https://example.com/"}
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])
self.put(url, data, expected_status_code=status.HTTP_200_OK)
updated_bookmark = Bookmark.objects.get(id=bookmark.id)
self.assertEqual(updated_bookmark.url, data["url"])
self.assertEqual(updated_bookmark.title, "")
self.assertEqual(updated_bookmark.description, "")
self.assertEqual(updated_bookmark.notes, "")
self.assertEqual(updated_bookmark.tag_names, [])
self.assertEqual(updated_bookmark.title, bookmark.title)
self.assertEqual(updated_bookmark.description, bookmark.description)
self.assertEqual(updated_bookmark.notes, bookmark.notes)
self.assertEqual(updated_bookmark.is_archived, bookmark.is_archived)
self.assertEqual(updated_bookmark.unread, bookmark.unread)
self.assertEqual(updated_bookmark.shared, bookmark.shared)
self.assertListEqual(updated_bookmark.tag_names, bookmark.tag_names)

def test_update_bookmark_unread_flag(self):
self.authenticate()
Expand Down Expand Up @@ -703,16 +744,42 @@ def test_patch_bookmark(self):
tag_names = [tag.name for tag in bookmark.tags.all()]
self.assertListEqual(tag_names, ["updated-tag-1", "updated-tag-2"])

def test_patch_with_empty_payload_does_not_modify_bookmark(self):
def test_patch_ignores_readonly_fields(self):
self.authenticate()
bookmark = self.setup_bookmark()

data = {
"web_archive_snapshot_url": "test",
"website_title": "test",
"website_description": "test",
}
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])
self.patch(url, data, expected_status_code=status.HTTP_200_OK)
updated_bookmark = Bookmark.objects.get(id=bookmark.id)
self.assertNotEqual(
data["web_archive_snapshot_url"], updated_bookmark.web_archive_snapshot_url
)
self.assertNotEqual(data["website_title"], updated_bookmark.website_title)
self.assertNotEqual(
data["website_description"], updated_bookmark.website_description
)

def test_patch_with_empty_payload_does_not_modify_bookmark(self):
self.authenticate()
bookmark = self.setup_bookmark(
is_archived=True, unread=True, shared=True, tags=[self.setup_tag()]
)

url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])
self.patch(url, {}, expected_status_code=status.HTTP_200_OK)
updated_bookmark = Bookmark.objects.get(id=bookmark.id)
self.assertEqual(updated_bookmark.url, bookmark.url)
self.assertEqual(updated_bookmark.title, bookmark.title)
self.assertEqual(updated_bookmark.description, bookmark.description)
self.assertEqual(updated_bookmark.notes, bookmark.notes)
self.assertEqual(updated_bookmark.is_archived, bookmark.is_archived)
self.assertEqual(updated_bookmark.unread, bookmark.unread)
self.assertEqual(updated_bookmark.shared, bookmark.shared)
self.assertListEqual(updated_bookmark.tag_names, bookmark.tag_names)

def test_patch_bookmark_adds_tags_from_auto_tagging(self):
Expand Down Expand Up @@ -919,6 +986,7 @@ def test_can_only_access_own_bookmarks(self):
{url: "https://example.com/"},
expected_status_code=status.HTTP_404_NOT_FOUND,
)
self.patch(url, expected_status_code=status.HTTP_404_NOT_FOUND)

url = reverse(
"bookmarks:bookmark-detail", args=[inaccessible_shared_bookmark.id]
Expand All @@ -928,6 +996,7 @@ def test_can_only_access_own_bookmarks(self):
{url: "https://example.com/"},
expected_status_code=status.HTTP_404_NOT_FOUND,
)
self.patch(url, expected_status_code=status.HTTP_404_NOT_FOUND)

url = reverse("bookmarks:bookmark-detail", args=[inaccessible_bookmark.id])
self.delete(url, expected_status_code=status.HTTP_404_NOT_FOUND)
Expand Down
20 changes: 20 additions & 0 deletions bookmarks/tests/test_bookmarks_api_permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,16 @@ def test_update_bookmark_requires_authentication(self):
self.authenticate()
self.put(url, data, expected_status_code=status.HTTP_200_OK)

def test_update_bookmark_only_updates_own_bookmarks(self):
self.authenticate()

other_user = self.setup_user()
bookmark = self.setup_bookmark(user=other_user)
data = {"url": "https://example.com/"}
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])

self.put(url, data, expected_status_code=status.HTTP_404_NOT_FOUND)

def test_patch_bookmark_requires_authentication(self):
bookmark = self.setup_bookmark()
data = {"url": "https://example.com"}
Expand All @@ -97,6 +107,16 @@ def test_patch_bookmark_requires_authentication(self):
self.authenticate()
self.patch(url, data, expected_status_code=status.HTTP_200_OK)

def test_patch_bookmark_only_updates_own_bookmarks(self):
self.authenticate()

other_user = self.setup_user()
bookmark = self.setup_bookmark(user=other_user)
data = {"url": "https://example.com"}
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])

self.patch(url, data, expected_status_code=status.HTTP_404_NOT_FOUND)

def test_delete_bookmark_requires_authentication(self):
bookmark = self.setup_bookmark()
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])
Expand Down
29 changes: 4 additions & 25 deletions docs/src/content/docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,34 +155,13 @@ Example payload:

```
PUT /api/bookmarks/<id>/
```

Updates a bookmark.
This is a full update, which requires at least a URL, and fields that are not specified are cleared or reset to their defaults.
Tags are simply assigned using their names.

Example payload:

```json
{
"url": "https://example.com",
"title": "Example title",
"description": "Example description",
"tag_names": [
"tag1",
"tag2"
]
}
```

**Patch**

```
PATCH /api/bookmarks/<id>/
```

Updates a bookmark partially.
Allows to modify individual fields of a bookmark.
Updates a bookmark.
When using `POST`, at least all required fields must be provided (currently only `url`).
When using `PATCH`, only the fields that should be updated need to be provided.
Regardless which method is used, any field that is not provided is not modified.
Tags are simply assigned using their names.

Example payload:
Expand Down

0 comments on commit d15c385

Please sign in to comment.