Skip to content

Commit

Permalink
Feature/staticfiles (#47)
Browse files Browse the repository at this point in the history
* add files middlewares

* improve files middlewares and example

* add tests for staticfiles and uploaded files middlewares

* add docs for files middlewares

* add uvicorn to test dependencies
  • Loading branch information
livioribeiro authored Oct 8, 2024
1 parent ccb2d65 commit 6ec6bd1
Show file tree
Hide file tree
Showing 36 changed files with 409 additions and 40 deletions.
2 changes: 1 addition & 1 deletion docs/extensions/overview.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Overview
# Extensions

Extensions are python packages that provide additional functionality or integrate
external libraries into the framework.
Expand Down
2 changes: 1 addition & 1 deletion docs/middleware.md → docs/middleware/overview.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Middleware

The middleware pipeline is configured with the `MIDDLEWARE` configuration property. It must contain a list of classes
The middleware pipeline is configured with the `middleware` configuration property. It must contain a list of classes
that inherit from `selva.web.middleware.Middleware`.

## Usage
Expand Down
57 changes: 57 additions & 0 deletions docs/middleware/staticfiles_uploads.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Static and uploaded files

The `StaticFilesMiddleware` and `UploadedFilesMiddleware` provide a way of serving
static content and user uploaded files.

There are two separate middlewares to allow distinct handling in the middleware
pipeline. For example, you could set the uploaded files to be served after authorization,
while the static files remain publicly accessible.

## Usage

First you need to activate the middlewares in the `settings.yaml`

```yaml
middleware:
# ...
- selva.web.middleware.files.StaticFilesMiddleware
- selva.web.middleware.files.UploadedFilesMiddleware
# ...
```

After that, files located in the directories `resources/static` and `resources/uploads`
will be served at `/static/` and `/uploads/`, respectively.

## Static files mappings

You can map specific paths to single static files in order to, for example, serve
the favicon at `/favicon.ico` pointing to a file in `resources/static/`:

```yaml
middleware:
- selva.web.middleware.files.StaticFilesMiddleware
staticfiles:
mappings:
favicon.ico: my-icon.ico
```
## Configuration options
The available options to configure the `StaticFilesMiddleware` and `UploadedFilesMiddleware`
are shown below:

```yaml
staticfiles:
path: /static # (1)
root: resources/static # (2)
mappings: {}
uploadedfiles:
path: /uploads # (3)
root: resources/uploads # (4)
```

1. Path where static files are served
2. Directory where static files are located
3. Path where uploaded files are served
4. Directory where uploaded files are located
9 changes: 9 additions & 0 deletions examples/hello_world/configuration/settings.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
middleware:
- selva.web.middleware.files.StaticFilesMiddleware
- selva.web.middleware.request_id.RequestIdMiddleware

logging:
root: info
level:
application: info
format: console
1 change: 1 addition & 0 deletions examples/hello_world/resources/static/file.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Lorem ipsum dolor sit amet.
10 changes: 10 additions & 0 deletions examples/middleware_files/application.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from asgikit.responses import respond_text
from selva.web import controller, get


@controller
class Controller:
@get
async def index(self, request):
request.response.content_type = "text/html"
await respond_text(request.response, "Lorem ipsum dolor sit amet")
6 changes: 6 additions & 0 deletions examples/middleware_files/configuration/settings.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
middleware:
- selva.web.middleware.files.StaticFilesMiddleware
- selva.web.middleware.files.UploadedFilesMiddleware
staticfiles:
mappings:
favicon.ico: python.ico
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam congue feugiat tortor sit amet bibendum. Phasellus euismod risus eget lorem ornare, ac porta quam lobortis. Sed eget est at nulla scelerisque tincidunt. Aliquam condimentum purus id nisl vestibulum, nec eleifend magna interdum. Curabitur lacinia libero sed nisl mollis, et accumsan justo efficitur. Integer sit amet libero eget lorem varius faucibus vel quis sem. Duis consequat tempor orci eu porta. Sed ut metus porta, elementum nunc a, tincidunt massa. Sed volutpat tincidunt odio, vel venenatis est ultrices ut. Morbi et purus sed sapien elementum commodo. Sed rutrum odio vel magna dignissim laoreet.
6 changes: 4 additions & 2 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,12 @@ nav:
- routing.md
- templates.md
- configuration.md
- middleware.md
- logging.md
- Middleware:
- Overview: middleware/overview.md
- middleware/staticfiles_uploads.md
- Extensions:
- extensions/overview.md
- Overview: extensions/overview.md
- extensions/sqlalchemy.md
- extensions/redis.md
- extensions/jinja.md
6 changes: 4 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ packages = [

[tool.poetry.dependencies]
python = "^3.11"
asgikit = "^0.8"
asgikit = "^0.9"
pydantic = "^2.7"
python-dotenv = "^1.0"
"ruamel.yaml" = "^0.18"
Expand All @@ -49,7 +49,8 @@ memcached = ["emcache"]
optional = true

[tool.poetry.group.dev.dependencies]
uvicorn = { version = "^0.23", extras = ["standard"] }
uvicorn = { version = "^0.29", extras = ["standard"] }
granian = "^1.3"

[tool.poetry.group.test]
optional = true
Expand All @@ -62,6 +63,7 @@ coverage = { version = "^7", extras = ["toml"] }
httpx = "^0.27"
aiosqlite = "^0.20"
psycopg = "^3.1"
uvicorn = { version = "^0.29", extras = ["standard"] }

[tool.poetry.group.lint]
optional = true
Expand Down
9 changes: 9 additions & 0 deletions src/selva/configuration/defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,13 @@
"redis": {},
"sqlalchemy": {},
},
"staticfiles": {
"path": "/static",
"root": "resources/static",
"mappings": {},
},
"uploadedfiles": {
"path": "/uploads",
"root": "resources/uploads",
},
}
5 changes: 2 additions & 3 deletions src/selva/ext/templates/jinja/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ async def respond(
template_name: str,
context: dict,
*,
status: HTTPStatus = HTTPStatus.OK,
content_type: str = None,
stream: bool = False,
):
Expand All @@ -51,10 +50,10 @@ async def respond(

if stream:
render_stream = template.generate_async(context)
await respond_stream(response, render_stream, status=status)
await respond_stream(response, render_stream)
else:
rendered = await template.render_async(context)
await respond_text(response, rendered, status=status)
await respond_text(response, rendered)

async def render(self, template_name: str, context: dict) -> str:
template = self.environment.get_template(template_name)
Expand Down
5 changes: 3 additions & 2 deletions src/selva/logging.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import sys

import structlog
from uvicorn.config import LOGGING_CONFIG

from selva.configuration.settings import Settings

Expand Down Expand Up @@ -45,7 +46,7 @@ def setup(settings: Settings):

logging_config = {
"version": 1,
"disable_existing_loggers": True,
"disable_existing_loggers": False,
"formatters": {
"structlog": {
"()": structlog.stdlib.ProcessorFormatter,
Expand All @@ -60,7 +61,7 @@ def setup(settings: Settings):
},
"root": {
"handlers": ["console"],
"level": settings.logging.get("root", "INFO").upper(),
"level": settings.logging.get("root", "WARN").upper(),
},
"loggers": {
module: {"level": level.upper()}
Expand Down
12 changes: 6 additions & 6 deletions src/selva/web/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,8 @@ async def _initialize_middleware(self):
for cls in reversed(middleware):
if not hasattr(cls, selva.di.decorator.DI_ATTRIBUTE_SERVICE):
selva.di.decorator.service(cls)

if not self.di.has(cls):
self.di.register(cls)

mid = await self.di.get(cls)
Expand Down Expand Up @@ -231,17 +233,15 @@ async def _handle_request(self, scope, receive, send):
return

if stack_trace:
await respond_text(response, stack_trace, status=err.status)
response.status = err.status
await respond_text(response, stack_trace)
else:
await respond_status(response, status=err.status)
except Exception:
logger.exception("error processing request")

await respond_text(
request.response,
traceback.format_exc(),
status=HTTPStatus.INTERNAL_SERVER_ERROR,
)
request.response.status = HTTPStatus.INTERNAL_SERVER_ERROR
await respond_text(request.response, traceback.format_exc())

async def _process_request(self, request: Request):
logger.debug(
Expand Down
77 changes: 77 additions & 0 deletions src/selva/web/middleware/files.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
from abc import ABCMeta, abstractmethod
from pathlib import Path
from typing import Annotated

from asgikit.requests import Request
from asgikit.responses import respond_file

from selva.configuration import Settings
from selva.di import Inject
from selva.web.middleware import CallNext, Middleware
from selva.web.exception import HTTPNotFoundException


class BaseFilesMiddleware(Middleware, metaclass=ABCMeta):
settings: Annotated[Settings, Inject]
settings_property: str

path: str
root: Path

def initialize(self):
settings = self.settings.get(self.settings_property)
self.path = settings.path.lstrip("/")
self.root = Path(settings.root).resolve()

@abstractmethod
def get_file_to_serve(self, request: Request) -> str | None:
pass

async def __call__(self, call_next: CallNext, request: Request):
if file_to_serve := self.get_file_to_serve(request):
file_to_serve = (self.root / file_to_serve).resolve()
if not (
file_to_serve.is_file() and file_to_serve.is_relative_to(self.root)
):
raise HTTPNotFoundException()

await respond_file(request.response, file_to_serve)
else:
await call_next(request)


class UploadedFilesMiddleware(BaseFilesMiddleware):
settings_property = "uploadedfiles"

def get_file_to_serve(self, request: Request) -> str | None:
request_path = request.path.lstrip("/")

if request_path.startswith(self.path):
return request_path.removeprefix(self.path).lstrip("/")

return None


class StaticFilesMiddleware(BaseFilesMiddleware):
settings_property = "staticfiles"
mappings: dict[str, str]

def initialize(self):
super().initialize()

settings = self.settings.get(self.settings_property)
self.mappings = {
name.lstrip("/"): value.lstrip("/")
for name, value in settings.get("mappings", {}).items()
}

def get_file_to_serve(self, request: Request) -> str | None:
request_path = request.path.lstrip("/")

if file_to_serve := self.mappings.get(request_path):
return file_to_serve.lstrip("/")

if request_path.startswith(self.path):
return request_path.removeprefix(self.path).lstrip("/")

return None
1 change: 0 additions & 1 deletion src/selva/web/templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ async def respond(
template_name: str,
context: dict,
*,
status: HTTPStatus = HTTPStatus.OK,
content_type: str = None,
stream: bool = False,
):
Expand Down
4 changes: 2 additions & 2 deletions tests/ext/data/memcached/test_application.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from http import HTTPStatus

import pytest
from httpx import AsyncClient
from httpx import AsyncClient, ASGITransport

from selva.configuration.defaults import default_settings
from selva.configuration.settings import Settings
Expand Down Expand Up @@ -34,7 +34,7 @@ async def test_application(application: str, database: str):

await app._lifespan_startup()

client = AsyncClient(app=app)
client = AsyncClient(transport=ASGITransport(app=app))
response = await client.get("http://localhost:8000/")

await app._lifespan_shutdown()
Expand Down
4 changes: 2 additions & 2 deletions tests/ext/data/redis/test_application.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from http import HTTPStatus

import pytest
from httpx import AsyncClient
from httpx import AsyncClient, ASGITransport

from selva.configuration.defaults import default_settings
from selva.configuration.settings import Settings
Expand Down Expand Up @@ -34,7 +34,7 @@ async def test_application(application: str, database: str):

await app._lifespan_startup()

client = AsyncClient(app=app)
client = AsyncClient(transport=ASGITransport(app=app))
response = await client.get("http://localhost:8000/")

await app._lifespan_shutdown()
Expand Down
4 changes: 2 additions & 2 deletions tests/ext/data/sqlalchemy/test_application.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from http import HTTPStatus

import pytest
from httpx import AsyncClient
from httpx import AsyncClient, ASGITransport

from selva.configuration.defaults import default_settings
from selva.configuration.settings import Settings
Expand Down Expand Up @@ -34,7 +34,7 @@ async def test_application(application: str, database: str):

await app._lifespan_startup()

client = AsyncClient(app=app)
client = AsyncClient(transport=ASGITransport(app=app))
response = await client.get("http://localhost:8000/")

assert response.status_code == HTTPStatus.OK, response.text
3 changes: 2 additions & 1 deletion tests/ext/templates/jinja/test_jinja_render.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from copy import deepcopy
from pathlib import Path

from selva.configuration.defaults import default_settings
Expand All @@ -18,7 +19,7 @@ async def test_render_template():


async def test_render_str():
settings = Settings(default_settings)
settings = Settings(deepcopy(default_settings))
template = JinjaTemplate(settings)
template.initialize()
result = await template.render_str("{{ variable }}", {"variable": "Jinja"})
Expand Down
Loading

0 comments on commit 6ec6bd1

Please sign in to comment.