From e497bcb5c0906b32bddaa2b75842f84612104548 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sascha=20I=C3=9Fbr=C3=BCcker?= Date: Sun, 27 Sep 2020 09:34:56 +0200 Subject: [PATCH] #24 Implement REST API (#32) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * #24 Implement readonly bookmark API * #24 Implement create/update bookmark API * #24 Fix title, description not allowing blank values * #24 Code cleanup * #24 Add modification dates to response * #24 Add API docs * #24 Implement delete bookmark API * #24 Fix API docs link * #24 Fix API docs link * #24 Implement tag API Co-authored-by: Sascha Ißbrücker --- API.md | 176 ++++++++++++++++++ README.md | 4 + bookmarks/api/__init__.py | 0 bookmarks/api/routes.py | 47 +++++ bookmarks/api/serializers.py | 44 +++++ .../migrations/0004_auto_20200926_1028.py | 23 +++ bookmarks/models.py | 4 +- bookmarks/services/bookmarks.py | 26 +-- bookmarks/templates/settings/index.html | 16 ++ bookmarks/urls.py | 4 +- bookmarks/views/bookmarks.py | 4 +- bookmarks/views/settings.py | 3 + requirements.prod.txt | 1 + requirements.txt | 1 + siteroot/settings/base.py | 12 ++ 15 files changed, 349 insertions(+), 16 deletions(-) create mode 100644 API.md create mode 100644 bookmarks/api/__init__.py create mode 100644 bookmarks/api/routes.py create mode 100644 bookmarks/api/serializers.py create mode 100644 bookmarks/migrations/0004_auto_20200926_1028.py diff --git a/API.md b/API.md new file mode 100644 index 0000000..70a59e4 --- /dev/null +++ b/API.md @@ -0,0 +1,176 @@ +# API + +The application provides a REST API that can be used by 3rd party applications to manage bookmarks. + +## Authentication + +All requests against the API must be authorized using an authorization token. The application automatically generates an API token for each user, which can be accessed through the *Settings* page. + +The token needs to be passed as `Authorization` header in the HTTP request: + +``` +Authorization: Token +``` + +## Resources + +The following resources are available: + +### Bookmarks + +**List** + +``` +GET /api/bookmarks/ +``` + +List bookmarks. + +Parameters: + +- `q` - Filters results using a search phrase using the same logic as through the UI +- `limit` - Limits the max. number of results. Default is `100`. +- `offset` - Index from which to start returning results + +Example response: + +```json +{ + "count": 123, + "next": "http://127.0.0.1:8000/api/bookmarks/?limit=100&offset=100", + "previous": null, + "results": [ + { + "id": 1, + "url": "https://example.com", + "title": "Example title", + "description": "Example description", + "tag_names": [ + "tag1", + "tag2" + ], + "date_added": "2020-09-26T09:46:23.006313Z", + "date_modified": "2020-09-26T16:01:14.275335Z" + }, + ... + ] +} +``` + +**Retrieve** + +``` +GET /api/bookmarks// +``` + +Retrieves a single bookmark by ID. + +**Create** + +``` +POST /api/bookmarks/ +``` + +Creates a new bookmark. Tags are simply assigned using their names. + +Example payload: + +```json +{ + "url": "https://example.com", + "title": "Example title", + "description": "Example description", + "tag_names": [ + "tag1", + "tag2" + ] +} +``` + +**Update** + +``` +PUT /api/bookmarks// +``` + +Updates a bookmark. Tags are simply assigned using their names. + +Example payload: + +```json +{ + "url": "https://example.com", + "title": "Example title", + "description": "Example description", + "tag_names": [ + "tag1", + "tag2" + ] +} +``` + +**Delete** + +``` +DELETE /api/bookmarks// +``` + +Deletes a bookmark by ID. + +### Tags + +**List** + +``` +GET /api/tags/ +``` + +List tags. + +Parameters: + +- `limit` - Limits the max. number of results. Default is `100`. +- `offset` - Index from which to start returning results + +Example response: + +```json +{ + "count": 123, + "next": "http://127.0.0.1:8000/api/tags/?limit=100&offset=100", + "previous": null, + "results": [ + { + "id": 1, + "name": "example", + "date_added": "2020-09-26T09:46:23.006313Z" + }, + ... + ] +} +``` + +**Retrieve** + +``` +GET /api/tags// +``` + +Retrieves a single tag by ID. + +**Create** + +``` +POST /api/tags/ +``` + +Creates a new tag. + +Example payload: + +```json +{ + "name": "example" +} +``` + diff --git a/README.md b/README.md index 6362c10..891043f 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,10 @@ For manual backups you can export your bookmarks from the UI and store them on a For automatic backups you want to backup the applications database. As described above, for production setups you should [mount](https://stackoverflow.com/questions/23439126/how-to-mount-a-host-directory-in-a-docker-container) the `/etc/linkding/data` directory from the Docker container to a directory on your host system. You can then use a backup tool of your choice to backup the contents of that directory. +## API + +The application provides a REST API that can be used by 3rd party applications to manage bookmarks. Check the [API docs](API.md) for further information. + ## Development The application is open source, so you are free to modify or contribute. The application is built using the Django web framework. You can get started by checking out the excellent Django docs: https://docs.djangoproject.com/en/3.0/. The `bookmarks` folder contains the actual bookmark application, `siteroot` is the Django root application. Other than that the code should be self-explanatory / standard Django stuff 🙂. diff --git a/bookmarks/api/__init__.py b/bookmarks/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bookmarks/api/routes.py b/bookmarks/api/routes.py new file mode 100644 index 0000000..696ddaa --- /dev/null +++ b/bookmarks/api/routes.py @@ -0,0 +1,47 @@ +from rest_framework import viewsets, mixins +from rest_framework.routers import DefaultRouter + +from bookmarks import queries +from bookmarks.api.serializers import BookmarkSerializer, TagSerializer +from bookmarks.models import Bookmark, Tag + + +class BookmarkViewSet(viewsets.GenericViewSet, + mixins.ListModelMixin, + mixins.RetrieveModelMixin, + mixins.CreateModelMixin, + mixins.UpdateModelMixin, + mixins.DestroyModelMixin): + serializer_class = BookmarkSerializer + + def get_queryset(self): + user = self.request.user + # For list action, use query set that applies search and tag projections + if self.action == 'list': + query_string = self.request.GET.get('q') + return queries.query_bookmarks(user, query_string) + + # For single entity actions use default query set without projections + return Bookmark.objects.all().filter(owner=user) + + def get_serializer_context(self): + return {'user': self.request.user} + + +class TagViewSet(viewsets.GenericViewSet, + mixins.ListModelMixin, + mixins.RetrieveModelMixin, + mixins.CreateModelMixin): + serializer_class = TagSerializer + + def get_queryset(self): + user = self.request.user + return Tag.objects.all().filter(owner=user) + + def get_serializer_context(self): + return {'user': self.request.user} + + +router = DefaultRouter() +router.register(r'bookmarks', BookmarkViewSet, basename='bookmark') +router.register(r'tags', TagViewSet, basename='tag') diff --git a/bookmarks/api/serializers.py b/bookmarks/api/serializers.py new file mode 100644 index 0000000..d867706 --- /dev/null +++ b/bookmarks/api/serializers.py @@ -0,0 +1,44 @@ +from rest_framework import serializers + +from bookmarks.models import Bookmark, Tag, build_tag_string +from bookmarks.services.bookmarks import create_bookmark, update_bookmark +from bookmarks.services.tags import get_or_create_tag + + +class TagListField(serializers.ListField): + child = serializers.CharField() + + +class BookmarkSerializer(serializers.ModelSerializer): + class Meta: + model = Bookmark + fields = ['id', 'url', 'title', 'description', 'tag_names', 'date_added', 'date_modified'] + read_only_fields = ['date_added', 'date_modified'] + + # Override readonly tag_names property to allow passing a list of tag names to create/update + tag_names = TagListField() + + def create(self, validated_data): + bookmark = Bookmark() + bookmark.url = validated_data['url'] + bookmark.title = validated_data['title'] + bookmark.description = validated_data['description'] + tag_string = build_tag_string(validated_data['tag_names'], ' ') + return create_bookmark(bookmark, tag_string, self.context['user']) + + def update(self, instance: Bookmark, validated_data): + instance.url = validated_data['url'] + instance.title = validated_data['title'] + instance.description = validated_data['description'] + tag_string = build_tag_string(validated_data['tag_names'], ' ') + return update_bookmark(instance, tag_string, self.context['user']) + + +class TagSerializer(serializers.ModelSerializer): + class Meta: + model = Tag + fields = ['id', 'name', 'date_added'] + read_only_fields = ['date_added'] + + def create(self, validated_data): + return get_or_create_tag(validated_data['name'], self.context['user']) diff --git a/bookmarks/migrations/0004_auto_20200926_1028.py b/bookmarks/migrations/0004_auto_20200926_1028.py new file mode 100644 index 0000000..1636b0c --- /dev/null +++ b/bookmarks/migrations/0004_auto_20200926_1028.py @@ -0,0 +1,23 @@ +# Generated by Django 2.2.13 on 2020-09-26 10:28 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('bookmarks', '0003_auto_20200913_0656'), + ] + + operations = [ + migrations.AlterField( + model_name='bookmark', + name='description', + field=models.TextField(blank=True), + ), + migrations.AlterField( + model_name='bookmark', + name='title', + field=models.CharField(blank=True, max_length=512), + ), + ] diff --git a/bookmarks/models.py b/bookmarks/models.py index 3906572..7e00435 100644 --- a/bookmarks/models.py +++ b/bookmarks/models.py @@ -30,8 +30,8 @@ def build_tag_string(tag_names: List[str], delimiter: str = ','): class Bookmark(models.Model): url = models.URLField(max_length=2048) - title = models.CharField(max_length=512) - description = models.TextField() + title = models.CharField(max_length=512, blank=True) + description = models.TextField(blank=True) website_title = models.CharField(max_length=512, blank=True, null=True) website_description = models.TextField(blank=True, null=True) unread = models.BooleanField(default=True) diff --git a/bookmarks/services/bookmarks.py b/bookmarks/services/bookmarks.py index 441620b..72e7d19 100644 --- a/bookmarks/services/bookmarks.py +++ b/bookmarks/services/bookmarks.py @@ -1,21 +1,19 @@ from django.contrib.auth.models import User from django.utils import timezone -from bookmarks.models import Bookmark, BookmarkForm, parse_tag_string +from bookmarks.models import Bookmark, parse_tag_string from bookmarks.services.tags import get_or_create_tags from bookmarks.services.website_loader import load_website_metadata -def create_bookmark(form: BookmarkForm, current_user: User): +def create_bookmark(bookmark: Bookmark, tag_string: str, current_user: User): # If URL is already bookmarked, then update it - existing_bookmark = Bookmark.objects.filter(owner=current_user, url=form.data['url']).first() + existing_bookmark: Bookmark = Bookmark.objects.filter(owner=current_user, url=bookmark.url).first() if existing_bookmark is not None: - update_form = BookmarkForm(data=form.data, instance=existing_bookmark) - update_bookmark(update_form, current_user) - return + _merge_bookmark_data(bookmark, existing_bookmark) + return update_bookmark(existing_bookmark, tag_string, current_user) - bookmark = form.save(commit=False) # Update website info _update_website_metadata(bookmark) # Set currently logged in user as owner @@ -25,19 +23,25 @@ def create_bookmark(form: BookmarkForm, current_user: User): bookmark.date_modified = timezone.now() bookmark.save() # Update tag list - _update_bookmark_tags(bookmark, form.data['tag_string'], current_user) + _update_bookmark_tags(bookmark, tag_string, current_user) bookmark.save() + return bookmark -def update_bookmark(form: BookmarkForm, current_user: User): - bookmark = form.save(commit=False) +def update_bookmark(bookmark: Bookmark, tag_string, current_user: User): # Update website info _update_website_metadata(bookmark) # Update tag list - _update_bookmark_tags(bookmark, form.data['tag_string'], current_user) + _update_bookmark_tags(bookmark, tag_string, current_user) # Update dates bookmark.date_modified = timezone.now() bookmark.save() + return bookmark + + +def _merge_bookmark_data(from_bookmark: Bookmark, to_bookmark: Bookmark): + to_bookmark.title = from_bookmark.title + to_bookmark.description = from_bookmark.description def _update_website_metadata(bookmark: Bookmark): diff --git a/bookmarks/templates/settings/index.html b/bookmarks/templates/settings/index.html index b6f430f..58e846a 100644 --- a/bookmarks/templates/settings/index.html +++ b/bookmarks/templates/settings/index.html @@ -51,6 +51,22 @@ {% endif %} + {# API token section #} +
+
+

API Token

+
+

The following token can be used to authenticate 3rd-party applications against the REST API:

+
+
+
+ +
+
+
+

Please treat this token as you would any other credential. Any party with access to this token can access and manage all your bookmarks.

+
+ {% endblock %} diff --git a/bookmarks/urls.py b/bookmarks/urls.py index ef265a3..f0cc8eb 100644 --- a/bookmarks/urls.py +++ b/bookmarks/urls.py @@ -1,7 +1,8 @@ from django.conf.urls import url -from django.urls import path +from django.urls import path, include from django.views.generic import RedirectView +from bookmarks.api.routes import router from bookmarks import views app_name = 'bookmarks' @@ -21,4 +22,5 @@ urlpatterns = [ path('settings/export', views.settings.bookmark_export, name='settings.export'), # API path('api/check_url', views.api.check_url, name='api.check_url'), + url(r'^api/', include(router.urls)) ] diff --git a/bookmarks/views/bookmarks.py b/bookmarks/views/bookmarks.py index 1a43a9a..08b60c5 100644 --- a/bookmarks/views/bookmarks.py +++ b/bookmarks/views/bookmarks.py @@ -61,7 +61,7 @@ def new(request): auto_close = form.data['auto_close'] if form.is_valid(): current_user = request.user - create_bookmark(form, current_user) + create_bookmark(form.save(commit=False), form.data['tag_string'], current_user) if auto_close: return HttpResponseRedirect(reverse('bookmarks:close')) else: @@ -92,7 +92,7 @@ def edit(request, bookmark_id: int): form = BookmarkForm(request.POST, instance=bookmark) return_url = form.data['return_url'] if form.is_valid(): - update_bookmark(form, request.user) + update_bookmark(form.save(commit=False), form.data['tag_string'], request.user) return HttpResponseRedirect(return_url) else: return_url = request.GET.get('return_url') diff --git a/bookmarks/views/settings.py b/bookmarks/views/settings.py index 2e9c59d..a8066ef 100644 --- a/bookmarks/views/settings.py +++ b/bookmarks/views/settings.py @@ -5,6 +5,7 @@ from django.contrib.auth.decorators import login_required from django.http import HttpResponseRedirect, HttpResponse from django.shortcuts import render from django.urls import reverse +from rest_framework.authtoken.models import Token from bookmarks.queries import query_bookmarks from bookmarks.services.exporter import export_netscape_html @@ -17,9 +18,11 @@ logger = logging.getLogger(__name__) def index(request): import_success_message = _find_message_with_tag(messages.get_messages(request), 'bookmark_import_success') import_errors_message = _find_message_with_tag(messages.get_messages(request), 'bookmark_import_errors') + api_token = Token.objects.get_or_create(user=request.user)[0] return render(request, 'settings/index.html', { 'import_success_message': import_success_message, 'import_errors_message': import_errors_message, + 'api_token': api_token.key }) diff --git a/requirements.prod.txt b/requirements.prod.txt index 8f4f45e..8821e4d 100644 --- a/requirements.prod.txt +++ b/requirements.prod.txt @@ -9,6 +9,7 @@ django-picklefield==2.0 django-registration==3.0.1 django-sass-processor==0.7.3 django-widget-tweaks==1.4.5 +djangorestframework==3.11.1 idna==2.8 pytz==2019.1 rcssmin==1.0.6 diff --git a/requirements.txt b/requirements.txt index 9e99cb6..d3e5d16 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,6 +10,7 @@ django-picklefield==2.0 django-registration==3.0.1 django-sass-processor==0.7.3 django-widget-tweaks==1.4.5 +djangorestframework==3.11.1 idna==2.8 libsass==0.19.2 pytz==2019.1 diff --git a/siteroot/settings/base.py b/siteroot/settings/base.py index ee3519b..535c667 100644 --- a/siteroot/settings/base.py +++ b/siteroot/settings/base.py @@ -40,6 +40,8 @@ INSTALLED_APPS = [ 'sass_processor', 'widget_tweaks', 'django_generate_secret_key', + 'rest_framework', + 'rest_framework.authtoken' ] MIDDLEWARE = [ @@ -143,5 +145,15 @@ STATICFILES_DIRS = [ os.path.join(BASE_DIR, 'bookmarks', 'styles'), ] +# REST framework +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': [ + 'rest_framework.authentication.TokenAuthentication', + 'rest_framework.authentication.SessionAuthentication', + ], + 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination', + 'PAGE_SIZE': 100 +} + # Registration switch ALLOW_REGISTRATION = False