* #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:
parent
7fb73111b2
commit
e497bcb5c0
|
@ -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"
|
||||
}
|
||||
```
|
||||
|
|
@ -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 🙂.
|
||||
|
|
|
@ -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')
|
|
@ -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'])
|
|
@ -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),
|
||||
),
|
||||
]
|
|
@ -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)
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -51,6 +51,22 @@
|
|||
{% endif %}
|
||||
</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>
|
||||
|
||||
{% endblock %}
|
||||
|
|
|
@ -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))
|
||||
]
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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
|
||||
})
|
||||
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue