Skip to content

Commit 5cdd06d

Browse files
sampaccoudlebaudantoine
authored andcommitted
✨(backend) add server-to-server API endpoint to create documents
We want trusted external applications to be able to create documents via the API on behalf of any user. The user may or may not pre-exist in our database and should be notified of the document creation by email.
1 parent 47e23bf commit 5cdd06d

17 files changed

+1048
-194
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ and this project adheres to
99

1010
## [Unreleased]
1111

12+
## Added
13+
14+
- ✨(backend) add server-to-server API endpoint to create documents #467
15+
16+
1217
## [1.9.0] - 2024-12-11
1318

1419
## Added

src/backend/core/api/serializers.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,18 @@
44

55
from django.conf import settings
66
from django.db.models import Q
7+
from django.utils.functional import lazy
78
from django.utils.translation import gettext_lazy as _
89

910
import magic
1011
from rest_framework import exceptions, serializers
1112

1213
from core import enums, models
1314
from core.services.ai_services import AI_ACTIONS
15+
from core.services.converter_services import (
16+
ConversionError,
17+
YdocConverter,
18+
)
1419

1520

1621
class UserSerializer(serializers.ModelSerializer):
@@ -227,6 +232,96 @@ def validate_id(self, value):
227232
return value
228233

229234

235+
class ServerCreateDocumentSerializer(serializers.Serializer):
236+
"""
237+
Serializer for creating a document from a server-to-server request.
238+
239+
Expects 'content' as a markdown string, which is converted to our internal format
240+
via a Node.js microservice. The conversion is handled automatically, so third parties
241+
only need to provide markdown.
242+
243+
Both "sub" and "email" are required because the external app calling doesn't know
244+
if the user will pre-exist in Docs database. If the user pre-exist, we will ignore the
245+
submitted "email" field and use the email address set on the user account in our database
246+
"""
247+
248+
# Document
249+
title = serializers.CharField(required=True)
250+
content = serializers.CharField(required=True)
251+
# User
252+
sub = serializers.CharField(
253+
required=True, validators=[models.User.sub_validator], max_length=255
254+
)
255+
email = serializers.EmailField(required=True)
256+
language = serializers.ChoiceField(
257+
required=False, choices=lazy(lambda: settings.LANGUAGES, tuple)()
258+
)
259+
# Invitation
260+
message = serializers.CharField(required=False)
261+
subject = serializers.CharField(required=False)
262+
263+
def create(self, validated_data):
264+
"""Create the document and associate it with the user or send an invitation."""
265+
language = validated_data.get("language", settings.LANGUAGE_CODE)
266+
267+
# Get the user based on the sub (unique identifier)
268+
try:
269+
user = models.User.objects.get(sub=validated_data["sub"])
270+
except (models.User.DoesNotExist, KeyError):
271+
user = None
272+
email = validated_data["email"]
273+
else:
274+
email = user.email
275+
language = user.language or language
276+
277+
try:
278+
converter_response = YdocConverter().convert_markdown(
279+
validated_data["content"]
280+
)
281+
except ConversionError as err:
282+
raise exceptions.APIException(detail="could not convert content") from err
283+
284+
document = models.Document.objects.create(
285+
title=validated_data["title"],
286+
content=converter_response["content"],
287+
creator=user,
288+
)
289+
290+
if user:
291+
# Associate the document with the pre-existing user
292+
models.DocumentAccess.objects.create(
293+
document=document,
294+
role=models.RoleChoices.OWNER,
295+
user=user,
296+
)
297+
else:
298+
# The user doesn't exist in our database: we need to invite him/her
299+
models.Invitation.objects.create(
300+
document=document,
301+
email=email,
302+
role=models.RoleChoices.OWNER,
303+
)
304+
305+
# Notify the user about the newly created document
306+
subject = validated_data.get("subject") or _(
307+
"A new document was created on your behalf!"
308+
)
309+
context = {
310+
"message": validated_data.get("message")
311+
or _("You have been granted ownership of a new document:"),
312+
"title": subject,
313+
}
314+
document.send_email(subject, [email], context, language)
315+
316+
return document
317+
318+
def update(self, instance, validated_data):
319+
"""
320+
This serializer does not support updates.
321+
"""
322+
raise NotImplementedError("Update is not supported for this serializer.")
323+
324+
230325
class LinkDocumentSerializer(BaseResourceSerializer):
231326
"""
232327
Serialize link configuration for documents.

src/backend/core/api/viewsets.py

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,11 @@
2525
import rest_framework as drf
2626
from botocore.exceptions import ClientError
2727
from django_filters import rest_framework as drf_filters
28-
from rest_framework import filters
28+
from rest_framework import filters, status
29+
from rest_framework import response as drf_response
2930
from rest_framework.permissions import AllowAny
3031

31-
from core import enums, models
32+
from core import authentication, enums, models
3233
from core.services.ai_services import AIService
3334
from core.services.collaboration_services import CollaborationService
3435

@@ -430,6 +431,30 @@ def perform_create(self, serializer):
430431
role=models.RoleChoices.OWNER,
431432
)
432433

434+
@drf.decorators.action(
435+
authentication_classes=[authentication.ServerToServerAuthentication],
436+
detail=False,
437+
methods=["post"],
438+
permission_classes=[],
439+
url_path="create-for-owner",
440+
)
441+
def create_for_owner(self, request):
442+
"""
443+
Create a document on behalf of a specified owner (pre-existing user or invited).
444+
"""
445+
# Deserialize and validate the data
446+
serializer = serializers.ServerCreateDocumentSerializer(data=request.data)
447+
if not serializer.is_valid():
448+
return drf_response.Response(
449+
serializer.errors, status=status.HTTP_400_BAD_REQUEST
450+
)
451+
452+
document = serializer.save()
453+
454+
return drf_response.Response(
455+
{"id": str(document.id)}, status=status.HTTP_201_CREATED
456+
)
457+
433458
@drf.decorators.action(detail=True, methods=["get"], url_path="versions")
434459
def versions_list(self, request, *args, **kwargs):
435460
"""
@@ -813,11 +838,11 @@ def perform_create(self, serializer):
813838
access = serializer.save()
814839
language = self.request.headers.get("Content-Language", "en-us")
815840

816-
access.document.email_invitation(
817-
language,
841+
access.document.send_invitation_email(
818842
access.user.email,
819843
access.role,
820844
self.request.user,
845+
language,
821846
)
822847

823848
def perform_update(self, serializer):
@@ -1078,8 +1103,8 @@ def perform_create(self, serializer):
10781103

10791104
language = self.request.headers.get("Content-Language", "en-us")
10801105

1081-
invitation.document.email_invitation(
1082-
language, invitation.email, invitation.role, self.request.user
1106+
invitation.document.send_invitation_email(
1107+
invitation.email, invitation.role, self.request.user, language
10831108
)
10841109

10851110

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
"""Custom authentication classes for the Impress core app"""
2+
3+
from django.conf import settings
4+
5+
from rest_framework.authentication import BaseAuthentication
6+
from rest_framework.exceptions import AuthenticationFailed
7+
8+
9+
class ServerToServerAuthentication(BaseAuthentication):
10+
"""
11+
Custom authentication class for server-to-server requests.
12+
Validates the presence and correctness of the Authorization header.
13+
"""
14+
15+
AUTH_HEADER = "Authorization"
16+
TOKEN_TYPE = "Bearer" # noqa S105
17+
18+
def authenticate(self, request):
19+
"""
20+
Authenticate the server-to-server request by validating the Authorization header.
21+
22+
This method checks if the Authorization header is present in the request, ensures it
23+
contains a valid token with the correct format, and verifies the token against the
24+
list of allowed server-to-server tokens. If the header is missing, improperly formatted,
25+
or contains an invalid token, an AuthenticationFailed exception is raised.
26+
27+
Returns:
28+
None: If authentication is successful
29+
(no user is authenticated for server-to-server requests).
30+
31+
Raises:
32+
AuthenticationFailed: If the Authorization header is missing, malformed,
33+
or contains an invalid token.
34+
"""
35+
auth_header = request.headers.get(self.AUTH_HEADER)
36+
if not auth_header:
37+
raise AuthenticationFailed("Authorization header is missing.")
38+
39+
# Validate token format and existence
40+
auth_parts = auth_header.split(" ")
41+
if len(auth_parts) != 2 or auth_parts[0] != self.TOKEN_TYPE:
42+
raise AuthenticationFailed("Invalid authorization header.")
43+
44+
token = auth_parts[1]
45+
if token not in settings.SERVER_TO_SERVER_API_TOKENS:
46+
raise AuthenticationFailed("Invalid server-to-server token.")
47+
48+
# Authentication is successful, but no user is authenticated
49+
50+
def authenticate_header(self, request):
51+
"""Return the WWW-Authenticate header value."""
52+
return f"{self.TOKEN_TYPE} realm='Create document server to server'"
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# Generated by Django 5.1.2 on 2024-11-30 22:23
2+
3+
import django.db.models.deletion
4+
from django.conf import settings
5+
from django.db import migrations, models
6+
7+
8+
class Migration(migrations.Migration):
9+
10+
dependencies = [
11+
('core', '0011_populate_creator_field_and_make_it_required'),
12+
]
13+
14+
operations = [
15+
migrations.AlterField(
16+
model_name='document',
17+
name='creator',
18+
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='documents_created', to=settings.AUTH_USER_MODEL),
19+
),
20+
migrations.AlterField(
21+
model_name='invitation',
22+
name='issuer',
23+
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='invitations', to=settings.AUTH_USER_MODEL),
24+
),
25+
migrations.AlterField(
26+
model_name='user',
27+
name='language',
28+
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'), ('de-de', 'German'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
29+
),
30+
]

0 commit comments

Comments
 (0)