Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/staticfiles #47

Merged
merged 5 commits into from
Oct 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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