#24 Implement REST API (#32)

* #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 <sissbruecker@lyska.io>
This commit is contained in:
Sascha Ißbrücker 2020-09-27 09:34:56 +02:00 committed by GitHub
parent 7fb73111b2
commit e497bcb5c0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 349 additions and 16 deletions

176
API.md Normal file
View File

@ -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 <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/<id>/
```
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/<id>/
```
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/<id>/
```
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/<id>/
```
Retrieves a single tag by ID.
**Create**
```
POST /api/tags/
```
Creates a new tag.
Example payload:
```json
{
"name": "example"
}
```

View File

@ -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. 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 ## 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 🙂. 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 🙂.

View File

47
bookmarks/api/routes.py Normal file
View File

@ -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')

View File

@ -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'])

View File

@ -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),
),
]

View File

@ -30,8 +30,8 @@ def build_tag_string(tag_names: List[str], delimiter: str = ','):
class Bookmark(models.Model): class Bookmark(models.Model):
url = models.URLField(max_length=2048) url = models.URLField(max_length=2048)
title = models.CharField(max_length=512) title = models.CharField(max_length=512, blank=True)
description = models.TextField() description = models.TextField(blank=True)
website_title = models.CharField(max_length=512, blank=True, null=True) website_title = models.CharField(max_length=512, blank=True, null=True)
website_description = models.TextField(blank=True, null=True) website_description = models.TextField(blank=True, null=True)
unread = models.BooleanField(default=True) unread = models.BooleanField(default=True)

View File

@ -1,21 +1,19 @@
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.utils import timezone 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.tags import get_or_create_tags
from bookmarks.services.website_loader import load_website_metadata 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 # 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: if existing_bookmark is not None:
update_form = BookmarkForm(data=form.data, instance=existing_bookmark) _merge_bookmark_data(bookmark, existing_bookmark)
update_bookmark(update_form, current_user) return update_bookmark(existing_bookmark, tag_string, current_user)
return
bookmark = form.save(commit=False)
# Update website info # Update website info
_update_website_metadata(bookmark) _update_website_metadata(bookmark)
# Set currently logged in user as owner # 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.date_modified = timezone.now()
bookmark.save() bookmark.save()
# Update tag list # Update tag list
_update_bookmark_tags(bookmark, form.data['tag_string'], current_user) _update_bookmark_tags(bookmark, tag_string, current_user)
bookmark.save() bookmark.save()
return bookmark
def update_bookmark(form: BookmarkForm, current_user: User): def update_bookmark(bookmark: Bookmark, tag_string, current_user: User):
bookmark = form.save(commit=False)
# Update website info # Update website info
_update_website_metadata(bookmark) _update_website_metadata(bookmark)
# Update tag list # Update tag list
_update_bookmark_tags(bookmark, form.data['tag_string'], current_user) _update_bookmark_tags(bookmark, tag_string, current_user)
# Update dates # Update dates
bookmark.date_modified = timezone.now() bookmark.date_modified = timezone.now()
bookmark.save() 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): def _update_website_metadata(bookmark: Bookmark):

View File

@ -51,6 +51,22 @@
{% endif %} {% endif %}
</section> </section>
{# API token section #}
<section class="content-area">
<div class="content-area-header">
<h2>API Token</h2>
</div>
<p>The following token can be used to authenticate 3rd-party applications against the REST API:</p>
<div class="form-group">
<div class="columns">
<div class="column col-6 col-md-12">
<input class="form-input" value="{{ api_token }}" disabled>
</div>
</div>
</div>
<p><strong>Please treat this token as you would any other credential.</strong> Any party with access to this token can access and manage all your bookmarks.</p>
</section>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -1,7 +1,8 @@
from django.conf.urls import url from django.conf.urls import url
from django.urls import path from django.urls import path, include
from django.views.generic import RedirectView from django.views.generic import RedirectView
from bookmarks.api.routes import router
from bookmarks import views from bookmarks import views
app_name = 'bookmarks' app_name = 'bookmarks'
@ -21,4 +22,5 @@ urlpatterns = [
path('settings/export', views.settings.bookmark_export, name='settings.export'), path('settings/export', views.settings.bookmark_export, name='settings.export'),
# API # API
path('api/check_url', views.api.check_url, name='api.check_url'), path('api/check_url', views.api.check_url, name='api.check_url'),
url(r'^api/', include(router.urls))
] ]

View File

@ -61,7 +61,7 @@ def new(request):
auto_close = form.data['auto_close'] auto_close = form.data['auto_close']
if form.is_valid(): if form.is_valid():
current_user = request.user current_user = request.user
create_bookmark(form, current_user) create_bookmark(form.save(commit=False), form.data['tag_string'], current_user)
if auto_close: if auto_close:
return HttpResponseRedirect(reverse('bookmarks:close')) return HttpResponseRedirect(reverse('bookmarks:close'))
else: else:
@ -92,7 +92,7 @@ def edit(request, bookmark_id: int):
form = BookmarkForm(request.POST, instance=bookmark) form = BookmarkForm(request.POST, instance=bookmark)
return_url = form.data['return_url'] return_url = form.data['return_url']
if form.is_valid(): 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) return HttpResponseRedirect(return_url)
else: else:
return_url = request.GET.get('return_url') return_url = request.GET.get('return_url')

View File

@ -5,6 +5,7 @@ from django.contrib.auth.decorators import login_required
from django.http import HttpResponseRedirect, HttpResponse from django.http import HttpResponseRedirect, HttpResponse
from django.shortcuts import render from django.shortcuts import render
from django.urls import reverse from django.urls import reverse
from rest_framework.authtoken.models import Token
from bookmarks.queries import query_bookmarks from bookmarks.queries import query_bookmarks
from bookmarks.services.exporter import export_netscape_html from bookmarks.services.exporter import export_netscape_html
@ -17,9 +18,11 @@ logger = logging.getLogger(__name__)
def index(request): def index(request):
import_success_message = _find_message_with_tag(messages.get_messages(request), 'bookmark_import_success') 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') 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', { return render(request, 'settings/index.html', {
'import_success_message': import_success_message, 'import_success_message': import_success_message,
'import_errors_message': import_errors_message, 'import_errors_message': import_errors_message,
'api_token': api_token.key
}) })

View File

@ -9,6 +9,7 @@ django-picklefield==2.0
django-registration==3.0.1 django-registration==3.0.1
django-sass-processor==0.7.3 django-sass-processor==0.7.3
django-widget-tweaks==1.4.5 django-widget-tweaks==1.4.5
djangorestframework==3.11.1
idna==2.8 idna==2.8
pytz==2019.1 pytz==2019.1
rcssmin==1.0.6 rcssmin==1.0.6

View File

@ -10,6 +10,7 @@ django-picklefield==2.0
django-registration==3.0.1 django-registration==3.0.1
django-sass-processor==0.7.3 django-sass-processor==0.7.3
django-widget-tweaks==1.4.5 django-widget-tweaks==1.4.5
djangorestframework==3.11.1
idna==2.8 idna==2.8
libsass==0.19.2 libsass==0.19.2
pytz==2019.1 pytz==2019.1

View File

@ -40,6 +40,8 @@ INSTALLED_APPS = [
'sass_processor', 'sass_processor',
'widget_tweaks', 'widget_tweaks',
'django_generate_secret_key', 'django_generate_secret_key',
'rest_framework',
'rest_framework.authtoken'
] ]
MIDDLEWARE = [ MIDDLEWARE = [
@ -143,5 +145,15 @@ STATICFILES_DIRS = [
os.path.join(BASE_DIR, 'bookmarks', 'styles'), 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 # Registration switch
ALLOW_REGISTRATION = False ALLOW_REGISTRATION = False