Skip to content

Commit

Permalink
✨ Pagination now works for schemaless responses
Browse files Browse the repository at this point in the history
  • Loading branch information
perdy committed May 14, 2020
1 parent 6d15cee commit 46473ae
Show file tree
Hide file tree
Showing 8 changed files with 177 additions and 164 deletions.
5 changes: 3 additions & 2 deletions flama/pagination/__init__.py
@@ -1,3 +1,4 @@
from flama.pagination.paginator import Paginator
from flama.pagination.limit_offset import limit_offset
from flama.pagination.page_number import page_number

__all__ = ["Paginator"]
__all__ = ["limit_offset", "page_number"]
69 changes: 68 additions & 1 deletion flama/pagination/limit_offset.py
@@ -1,10 +1,18 @@
import asyncio
import functools
import typing

import marshmallow

from flama.responses import APIResponse
from flama.validation import get_output_schema

__all__ = ["LimitOffsetSchema", "LimitOffsetResponse"]
try:
import forge
except Exception: # pragma: no cover
forge = None # type: ignore

__all__ = ["LimitOffsetSchema", "LimitOffsetResponse", "limit_offset"]


class LimitOffsetMeta(marshmallow.Schema):
Expand Down Expand Up @@ -52,3 +60,62 @@ def render(self, content: typing.Sequence):
"data": content[init:end],
}
)


def limit_offset(func):
"""
Decorator for adding pagination behavior to a view. That decorator produces a view based on limit-offset and
it adds three query parameters to control the pagination: limit, offset and count. Offset has a default value of
zero to start with the first element of the collection, limit default value is defined in
:class:`LimitOffsetResponse` and count defines if the response will
define the total number of elements.
The output schema is also modified by :class:`LimitOffsetSchema`,
creating a new schema based on it but using the old output schema as the content of its data field.
:param func: View to be decorated.
:return: Decorated view.
"""
assert forge is not None, "`python-forge` must be installed to use Paginator."

resource_schema = get_output_schema(func)
data_schema = marshmallow.fields.Nested(resource_schema, many=True) if resource_schema else marshmallow.fields.Raw()

schema = type(
"LimitOffsetPaginated" + resource_schema.__class__.__name__, # Add a prefix to avoid collision
(LimitOffsetSchema,),
{"data": data_schema}, # Replace generic with resource schema
)()

forge_revision_list = (
forge.copy(func),
forge.insert(forge.arg("limit", default=None, type=int), index=-1),
forge.insert(forge.arg("offset", default=None, type=int), index=-1),
forge.insert(forge.arg("count", default=True, type=bool), index=-1),
forge.delete("kwargs"),
forge.returns(schema),
)

try:
if asyncio.iscoroutinefunction(func):

@forge.compose(*forge_revision_list)
@functools.wraps(func)
async def decorator(*args, limit: int = None, offset: int = None, count: bool = True, **kwargs):
return LimitOffsetResponse(
schema=schema, limit=limit, offset=offset, count=count, content=await func(*args, **kwargs)
)

else:

@forge.compose(*forge_revision_list)
@functools.wraps(func)
def decorator(*args, limit: int = None, offset: int = None, count: bool = True, **kwargs):
return LimitOffsetResponse(
schema=schema, limit=limit, offset=offset, count=count, content=func(*args, **kwargs)
)

except ValueError as e:
raise TypeError("Paginated views must define **kwargs param") from e

return decorator
69 changes: 68 additions & 1 deletion flama/pagination/page_number.py
@@ -1,10 +1,18 @@
import asyncio
import functools
import typing

import marshmallow

from flama.responses import APIResponse
from flama.validation import get_output_schema

__all__ = ["PageNumberSchema", "PageNumberResponse"]
try:
import forge
except Exception: # pragma: no cover
forge = None # type: ignore

__all__ = ["PageNumberSchema", "PageNumberResponse", "page_number"]


class PageNumberMeta(marshmallow.Schema):
Expand Down Expand Up @@ -59,3 +67,62 @@ def render(self, content: typing.Sequence):
"data": content[init:end],
}
)


def page_number(func):
"""
Decorator for adding pagination behavior to a view. That decorator produces a view based on page numbering and
it adds three query parameters to control the pagination: page, page_size and count. Page has a default value of
first page, page_size default value is defined in
:class:`PageNumberResponse` and count defines if the response will define
the total number of elements.
The output schema is also modified by :class:`PageNumberSchema`, creating
a new schema based on it but using the old output schema as the content of its data field.
:param func: View to be decorated.
:return: Decorated view.
"""
assert forge is not None, "`python-forge` must be installed to use Paginator."

resource_schema = get_output_schema(func)
data_schema = marshmallow.fields.Nested(resource_schema, many=True) if resource_schema else marshmallow.fields.Raw()

schema = type(
"PageNumberPaginated" + resource_schema.__class__.__name__, # Add a prefix to avoid collision
(PageNumberSchema,),
{"data": data_schema}, # Replace generic with resource schema
)()

forge_revision_list = (
forge.copy(func),
forge.insert(forge.arg("page", default=None, type=int), index=-1),
forge.insert(forge.arg("page_size", default=None, type=int), index=-1),
forge.insert(forge.arg("count", default=True, type=bool), index=-1),
forge.delete("kwargs"),
forge.returns(schema),
)

try:
if asyncio.iscoroutinefunction(func):

@forge.compose(*forge_revision_list)
@functools.wraps(func)
async def decorator(*args, page: int = None, page_size: int = None, count: bool = True, **kwargs):
return PageNumberResponse(
schema=schema, page=page, page_size=page_size, count=count, content=await func(*args, **kwargs)
)

else:

@forge.compose(*forge_revision_list)
@functools.wraps(func)
def decorator(*args, page: int = None, page_size: int = None, count: bool = True, **kwargs):
return PageNumberResponse(
schema=schema, page=page, page_size=page_size, count=count, content=func(*args, **kwargs)
)

except ValueError as e:
raise TypeError("Paginated views must define **kwargs param") from e

return decorator
133 changes: 0 additions & 133 deletions flama/pagination/paginator.py

This file was deleted.

4 changes: 2 additions & 2 deletions flama/resources.py
Expand Up @@ -6,8 +6,8 @@

import marshmallow

from flama import pagination
from flama.exceptions import HTTPException
from flama.pagination import Paginator
from flama.responses import APIResponse
from flama.types import Model, PrimaryKey, ResourceMeta, ResourceMethodMeta

Expand Down Expand Up @@ -388,7 +388,7 @@ async def filter(self, *clauses, **filters) -> typing.List[typing.Dict]:
return [dict(row) for row in await self.database.fetch_all(query)]

@resource_method("/", methods=["GET"], name=f"{name}-list")
@Paginator.page_number
@pagination.page_number
async def list(self, **kwargs) -> output_schema(many=True):
return await self._filter() # noqa

Expand Down
43 changes: 27 additions & 16 deletions flama/routing.py
@@ -1,5 +1,6 @@
import asyncio
import inspect
import logging
import typing
from functools import wraps

Expand All @@ -20,6 +21,8 @@

__all__ = ["Route", "WebSocketRoute", "Router"]

logger = logging.getLogger(__name__)


FieldsMap = typing.Dict[str, Field]
MethodsMap = typing.Dict[str, FieldsMap]
Expand Down Expand Up @@ -164,20 +167,24 @@ async def _app(scope: Scope, receive: Receive, send: Send) -> None:
"request": http.Request(scope, receive),
}

injected_func = await app.injector.inject(endpoint, state)

if asyncio.iscoroutinefunction(endpoint):
response = await injected_func()
else:
response = await run_in_threadpool(injected_func)
try:
injected_func = await app.injector.inject(endpoint, state)

# Wrap response data with a proper response class
if isinstance(response, (dict, list)):
response = APIResponse(content=response, schema=get_output_schema(endpoint))
elif isinstance(response, str):
response = APIResponse(content=response)
elif response is None:
response = APIResponse(content="")
if asyncio.iscoroutinefunction(endpoint):
response = await injected_func()
else:
response = await run_in_threadpool(injected_func)

# Wrap response data with a proper response class
if isinstance(response, (dict, list)):
response = APIResponse(content=response, schema=get_output_schema(endpoint))
elif isinstance(response, str):
response = APIResponse(content=response)
elif response is None:
response = APIResponse(content="")
except Exception:
logger.exception("Error building response")
raise

await response(scope, receive, send)

Expand Down Expand Up @@ -216,10 +223,14 @@ async def _app(scope: Scope, receive: Receive, send: Send) -> None:
"websocket": websockets.WebSocket(scope, receive, send),
}

injected_func = await app.injector.inject(endpoint, state)
try:
injected_func = await app.injector.inject(endpoint, state)

kwargs = scope.get("kwargs", {})
await injected_func(**kwargs)
kwargs = scope.get("kwargs", {})
await injected_func(**kwargs)
except Exception:
logger.exception("Error building response")
raise

return _app

Expand Down

0 comments on commit 46473ae

Please sign in to comment.