From 7fcb210aa4249cf3080c04f2d2ae17e6ae25e77d Mon Sep 17 00:00:00 2001 From: Julien Enselme Date: Sun, 24 Nov 2024 00:21:24 +0100 Subject: [PATCH] feat(api): make API async See #318 --- config/settings.py | 1 + legadilo/reading/api.py | 12 ++++++------ legadilo/users/api.py | 16 ++++++++-------- legadilo/utils/collections_utils.py | 6 +----- legadilo/utils/pagination.py | 24 ++++++++++++++++++++++++ 5 files changed, 40 insertions(+), 19 deletions(-) diff --git a/config/settings.py b/config/settings.py index de558205..3690d7af 100644 --- a/config/settings.py +++ b/config/settings.py @@ -613,6 +613,7 @@ def before_send_to_sentry(event, hint): # ------------------------------------------------------------------------------ # See https://django-ninja.dev/reference/settings/ NINJA_PAGINATION_MAX_LIMIT = 500 +NINJA_PAGINATION_CLASS = "legadilo.utils.pagination.LimitOffsetPagination" # Your stuff... diff --git a/legadilo/reading/api.py b/legadilo/reading/api.py index ce4d84e0..4a562883 100644 --- a/legadilo/reading/api.py +++ b/legadilo/reading/api.py @@ -16,7 +16,7 @@ from operator import xor from typing import Annotated, Self -from asgiref.sync import async_to_sync +from asgiref.sync import sync_to_async from ninja import ModelSchema, Router, Schema from ninja.pagination import paginate from pydantic import Field, model_validator @@ -59,25 +59,25 @@ def has_data(self) -> bool: @reading_api_router.get("/articles/", response=list[OutArticleSchema]) @paginate -def list_articles(request: AuthenticateApiRequest): +async def list_articles(request: AuthenticateApiRequest): # noqa: RUF029 pagination is async! return Article.objects.get_queryset().for_user(request.auth).default_order_by() @reading_api_router.post("/articles/", response=OutArticleSchema) -def create_article(request: AuthenticateApiRequest, article: InArticleSchema): +async def create_article(request: AuthenticateApiRequest, article: InArticleSchema): if article.has_data: article_data = build_article_data_from_content( url=article.link, title=article.title, content=article.content ) else: - article_data = async_to_sync(get_article_from_url)(article.link) + article_data = await get_article_from_url(article.link) # Tags specified in article data are the raw tags used in feeds, they are not used to link an # article to tag objects. - tags = Tag.objects.get_or_create_from_list(request.auth, article.tags) + tags = await sync_to_async(Tag.objects.get_or_create_from_list)(request.auth, article.tags) article_data = article_data.model_copy(update={"tags": ()}) - articles = Article.objects.update_or_create_from_articles_list( + articles = await sync_to_async(Article.objects.update_or_create_from_articles_list)( request.auth, [article_data], tags, source_type=constants.ArticleSourceType.MANUAL ) return articles[0] diff --git a/legadilo/users/api.py b/legadilo/users/api.py index 2694924c..a6a53627 100644 --- a/legadilo/users/api.py +++ b/legadilo/users/api.py @@ -19,7 +19,7 @@ import jwt from django.core.exceptions import BadRequest from django.http import HttpRequest -from django.shortcuts import get_object_or_404 +from django.shortcuts import aget_object_or_404 from ninja import Router, Schema from ninja.security import HttpBearer from pydantic import BaseModel as BaseSchema @@ -35,12 +35,12 @@ class AuthBearer(HttpBearer): - def authenticate(self, request, token) -> User | None: + async def authenticate(self, request, token) -> User | None: if not token: return None decoded_jwt = _decode_jwt(token) - return _get_user_from_jwt(decoded_jwt) + return await _get_user_from_jwt(decoded_jwt) class JWT(BaseSchema): @@ -59,9 +59,9 @@ def _decode_jwt(token: str) -> JWT: raise BadRequest("Invalid JWT token") from e -def _get_user_from_jwt(decoded_jwt: JWT) -> User | None: +async def _get_user_from_jwt(decoded_jwt: JWT) -> User | None: try: - return User.objects.get(id=decoded_jwt.user_id) + return await User.objects.aget(id=decoded_jwt.user_id) except User.DoesNotExist: return None @@ -75,13 +75,13 @@ class Token(Schema): @users_api_router.post("/refresh/", auth=None, response=Token) -def refresh_token(request: HttpRequest, payload: RefreshTokenPayload) -> Token: - application_token = get_object_or_404( +async def refresh_token(request: HttpRequest, payload: RefreshTokenPayload) -> Token: + application_token = await aget_object_or_404( ApplicationToken.objects.get_queryset().only_valid().defer(None), token=payload.application_token, ) application_token.last_used_at = utcnow() - application_token.save() + await application_token.asave() jwt = _create_jwt(application_token.user_id, application_token.token) return Token(jwt=jwt) diff --git a/legadilo/utils/collections_utils.py b/legadilo/utils/collections_utils.py index d1290adf..7d999e0f 100644 --- a/legadilo/utils/collections_utils.py +++ b/legadilo/utils/collections_utils.py @@ -55,11 +55,7 @@ def max_or_none( async def alist(collection: AsyncIterable[T]) -> list[T]: - output = [] - async for item in collection: - output.append(item) - - return output + return [item async for item in collection] async def aset(collection: AsyncIterable[T]) -> set[T]: diff --git a/legadilo/utils/pagination.py b/legadilo/utils/pagination.py index 37dd2f6d..0de30752 100644 --- a/legadilo/utils/pagination.py +++ b/legadilo/utils/pagination.py @@ -13,8 +13,13 @@ # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +from typing import Any from django.core.paginator import Page, Paginator +from django.db.models import QuerySet +from ninja.pagination import LimitOffsetPagination as NinjaLimitOffsetPagination + +from legadilo.utils.collections_utils import alist def get_requested_page(paginator: Paginator, requested_page: int) -> Page: @@ -23,3 +28,22 @@ def get_requested_page(paginator: Paginator, requested_page: int) -> Page: if 1 <= requested_page <= paginator.num_pages else paginator.page(1) ) + + +class LimitOffsetPagination(NinjaLimitOffsetPagination): + """Custom paginator to fix a bug in Ninja pagination. + + There is a bug in Ninja when we try to paginate querysets in async context: we will get a + SynchronousOnlyOperation error. This should be solved "soon" with + https://github.com/vitalik/django-ninja/pull/1340 + """ + + async def apaginate_queryset( + self, + queryset: QuerySet, + pagination: Any, + **params: Any, + ) -> Any: + result = await super().apaginate_queryset(queryset, pagination, **params) + result["items"] = await alist(result["items"]) + return result