diff --git a/docs/about.md b/docs/about.md index 43c4b88..24dd133 100644 --- a/docs/about.md +++ b/docs/about.md @@ -42,9 +42,11 @@ The name _BlackSheep_ was chosen for two reasons: intentional rebelliousness_](https://idioms.thefreedictionary.com/the+black+sheep) - especially for the choice of giving so much importance to _dependency - injection_ (which is not very popular in Python community), asynchronous - coding and type annotations (which are still debated upon in Python - community), and for being a Python web framework inspired by ASP.NET Core. + injection_ (which is not very popular in Python community, or _was not_ + popular when BlackSheep was started), asynchronous coding and type + annotations (which are still debated upon in Python community, or _were_ + debated upon when BlackSheep was started), and for being a Python web + framework inspired by ASP.NET Core. * as a tribute to the song _The Sinking Belle (Black Sheep)_ of the album [_Altar_](https://en.wikipedia.org/wiki/Altar_(album)), by Boris and Sunn O))). diff --git a/docs/anti-request-forgery.md b/docs/anti-request-forgery.md index bac49a9..2370767 100644 --- a/docs/anti-request-forgery.md +++ b/docs/anti-request-forgery.md @@ -1,14 +1,15 @@ # Preventing Cross-Site Request Forgery (XSRF/CSRF) Cross-site request forgery, also known as XSRF or CSRF, is a kind of attack that -exploits situations in which browsers automatically include credentials in web requests. +exploits situations in which browsers automatically include credentials in web +requests. Example of such situations are: -* Cookies are automatically included in web requests, so if an application uses +- Cookies are automatically included in web requests, so if an application uses cookie-based authentication, credentials are sent automatically -* After a user signs in with Basic or Digest authentication, the browser automatically - sends the credentials until the session ends +- After a user signs in with Basic or Digest authentication, the browser + automatically sends the credentials until the session ends If a web application uses cookie based authentication or other features that cause credentials to be automatically included in web requests, it requires @@ -27,18 +28,13 @@ page describes how to use the built-in solution. To enable anti-forgery validation, use the module `blacksheep.server.csrf`: ```python -from blacksheep import Application, FromForm +from blacksheep import Application from blacksheep.server.csrf import use_anti_forgery -from blacksheep.server.templating import use_templates -from jinja2 import PackageLoader app = Application(show_error_details=True) -use_templates(app, PackageLoader("app", "views")) - use_anti_forgery(app) - ``` The call to `use_anti_forgery(app)` configures a middleware that can issue and @@ -54,29 +50,25 @@ Consider an example having this folder structure: ├── app │   ├── __init__.py │   └── views -│   └── index.html +│   └── index.jinja └── server.py ``` Where `server.py` contains the following code: ```python -from blacksheep import Application, FromForm +from blacksheep import Application, FromForm, get, post, view from blacksheep.server.csrf import use_anti_forgery -from blacksheep.server.templating import use_templates -from jinja2 import PackageLoader app = Application(show_error_details=True) -render = use_templates(app, PackageLoader("app", "views")) - use_anti_forgery(app) -@app.router.get("/") -async def home(request): - return render("index", {}, request=request) +@get("/") +def home(request): + return view("index", {}, request=request) class CreateUserInput: @@ -84,13 +76,13 @@ class CreateUserInput: self.username = username -@app.router.post("/user") +@post("/user") async def create_user(data: FromForm[CreateUserInput]): """Calls to this endpoint require an anti-forgery token.""" return {"example": True, "username": data.value.username} ``` -And `index.html` contains the following template: +And `index.jinja` contains the following template: ```html @@ -171,7 +163,7 @@ To use custom parameter names, refer to the `AntiForgeryHandler` class in │   ├── __init__.py │   └── views │   └── home -│      └── index.html +│      └── index.jinja └── server.py ``` @@ -181,13 +173,9 @@ To use custom parameter names, refer to the `AntiForgeryHandler` class in from blacksheep import Application, FromForm from blacksheep.server.controllers import Controller, get, post from blacksheep.server.csrf import use_anti_forgery -from blacksheep.server.templating import use_templates -from jinja2 import PackageLoader app = Application(show_error_details=True) -use_templates(app, PackageLoader("app", "views")) - use_anti_forgery(app) @@ -207,7 +195,7 @@ class Home(Controller): return {"example": True, "username": data.value.username} ``` -`index.html` (like in the previous example). +`index.jinja` (like in the previous example). ## Rendering anti-forgery tokens without input elements @@ -231,7 +219,7 @@ from blacksheep.server.csrf import ignore_anti_forgery @ignore_anti_forgery() -@app.router.post("/example") +@post("/example") async def create_example(): """This endpoint does not require an anti-forgery token.""" ``` diff --git a/docs/application.md b/docs/application.md index 21a5c0d..ad0f56a 100644 --- a/docs/application.md +++ b/docs/application.md @@ -13,10 +13,9 @@ request handlers, producing a `HTTP 500 Internal Server Error` response. To see this in practice, start an application like the following: ```python -from blacksheep import Application +from blacksheep import Application, get app = Application() -get = app.router.get @get("/") @@ -50,7 +49,6 @@ import os from blacksheep import Application app = Application(show_error_details=bool(os.environ.get("SHOW_ERROR_DETAILS", None))) -get = app.router.get @get("/") @@ -59,11 +57,9 @@ def crash_test(): ``` -!!! info - BlackSheep project templates use a library to handle application - settings and configuration roots. Consider using - [`essentials-configuration`](https://github.com/Neoteroi/essentials-configuration) - for this. +!!! info "Settings strategy" + BlackSheep project templates include a strategy to handle application + settings and configuration roots. ### Configuring exceptions handlers @@ -74,7 +70,7 @@ matching handler for that kind of exception. An exception handler is defined as a function with the following signature: ```python -from blacksheep import Request, Response +from blacksheep import Request, Response, text async def exception_handler(self, request: Request, exc: Exception) -> Response: pass @@ -90,14 +86,14 @@ async def exception_handler(self, request, exc: CustomException): nonlocal app assert self is app assert isinstance(exc, CustomException) - return Response(200, content=TextContent('Called')) + return text("Called") # Register the exception handler for the CustomException type: app.exceptions_handlers[CustomException] = exception_handler -@app.router.get(b'/') +@get('/') async def home(request): # of course, the exception can be risen at any point # for example in the business logic layer @@ -105,9 +101,9 @@ async def home(request): ``` -Exceptions inheriting from `HTTPException` can be mapped to handlers by their type or by -their status code, using `int` keys; while user defined exceptions are mapped to handlers -by their type. +Exceptions inheriting from `HTTPException` can be mapped to handlers by their +type or by their status code, using `int` keys; while user defined exceptions +are mapped to handlers by their type. When an exception handler is registered for a type of exception, all subclasses are also handled by that handler. It is however possible to define a more @@ -190,7 +186,7 @@ async def register_http_client(): print("HTTP client disposed") -@app.router.get("/") +@router.get("/") async def home(http_client: ClientSession): print(http_client) return {"ok": True, "client_instance_id": id(http_client)} @@ -241,11 +237,13 @@ async def configure_redis(): interface, refer to its [official documentation](https://redis.readthedocs.io/en/stable/examples/asyncio_examples.html). ### on_start + This event should be used to configure things such as new request handlers, and service registered in `app.services`, such as database connection pools, HTTP client sessions. ### after_start + This event should be used to configure things that must happen after request handlers are normalized. At this point, the application router contains information about actual routes handled by the web application, and routes can be inspected. @@ -253,6 +251,7 @@ For example, the built-in generation of OpenAPI Documentation generates the API specification file at this point. ### on_stop + This event should be used to fire callbacks that need to happen when the application is stopped. For example, disposing of services that require disposal, such as database connection pools, HTTP client sessions using connection pools. @@ -270,11 +269,10 @@ are fired, and the state of the application when they are executed. Event handlers can be registered using decorators. ```python - from blacksheep import Application, Request, Response, text + from blacksheep import Application, Request, Response, text, get app = Application() - get = app.router.get @get("/") @@ -302,11 +300,10 @@ are fired, and the state of the application when they are executed. In alternative to decorators, event handlers can be registered using ` += `: ```python - from blacksheep import Application, Request, Response, text + from blacksheep import Application, Request, Response, text, get app = Application() - get = app.router.get @get("/") @@ -342,4 +339,5 @@ are fired, and the state of the application when they are executed. ``` ## Next + Read about the details of [routing in BlackSheep](../routing). diff --git a/docs/authentication.md b/docs/authentication.md index 9bfb053..d28eb60 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -1,5 +1,5 @@ # Authentication in BlackSheep -The words "authentication strategy" in the context of a web application refer +The words _authentication strategy_ in the context of a web application refer to the ability to identify the user who is using the application. BlackSheep implements a built-in authentication strategy for request handlers. This page describes: @@ -23,9 +23,9 @@ pypi](https://pypi.org/project/guardpost/)). Examples of common strategies to identify users in web applications include: -* reading an `Authorization: Bearer xxx` request header containing a [JWT](https://jwt.io/introduction/) +- reading an `Authorization: Bearer xxx` request header containing a [JWT](https://jwt.io/introduction/) with claims that identify the user -* reading a signed token from a cookie +- reading a signed token from a cookie The next paragraphs explain first how to use the built-in support for JWT Bearer tokens, and how to write a custom authentication handler. @@ -33,7 +33,7 @@ Bearer tokens, and how to write a custom authentication handler. !!! info The word "user" is usually used only to refer to human users, while the word "service" is used to describe non-human clients. In Java and .NET, a - common word to describe a generic client is "principal". + common word to describe a generic identity is "principal". ## OIDC @@ -41,17 +41,22 @@ BlackSheep implements built-in support for OpenID Connect authentication, meaning that it can be easily integrated with identity provider services such as: -* [Auth0](https://auth0.com) -* [Azure Active Directory](https://azure.microsoft.com/en-us/services/active-directory/) -* [Azure Active Directory B2C](https://docs.microsoft.com/en-us/azure/active-directory-b2c/overview) -* [Okta](https://www.okta.com) +- [Auth0](https://auth0.com) +- [Entra ID](https://www.microsoft.com/en-us/security/business/identity-access/microsoft-entra-id) +- [Azure Active Directory B2C](https://docs.microsoft.com/en-us/azure/active-directory-b2c/overview) +- [Okta](https://www.okta.com) + +!!! tip "Examples in GitHub" + The [Neoteroi/BlackSheep-Examples/](https://github.com/Neoteroi/BlackSheep-Examples/) + repository in GitHub contains examples of JWT Bearer authentication and + OpenID Connect integrations. A basic example integration with any of the identity providers above, having implicit flow enabled for `id_token` (meaning that the code doesn't need to handle any secret), looks like the following: ```python -from blacksheep import Application, html, pretty_json +from blacksheep import Application, get, html, pretty_json from blacksheep.server.authentication.oidc import OpenIDSettings, use_openid_connect from guardpost.authentication import Identity @@ -69,7 +74,7 @@ use_openid_connect( ) -@app.route("/") +@get("/") async def home(user: Identity): if user.is_authenticated(): response = pretty_json(user.claims) @@ -106,12 +111,12 @@ authorization to restrict access to certain methods, only for users who are successfully authenticated: ```python -from blacksheep import Application -from blacksheep.server.authorization import auth -from guardpost.common import AuthenticatedRequirement, Policy +from guardpost import Policy, User +from guardpost.common import AuthenticatedRequirement +from blacksheep import Application, get, json from blacksheep.server.authentication.jwt import JWTBearerAuthentication - +from blacksheep.server.authorization import auth app = Application() @@ -119,9 +124,7 @@ app.use_authentication().add( JWTBearerAuthentication( authority="https://login.microsoftonline.com/.onmicrosoft.com", valid_audiences=[""], - valid_issuers=[ - "https://login.microsoftonline.com//v2.0" - ], + valid_issuers=["https://login.microsoftonline.com//v2.0"], ) ) @@ -130,8 +133,6 @@ authorization = app.use_authorization() authorization += Policy("example_name", AuthenticatedRequirement()) -get = app.router.get - @get("/") def home(): @@ -150,6 +151,7 @@ async def open(user: User | None): return json({"anonymous": True}) else: return json(user.claims) + ``` The built-in handler for JWT Bearer authentication does not support JWTs signed @@ -168,14 +170,11 @@ The example below shows how to configure a custom authentication handler that obtains user's identity for each web request. ```python -from typing import Optional +from blacksheep import Application, Request, auth, get, json +from guardpost import AuthenticationHandler, Identity, User -from blacksheep import Application, Request, json -from guardpost.asynchronous.authentication import AuthenticationHandler, Identity -from guardpost.authentication import User app = Application(show_error_details=True) -get = app.router.get class ExampleAuthHandler(AuthenticationHandler): @@ -202,6 +201,26 @@ class ExampleAuthHandler(AuthenticationHandler): app.use_authentication().add(ExampleAuthHandler()) + + +@get("/") +def home(): + return "Hello, World" + + +@auth("example_name") +@get("/api/message") +def example(): + return "This is only for authenticated users" + + +@get("/open/") +async def open(user: User | None): + if user is None: + return json({"anonymous": True}) + else: + return json(user.claims) + ``` It is possible to configure several authentication handlers to implement @@ -237,17 +256,22 @@ the following body: For example, to generate web requests using `curl`: ```bash -$ curl http://127.0.0.1:44555/open {"anonymous":true} +curl http://127.0.0.1:44555/open +``` -$ curl -H "Authorization: foo" http://127.0.0.1:44555/open {"name":"Jan -Kowalski"} +Gets the output: `{"anonymous":true}`. + +```bash +curl -H "Authorization: foo" http://127.0.0.1:44555/open ``` +Gets the output: `{"name":"Jan Kowalski"}`. + _The application has been started on port 44555 (e.g. `uvicorn server:app --port=44555`)._ ## Reading user's context -The example below show how the user's identity can be read from the web -request + +The example below shows how user's identity can be read from the web request: === "Using binders (recommended)" diff --git a/docs/authorization.md b/docs/authorization.md index 56f872a..427978d 100644 --- a/docs/authorization.md +++ b/docs/authorization.md @@ -27,14 +27,13 @@ requires an authenticated user. It is modified from the example in the ```python from typing import Optional -from blacksheep import Application, Request, json, ok +from blacksheep import Application, Request, json, ok, get from blacksheep.server.authorization import Policy, auth from guardpost.asynchronous.authentication import AuthenticationHandler, Identity from guardpost.authentication import User from guardpost.common import AuthenticatedRequirement app = Application(show_error_details=True) -get = app.router.get class ExampleAuthHandler(AuthenticationHandler): @@ -99,10 +98,10 @@ validates user's claims (looking for a "role" claim that might be coming from a JWT). ```python -from blacksheep.server.authorization import Policy, auth +from blacksheep.server.authorization import Policy from guardpost.authorization import AuthorizationContext -from guardpost.synchronous.authorization import Requirement +from guardpost.authorization import Requirement class AdminRequirement(Requirement): @@ -116,7 +115,6 @@ class AdminRequirement(Requirement): class AdminsPolicy(Policy): def __init__(self): super().__init__("admin", AdminRequirement()) - ``` Full example: @@ -124,16 +122,18 @@ Full example: ```python from typing import Optional -from blacksheep import Application, Request, json, ok +from blacksheep import Application, Request, get, json, ok from blacksheep.server.authorization import Policy, auth -from guardpost.asynchronous.authentication import AuthenticationHandler, Identity -from guardpost.authentication import User -from guardpost.authorization import AuthorizationContext +from guardpost import ( + AuthenticationHandler, + Identity, + User, + AuthorizationContext, + Requirement, +) from guardpost.common import AuthenticatedRequirement -from guardpost.synchronous.authorization import Requirement app = Application(show_error_details=True) -get = app.router.get class ExampleAuthHandler(AuthenticationHandler): @@ -195,7 +195,6 @@ async def only_for_authenticated_users(): async def only_for_administrators(): # This method requires "admin" role in user's claims return ok("example") - ``` ## Using the default policy diff --git a/docs/background-tasks.md b/docs/background-tasks.md index 5a18597..7192a30 100644 --- a/docs/background-tasks.md +++ b/docs/background-tasks.md @@ -11,10 +11,9 @@ response status code. ```python import asyncio -from blacksheep import Application, Response, accepted +from blacksheep import Application, Response, accepted, get app = Application(show_error_details=True) -get = app.router.get async def background_work(): @@ -44,18 +43,18 @@ once every second: import asyncio from datetime import datetime -from blacksheep import Application +from blacksheep import Application, get app = Application() -@app.route("/") +@get("/") def home(): - return f"Hello, World! {datetime.utcnow().isoformat()}" + return f"Hello, World! {datetime.now().isoformat()}" def get_current_timestamp(): - return datetime.utcnow().isoformat() + return datetime.now().isoformat() class Foo: @@ -82,5 +81,5 @@ async def configure_background_tasks(app): app.on_start += configure_background_tasks -app.services.add_exact_scoped(Foo) +app.services.add_scoped(Foo) ``` diff --git a/docs/binders.md b/docs/binders.md index 154f5d0..acf45d5 100644 --- a/docs/binders.md +++ b/docs/binders.md @@ -77,7 +77,7 @@ Binders can be defined explicitly, using type annotations and classes from ```python from dataclasses import dataclass -from blacksheep import FromJSON, FromServices +from blacksheep import FromJSON, FromServices, post from your_business_logic.handlers.cats import CreateCatHandler # example @@ -148,7 +148,7 @@ async def example( ``` ```python -from blacksheep import FromQuery +from blacksheep import FromQuery, get @get("/foo") @@ -164,7 +164,7 @@ async def example( ```python from typing import Optional -from blacksheep import FromQuery +from blacksheep import FromQuery, get @get("/foo") @@ -180,7 +180,7 @@ async def example( ```python from typing import Optional -from blacksheep import FromQuery +from blacksheep import FromQuery, get @get("/foo") @@ -216,7 +216,7 @@ async def example( `name` class property: ```python -from blacksheep import FromCookie, FromHeader +from blacksheep import FromCookie, FromHeader, get class FromAcceptHeader(FromHeader[str]): diff --git a/docs/cache-control.md b/docs/cache-control.md index 84f40de..7b0c52c 100644 --- a/docs/cache-control.md +++ b/docs/cache-control.md @@ -18,20 +18,20 @@ The following example illustrates how the `cache_control` decorator can be used to control caching for specific request handlers: ```python -from blacksheep import Application +from blacksheep import Application, get from blacksheep.server.headers.cache import cache_control app = Application() -@app.router.get("/") +@get("/") @cache_control(no_cache=True, no_store=True) async def home(): return "This response should not be cached or stored!" -@app.router.get("/api/cats") +@get("/api/cats") @cache_control(max_age=120) async def get_cats(): ... diff --git a/docs/cli.md b/docs/cli.md new file mode 100644 index 0000000..a225dbc --- /dev/null +++ b/docs/cli.md @@ -0,0 +1,129 @@ +# More about the BlackSheep-CLI + +The second version of the web framework offers a command-line interface (CLI) +to bootstrap new projects using templates, inspired by similar CLIs of popular +front-end web frameworks. + +This page describes the CLI in details, covering the following subjects: + +- [X] How to install the `blacksheep-cli`. +- [X] How to use its help. +- [X] How to create a new project. +- [X] How to configure new templates. + +## Installing the CLI + +The CLI is distributed as a separate package, and can be installed from the +Python Package Index: + +```bash +pip install blacksheep-cli +``` + +Once installed, the CLI can be used to bootstrap new projects using interactive +prompts. + + +!!! tip "Beware of dependencies" + It is recommended to use the `blacksheep-cli` only to bootstrap new projects, + and to not include it as a dependency for web projects, because it + includes several dependencies that are not necessary to run a web server. + +## Using the help + +The `blacksheep-cli` is a [Click application](https://click.palletsprojects.com/en/8.1.x/) +with auto-generated help. Type `blacksheep` or `blacksheep --help` to display the help, +with the list of command groups: + +![CLI help](./img/cli-help.png) + +To display the help of a specific commands group, use the command group name +followed by `--help`, like in the following example: + +![blacksheep templates --help](./img/cli-group-help.png) + +## Listing available templates + +The `blacksheep-cli` is pre-configured with official templates. To list the +available templates, use the `blacksheep templates list`, or the +`blacksheep templates details` commands. + +```bash +$ blacksheep templates list +api +mvc +``` + +To display details about the templates, which are stored in a configuration file +in the user's folder, use the `blacksheep templates details` command: + +![blacksheep templates details](./img/cli-templates-details.png) + +!!! info "Available templates" + At the time of this writing, there are two officially maintained templates. + More can be added in the future, and users can configure additional + templates using the `blacksheep templates add` command. + +## Creating a new project + +Use the `blacksheep create` command to bootstrap a project +using one of the supported templates. The command will prompt for the necessary +input to bootstrap a new project. Aside from project name and template type, +each project template defines the parameters that are needed. + +![blacksheep create command](./img/cli-create-demo.gif) + +The CLI includes a help, and supports custom templates, using the +same sources supported by `Cookiecutter`. + +## Templates commands + +| Command | Description | +| --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `add` | Adds a new template to the list of available templates. This information is stored in a configuration file in the user's folder (this is an implementation detail and might change in the future). | +| `remove` | Removes a template from the list of available templates. | +| `list` | Lists the names of the available templates. | +| `details` | Displays details about the available templates. | + +## Creating a new template + +To create a new project template, define a new `cookiecutter` template. + +- [cookiecutter docs](https://cookiecutter.readthedocs.io/en/stable/) +- [API template, which can be followed as example](https://github.com/Neoteroi/BlackSheep-API) + +To offer a better prompt, include a `questions.json` file for Python `questionary`, +whose questions match the parameters described in `cookiecutter.json`; like in +[the API template](https://github.com/Neoteroi/BlackSheep-API/blob/main/questions.json). + +The BlackSheep CLI supports all sources that are supported by [`cookiecutter`](https://cookiecutter.readthedocs.io/en/stable/usage.html#grab-a-cookiecutter-template). + +Then include it in the list of available templates, using the `templates add` command, +like in: + +```bash +blacksheep templates add foo https://github.com/Neoteroi/BlackSheep-Foo -d 'Some nice template! 🐃' +``` + +To use a specific `git` tag, add a `${tag}` suffix to the source, like: + +```bash +blacksheep templates add foov2 'https://github.com/Neoteroi/BlackSheepFoo$v2' +``` + +## Cookiecutter + +The `blacksheep-cli` uses [`Cookiecutter`](https://cookiecutter.readthedocs.io/en/stable/) +under the hood, with [`questionary`](https://pypi.org/project/questionary/) to +offer better prompts. + +The official templates are `cookiecutter` templates, that can be used in non-interactive +way if desired, using the `cookiecutter` CLI. This can be useful to bootstrap new +projects in automated jobs (CI/CD). + +For example, to bootstrap a new project using the API template, with name "foo" +and using TOML files for application settings: + +```bash +cookiecutter https://github.com/Neoteroi/BlackSheep-API --no-input project_name=foo app_settings_format=TOML +``` diff --git a/docs/client.md b/docs/client.md index 2770c71..77de77b 100644 --- a/docs/client.md +++ b/docs/client.md @@ -72,67 +72,44 @@ client.configure() ``` ## Considerations about the ClientSession class -To make the client more user-friendly, default connection pools are reused by -loop id. This is to prevent users from killing the performance of their -applications simply by instantiating many times `ClientSession` (for example, -at every web request). -However, it is recommended to instantiate a single instance of HTTP client and -register it as service of the application: +The `ClientSession` owns by default a connections pool, if none is specified for +it. The connections pool is automatically disposed when the client is exited, +if it was created for the client. -```python - -async def configure_http_client(app): - http_client = ClientSession() - app.services.add_instance(http_client) # register a singleton - -app.on_start += configure_http_client +!!! danger "Connection pooling is important" + Avoid instantiating a new `ClientSession` at each web request, unless the + same `ConnectionsPool` is reused among the instances. Instantiating a new + `ClientSession` without reusing the same TCP connections pool has + negative effects on the performance of the application. -async def dispose_http_client(app): - http_client = app.service_provider.get(ClientSession) - await http_client.close() - -app.on_stop += dispose_http_client - -``` - -When following this approach, the http client can be automatically injected to -request handlers, and services that need it, like in this example: +It is recommended to instantiate a single instance of HTTP client and +register it as service of the application, using the `@app.lifespan` method: ```python -from blacksheep import html - - -@app.route("/get-python-homepage") -async def get_python_homepage(http_client): - response = await http_client.get("https://docs.python.org/3/") - - assert response is not None - data = await response.text() - return html(data) -``` - -Otherwise, instantiate a single connection pools and use it across several -instances of HTTP clients: - ```python -from blacksheep.client import ClientSession -from blacksheep.client.pool import ClientConnectionPools +from blacksheep import Application +from blacksheep.client.session import ClientSession +app = Application() -async def client_pools(): - # instantiate a single instance of pools - pools = ClientConnectionPools(loop) # loop is an asyncio loop - # instantiate clients using the same pools - client_one = ClientSession(pools=pools) +@app.lifespan +async def register_http_client(): + async with ClientSession() as client: + print("HTTP client created and registered as singleton") + app.services.register(ClientSession, instance=client) + yield - client_two = ClientSession(pools=pools) + print("HTTP client disposed") - client_three = ClientSession(pools=pools) - await pools.close() +@router.get("/") +async def home(http_client: ClientSession): + print(http_client) + return {"ok": True, "client_instance_id": id(http_client)} ``` -!!! danger "Dispose ClientConnectionPools" - When +When following this approach, the http client can be automatically injected to +request handlers, and services that need it, and is automatically disposed when +the application is stopped. diff --git a/docs/controllers.md b/docs/controllers.md index 8a8631a..b078153 100644 --- a/docs/controllers.md +++ b/docs/controllers.md @@ -1,4 +1,5 @@ # Controllers + BlackSheep has built-in features to support MVC (Model, View, Controller) architecture. A `Controller` is a class having at least one method registered as request handler (i.e. associated to a route). A Controller is instantiated @@ -14,11 +15,12 @@ It is recommended to follow the [MVC tutorial](../mvc-project-template/) before reading this page. !!! tip "For Flask users..." - If you come from Flask, controllers in BlackSheep would be the equivalent of - Flask's Blueprints, as they allow to group request handlers in dedicated - modules and classes. + If you come from Flask, controllers in BlackSheep can be considered + equivalent of Flask's Blueprints, as they allow to group request handlers + in dedicated modules and classes. ## The Controller class + Controllers implement several methods to simplify returning responses. These are the same described at [Responses](../responses/), but they can be overridden in subclasses of `Controller` and they remove the need to import functions. @@ -142,18 +144,18 @@ app.controllers_router = RoutesRegistry() get = app.controllers_router.get ``` -## The ApiController class -The `ApiController` class is a kind of `Controller` dedicated to API -definitions. An ApiController offers some properties to enable versioning +## The APIController class +The `APIController` class is a kind of `Controller` dedicated to API +definitions. An APIController offers some properties to enable versioning of routes and adding a common path prefix to all routes, for example prepending "/v1/" fragment to all routes and the name of the controller class. ```python from blacksheep import Response, FromJSON, FromQuery -from blacksheep.server.controllers import ApiController, delete, get, patch, post +from blacksheep.server.controllers import APIController, delete, get, patch, post -class Cats(ApiController): +class Cats(APIController): @get() def get_cats( self, @@ -195,7 +197,7 @@ To include a version number in the API, implement a `version` `@classmethod` lik following example: ```python -class Cats(ApiController): +class Cats(APIController): @classmethod def version(cls) -> str: @@ -219,7 +221,7 @@ To specify a name for the API, different than the default one, implement a `route` `@classmethod` like in the following example: ```python -class Cats(ApiController): +class Cats(APIController): @classmethod def route(cls) -> str: diff --git a/docs/cors.md b/docs/cors.md index 60f5b1c..0d7b0a6 100644 --- a/docs/cors.md +++ b/docs/cors.md @@ -50,6 +50,7 @@ The example below shows how to enable CORS only for certain endpoints: ```python app.use_cors() +cors = app.cors app.add_cors_policy( "example", @@ -57,12 +58,12 @@ app.add_cors_policy( allow_origins="*", ) -@app.route("/", methods=["GET", "POST"]) +@route("/", methods=["GET", "POST"]) async def home(): ... -@app.cors("example") -@app.route("/specific-rules", methods=["GET", "POST"]) +@cors("example") +@route("/specific-rules", methods=["GET", "POST"]) async def enabled(): ... @@ -103,17 +104,17 @@ app.add_cors_policy( app.add_cors_policy("deny") -@app.route("/", methods=["GET", "POST"]) +@route("/", methods=["GET", "POST"]) async def home(): ... @app.cors("one") -@app.route("/specific-rules", methods=["GET", "POST"]) +@route("/specific-rules", methods=["GET", "POST"]) async def enabled(): ... @app.cors("deny") -@app.router.get("/disabled-for-cors") +@get("/disabled-for-cors") async def disabled(): ... ``` diff --git a/docs/dependency-injection.md b/docs/dependency-injection.md index cabb477..aef172d 100644 --- a/docs/dependency-injection.md +++ b/docs/dependency-injection.md @@ -4,11 +4,12 @@ injected directly in request handlers, by function signature. BlackSheep also supports dependency injection of services configured for the application. This page describes: -- [X] An introduction to dependency injection in BlackSheep. +- [X] An introduction to dependency injection in BlackSheep, with a focus on `rodi`. - [X] Service resolution. - [X] Service lifetime. - [X] Options to create services. - [X] Examples of dependency injection. +- [X] How to use alternatives to `rodi`. ## Introduction @@ -52,17 +53,17 @@ as in this example: **server.py**: ```python -from blacksheep import Application +from blacksheep import Application, get from domain.foo import Foo app = Application() -app.services.add_exact_scoped(Foo) # <-- register Foo type as a service +app.services.add_scoped(Foo) # <-- register Foo type as a service -@app.route("/") +@get("/") def home(foo: Foo): # <-- foo is referenced in type annotation return f"Hello, {foo.foo}!" @@ -100,18 +101,18 @@ Note that both types need to be registered in `app.services`: **server.py**: ```python -from blacksheep import Application, text +from blacksheep import Application, get, text from domain.foo import A, Foo app = Application() -app.services.add_exact_transient(A) -app.services.add_exact_scoped(Foo) +app.services.add_transient(A) +app.services.add_scoped(Foo) -@app.route("/") +@get("/") def home(foo: Foo): return text( f""" @@ -182,21 +183,21 @@ class Foo: **server.py**: ```python -from blacksheep import Application, text +from blacksheep import Application, get, text from domain.foo import A, B, C, Foo app = Application() -app.services.add_exact_transient(A) -app.services.add_exact_scoped(B) -app.services.add_exact_singleton(C) +app.services.add_transient(A) +app.services.add_scoped(B) +app.services.add_singleton(C) -app.services.add_exact_scoped(Foo) +app.services.add_scoped(Foo) -@app.route("/") +@get("/") def home(foo: Foo): return text( f""" @@ -250,11 +251,12 @@ Produces responses like the following at "/": Note how: -* transient services are always instantiated whenever they are activated (A) -* scoped services are instantiated once per web request (B) -* a singleton service is activated only once (C) +- transient services are always instantiated whenever they are activated (A) +- scoped services are instantiated once per web request (B) +- a singleton service is activated only once (C) ## Options to create services + `rodi` provides several ways to define and instantiate services. 1. registering an exact instance as singleton @@ -292,7 +294,7 @@ class HelloHandler: return "Hello" -app.services.add_exact_transient(HelloHandler) +app.services.add_transient(HelloHandler) ``` @@ -338,7 +340,7 @@ app.services.add_scoped(CatsRepository, PostgreSQLCatsRepository) # a request handler needing the CatsRepository doesn't need to know about # the exact implementation (e.g. PostgreSQL, SQLite, etc.) -@app.route("/api/cats/{cat_id}") +@get("/api/cats/{cat_id}") async def get_cat(cat_id: str, repo: CatsRepository): cat = await repo.get_cat_by_id(cat_id) @@ -388,10 +390,10 @@ instantiated once per web request: ```python -app.services.add_exact_scoped(OperationContext) +app.services.add_scoped(OperationContext) -@app.route("/") +@get("/") def home(context: OperationContext): return text( f""" @@ -408,7 +410,7 @@ Services that require asynchronous initialization can be configured inside ```python import asyncio -from blacksheep import Application, text +from blacksheep import Application, get, text app = Application() @@ -428,9 +430,9 @@ async def configure_something(app: Application): app.on_start += configure_something -@app.route("/") +@get("/") async def home(service: Example): - return text(f"{service.text}") + return service.text ``` @@ -450,3 +452,134 @@ async def dispose_example(app: Application): app.on_stop += dispose_example ``` + +## The container protocol + +Since version 2, BlackSheep supports alternatives to `rodi` for dependency +injection. The `services` property of the `Application` class needs to conform +to the following container protocol: + +- `register` method to register types +- `resolve` method to resolve instances of types +- `__contains__` method to describe whether a type is defined inside the container + +```python +class ContainerProtocol: + """ + Generic interface of DI Container that can register and resolve services, + and tell if a type is configured. + """ + + def register(self, obj_type: Union[Type, str], *args, **kwargs): + """Registers a type in the container, with optional arguments.""" + + def resolve(self, obj_type: Union[Type[T], str], *args, **kwargs) -> T: + """Activates an instance of the given type, with optional arguments.""" + + def __contains__(self, item) -> bool: + """ + Returns a value indicating whether a given type is configured in this container. + """ +``` + +The following example shows how to use +[`punq`](https://github.com/bobthemighty/punq) for dependency injection instead +of `rodi`, and how a transient service can be resolved at "/" and a singleton +service resolved at "/home": + +```python +from typing import Type, TypeVar, Union, cast + +import punq + +from blacksheep import Application +from blacksheep.messages import Request +from blacksheep.server.controllers import Controller, get + +T = TypeVar("T") + + +class Foo: + def __init__(self) -> None: + self.foo = "Foo" + + +class PunqDI: + """ + BlackSheep DI container implemented with punq + + https://github.com/bobthemighty/punq + """ + def __init__(self, container: punq.Container) -> None: + self.container = container + + def register(self, obj_type, *args): + self.container.register(obj_type, *args) + + def resolve(self, obj_type: Union[Type[T], str], *args) -> T: + return cast(T, self.container.resolve(obj_type)) + + def __contains__(self, item) -> bool: + return bool(self.container.registrations[item]) + + +container = punq.Container() +container.register(Foo) + +app = Application(services=PunqDI(container), show_error_details=True) + + +@get("/") +def home(foo: Foo): # <-- foo is referenced in type annotation + return f"Hello, {foo.foo}!" + + +class Settings: + def __init__(self, greetings: str): + self.greetings = greetings + + +container.register(Settings, instance=Settings("example")) + + +class Home(Controller): + def __init__(self, settings: Settings): + # controllers are instantiated dynamically at every web request + self.settings = settings + + async def on_request(self, request: Request): + print("[*] Received a request!!") + + def greet(self): + return self.settings.greetings + + @get("/home") + async def index(self): + return self.greet() + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run(app, host="localhost", port=44777, log_level="debug") +``` + +It is also possible to configure the dependency injection container using the +`settings` namespace, like in the following example: + +```python +from blacksheep.settings.di import di_settings + + +def default_container_factory(): + return PunqDI(punq.Container()) + + +di_settings.use(default_container_factory) +``` + +!!! danger "Dependency injection libraries vary" + Some features might not be supported when using a different kind of container, + because not all libraries for dependency injection implement the notion of + `singleton`, `scoped`, and `transient` (most only implement `singleton` and + `transient`). diff --git a/docs/examples/marshmallow.md b/docs/examples/marshmallow.md index 3e52742..e394df6 100644 --- a/docs/examples/marshmallow.md +++ b/docs/examples/marshmallow.md @@ -73,7 +73,7 @@ async def invalid_body_handler(app, request, exc: InvalidBodyError): return pretty_json(exc.details, 400) -@app.router.post("/") +@router.post("/") def example(data: FromMultiSchema[BandMemberSchema]): return pretty_json(data.value) diff --git a/docs/getting-started.md b/docs/getting-started.md index 1618370..dc7b590 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -11,7 +11,8 @@ application.
It provides a general view, covering the following topics: ### Requirements -* [Python](https://www.python.org) version **3.8**, **3.9**, **3.10**, or **3.11** +* [Python](https://www.python.org) version >= **3.10** (3.8 and 3.9 are + supported but not recommended to follow this tutorial) * path to the python executable configured in the environment `$PATH` variable (tip: if you install Python on Windows using the official installer, enable the checkbox to update your `$PATH` variable during the installation) @@ -52,18 +53,19 @@ pip install blacksheep uvicorn ``` ### Creating and running a web application + Create a file `server.py`, and paste the following contents into it: ```python from datetime import datetime -from blacksheep import Application +from blacksheep import Application, get app = Application() -@app.route("/") +@get("/") def home(): - return f"Hello, World! {datetime.utcnow().isoformat()}" + return f"Hello, World! {datetime.now().isoformat()}" ``` Use the command below to start the application using port `44777`, with @@ -84,66 +86,31 @@ text answer from the web application: ![Hello World](./img/hello-world.png) ### Configuring routes + The current code configures a request handler for [HTTP GET](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/GET) method at the root path of the application: `"/"`. Note how a function decorator is used to register the `home` function as request handler: ```python -@app.route("/") +@get("/") def home(): ... ``` -This means that whenever a HTTP GET request is received at the root URL of +This means that whenever a [HTTP GET](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/GET) request is received at the root URL of the application (e.g. http://127.0.0.1:44777), the `home` function is used to handle the request and produce a response. -The application object provides two ways of defining routes: - -1. using `app.route` method -2. using `app.router`'s methods - -The first way enables defining request handlers specifying a path, and optional -[HTTP methods](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods) -(defaults to "GET" only). The second way provides methods for each -[HTTP method](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods). +Register more request handlers to handle more routes and +[HTTP methods](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods). +Update your `server.py` file to contain the following example, which includes +two request handlers: one for `HTTP GET /`, and one for `HTTP POST /`. ```python -# Handles HTTP GET and HEAD requests at /some-route -@app.route("/some-route", methods=["GET", "HEAD"]) -def mix_example(): - ... - - -# Handles HTTP GET at / -@app.router.get("/") -def example_get(): - ... - - -# Handles HTTP POST at / -@app.router.post("/") -def example_post(): - ... - - -# Handles HTTP DELETE at / -@app.router.delete("/") -def example_delete(): - ... -``` - -To reduce code verbosity when defining routes, it is possible to assign -router methods to variables. Edit the previous `server.py` file to replace its -contents with the following: - -```python -from blacksheep import Application +from blacksheep import Application, get, post app = Application() -get = app.router.get -post = app.router.post @get("/") @@ -182,17 +149,18 @@ For example, using [`curl`](https://curl.haxx.se): !!! info The application automatically handles requests for any path that - is not handled by the router, returning an `HTTP 404 Not Found` response; and - returns `HTTP 500 Internal Server Error` in case of unhandled exceptions - happening during code execution. + is not handled by the router, returning an `HTTP 404 Not Found` response; + and produces `HTTP 500 Internal Server Error` responses in case of + unhandled exceptions happening during code execution. ### Handling route parameters + So far the examples only showed request handlers that didn't use any input parameter. To define a request handler that uses a route parameter, define dynamic routes using the following syntax: ```python -@app.route("/{name}") +@get("/{name}") def greetings(name): return f"Hello, {name}!" ``` @@ -205,12 +173,12 @@ A route can contain several named parameters, separated by slashes, and dynamic fragments mixed with static fragments: ```python -@app.route("/{one}/{two}/{three}") +@get("/{one}/{two}/{three}") def multiple_parameters(one, two, three): return f"1: {one}, 2: {two}, 3: {three}!" -@app.route("/movies/{movie_id}/actors/{actor_id}") +@get("/movies/{movie_id}/actors/{actor_id}") def mix(movie_id, actor_id): ... ``` @@ -223,7 +191,7 @@ returns `HTTP 400 Bad Request` for invalid values, it is sufficient to decorate the function argument this way: ```python -@app.route("/lucky-number/{number}") +@get("/lucky-number/{number}") def only_numbers_here(number: int): return f"Lucky number: {number}\n" ``` @@ -249,17 +217,18 @@ def only_numbers_here(number: int): Invoke-WebRequest: Bad Request: Invalid value ['x'] for parameter `number`; expected a valid int. ``` -Several built-in types are handled automatically: e.g. `str`, `bool`, `int`, -`float`, `uuid.UUID`, `datetime.date`, `datetime.datetime`, `List[T]`, `Set[T]`. +Several built-in types are handled automatically, like `str`, `bool`, `int`, +`float`, `uuid.UUID`, `datetime.date`, `datetime.datetime`, `list[T]`, `set[T]`. ### Handling query string parameters + In the same way route parameters are injected automatically into request handlers by route parameters with matching names, `blacksheep` can handle query string parameters automatically. Adds this new fragment to your application: ```python -@app.route("/query/") +@get("/query") def query_greetings(name: str): return f"Hello, {name}!" ``` @@ -268,14 +237,12 @@ Then navigate to [http://localhost:44777/query?name=World](http://localhost:4477 --- -A request handler can use different query strings and query string parameters +A request handler can use different query strings, and query string parameters support lists. ```python -from typing import List - -@app.route("/query-list/") -def greetings_many(name: List[str]): +@get("/query-list") +def greetings_many(name: list[str]): return f"Hello, {', '.join(name)}!" # example: @@ -288,13 +255,14 @@ headers, cookies, query, route, request body, configured application services. These are treated in more details in the dedicated page about [Binders](./binders). ### Accessing the request object + To access the HTTP Request object directly, add a parameter called "request" to the signature of a request handler (type annotation is optional): ```python from blacksheep import Request -@app.route("/request-object/") +@get("/request-object") def request_object(request: Request): # the request object exposes methods to read headers, cookies, # body, route parameters @@ -303,14 +271,16 @@ def request_object(request: Request): !!! info You can name the request parameter any way you like (e.g. `request`, `req`, `foo`, etc.), - as long as you keep the correct type annotation (that is, `blacksheep.Request`). + as long as you keep the correct type annotation (`blacksheep.Request`). This subject will be treated in more details in a different section. ### Handling responses -Request handlers must return an instance of `blacksheep.Response` -class. The module `blacksheep.server.responses` provides several functions to -produce responses. You can also import them directly from the `blacksheep` package. + +Generally speaking, request handlers in BlackSheep must return an instance of +`blacksheep.messages.Response` class. The framework provides several functions +to produce responses for various use cases, defined in the +`blacksheep.server.responses` namespace. The following example shows how to serve a JSON response, using a class defined with [`dataclass`](https://docs.python.org/3/library/dataclasses.html). Delete @@ -320,7 +290,7 @@ all contents from the current `server.py` file and paste the following code: from dataclasses import dataclass from uuid import UUID, uuid4 -from blacksheep import Application, json +from blacksheep import Application, get, json @dataclass @@ -331,7 +301,6 @@ class Cat: app = Application() -get = app.router.get @get("/api/cats") @@ -356,8 +325,26 @@ to see the result, it will look like this: {"id":"b697358e-0f74-4449-840a-32c8db839244","name":"Pilou","active":true}] ``` -!!! info - Try also the `pretty_json` function in `blacksheep.server.responses`. +Note how the `json` function is used to create an instance of `Response` whose +content is a payload serialized into a JSON string. + +```python +from blacksheep import json + +response = json({"example": 1}) +response.content + + +response.content.body +b'{"example":1}' + +response.content.length +13 +``` + +!!! tip + Try also the `pretty_json` function in `blacksheep.server.responses`, which + returns indented JSON. For more granular control, it is possible to use the `blacksheep.messages.Response` class directly (read `blacksheep.server.responses` module for examples), and @@ -381,13 +368,34 @@ def get_cats(): return response ``` +User defined request handlers can also return arbitrary objects, which will +be automatically converted to JSON responses. The example above could also be +written this way: + + +```python +@get("/api/cats") +def get_cats() -> list[Cat]: + return [ + Cat(uuid4(), "Lampo", True), + Cat(uuid4(), "Milady", True), + Cat(uuid4(), "Meatball", True), + Cat(uuid4(), "Pilou", True), + ] +``` + +The rationale for this design choice is that JSON is the most commonly used +format to serialize objects today, and this feature is useful to reduce code +verbosity while making the return type explicit. Additionally, it enables +better generation of OpenAI Documentation. + ### Asynchronous request handlers -The examples so far show synchronous request handlers. To define asynchronous +The examples so far showed synchronous request handlers. To define asynchronous request handlers, define `async` functions: ```python -@app.route("/api/movies") +@get("/api/movies") async def get_movies(): # ... do something async (example) movies = await movies_provider.get_movies() @@ -397,14 +405,15 @@ async def get_movies(): Asynchronous code is described more in other sections of the documentation. ### Summary + This tutorial covered the ABCs of creating a BlackSheep application. The general concepts presented here apply to any kind of web framework: -* server side routing -* handling of query strings and route parameters -* handling of requests and responses +- server side routing +- handling of query strings and route parameters +- handling of requests and responses The next page will describe a more articulated scenario, including handling of HTML views on the server side, serving static files, and more. -* [Getting started with the MVC project template](../mvc-project-template/) +- [Getting started with the MVC project template](../mvc-project-template/) diff --git a/docs/img/cli-create-demo.gif b/docs/img/cli-create-demo.gif new file mode 100644 index 0000000..e507946 Binary files /dev/null and b/docs/img/cli-create-demo.gif differ diff --git a/docs/img/cli-group-help.png b/docs/img/cli-group-help.png new file mode 100644 index 0000000..2cb7b8b Binary files /dev/null and b/docs/img/cli-group-help.png differ diff --git a/docs/img/cli-help.png b/docs/img/cli-help.png new file mode 100644 index 0000000..5dbf5f7 Binary files /dev/null and b/docs/img/cli-help.png differ diff --git a/docs/img/cli-templates-details.png b/docs/img/cli-templates-details.png new file mode 100644 index 0000000..3b22f70 Binary files /dev/null and b/docs/img/cli-templates-details.png differ diff --git a/docs/img/mvc-template-v2.png b/docs/img/mvc-template-v2.png new file mode 100644 index 0000000..ce8da96 Binary files /dev/null and b/docs/img/mvc-template-v2.png differ diff --git a/docs/index.md b/docs/index.md index bf4685e..fabba1d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -15,25 +15,39 @@ pip install blacksheep ## BlackSheep offers... -* A rich code API, based on dependency injection and inspired by Flask and +- A rich code API, based on dependency injection and inspired by Flask and ASP.NET Core -* A typing-friendly codebase, which enables a comfortable development +- A typing-friendly codebase, which enables a comfortable development experience thanks to hints when coding with IDEs -* Built-in generation of OpenAPI Documentation, supporting version 3, YAML, and +- Built-in generation of OpenAPI Documentation, supporting version 3, YAML, and JSON -* A cross platform framework, using the most modern versions of Python -* Good performance +- A cross platform framework, using the most modern versions of Python +- Good performance ## Getting started To get started with BlackSheep, read these tutorials: -* [Basics](./getting-started/) -* [The MVC template](./mvc-project-template/) +- [Basics](./getting-started/) +- [The MVC template](./mvc-project-template/) -## Timeline +## Versions -The following timeline describe the current vision on the project, describing -features that are under development or taken into consideration. +The documentation here refers to the current version of the web framework. For +the documentation of first version of the framework, use the links below: -[timeline(./docs/timeline.yml)] +::cards:: cols=2 + +- title: Version 1 + url: /blacksheep/v1/ + content: > + The documentation of version 1 of the web framework is published + at `/blacksheep/v1/` + +- title: Migrating from v1 to v2 + url: /blacksheep/versions/migrating-to-v2/ + content: > + Go to the summary of changes between version 1 and version 2 of the web + framework. + +::/cards:: diff --git a/docs/mvc-project-template.md b/docs/mvc-project-template.md index dd42b49..85e60cd 100644 --- a/docs/mvc-project-template.md +++ b/docs/mvc-project-template.md @@ -1,9 +1,10 @@ # Getting started with the MVC project template + This tutorial explains how to create a BlackSheep application using the MVC ([_Model, View, Controller_](https://en.wikipedia.org/wiki/Model–view–controller)) project template, covering the following topics: -- [X] Creating a blacksheep application from a project template. +- [X] Creating an application from a project template, using the BlackSheep CLI. - [X] Routes defined using classes (controllers). - [X] Server side templating (views and models). - [X] Handling parameters in controllers. @@ -14,38 +15,90 @@ reading this one. ### Requirements -* [Python](https://www.python.org) version **3.7**, **3.8**, **3.9**, or **3.10** +* [Python](https://www.python.org) version >= **3.10** (3.8 and 3.9 are + supported but not recommended to follow this tutorial) * path to the python executable configured in the environment `$PATH` variable (tip: if you install Python on Windows using the official installer, enable the checkbox to update your `$PATH` variable automatically) * a text editor: any is fine; this tutorial uses [Visual Studio Code](https://code.visualstudio.com/Download) -## Downloading the project template -Navigate to [the BlackSheep MVC project template repository in GitHub](https://github.com/RobertoPrevato/BlackSheepMVC) -and download its code in one of the following ways: +## Introduction to the BlackSheep CLI + +The previous tutorial described the basics to create an application from +scratch. While that knowledge is important, it is usually not desirable to +start every project from scratch. BlackSheep offers a command-line interface +(CLI) that can be used to start new projects. The CLI can be installed from the +[Python Package Index](https://pypi.org/project/blacksheep-cli/) using the +`blacksheep-cli` package: -* either clone the repository using [`git`](https://git-scm.com) -* or download the project as zip file, using the _"Download ZIP"_ button, and unzip the contents into the desired location +```bash +pip install blacksheep-cli +``` -![MVC template](./img/mvc-template.png) +Once installed, the `create` command can be used to start new projects: + +```bash +blacksheep create +``` -!!! info - If you have a GitHub account, you can use the _"Use this template"_ button to create a new project, then clone it. +The CLI will prompt for input about various options. For the sake of this +tutorial, answer: -Then create a Python virtual environment as explained in the [previous -tutorial](./getting-started/#preparing-a-development-environment), for example -at the root folder of the project template, and install the dependencies of the -project template, using the following command: +- `tutorial` for project name +- `mvc` for the project template +- `Yes` for OpenAPI Documentation +- `essentials-configuration` to read settings +- `YAML` for app settings format ``` -pip install -r requirements.txt +✨ Project name: tutorial +🚀 Project template: mvc +📜 Use OpenAPI Documentation? Yes +🔧 Library to read settings essentials-configuration +🔩 App settings format (Use arrow keys) + » YAML + TOML + JSON + INI ``` -## Starting the application -Start the application using the following command: +!!! tip "blacksheep create" + It is possible to use the `create` command specifying directly project name + and template, like in: + + - `blacksheep create some_name` + - `blacksheep create some_name --template api` + +![MVC template](./img/mvc-template-v2.png) + +After a project is created, the CLI displays a message with instructions. ``` -uvicorn server:app --port 44777 --reload +────────────────────────────────────────────────────────────────────── +🏗️ Project created in tutorial +────────────────────────────────────────────────────────────────────── +-- What's next: + cd tutorial + pip install -r requirements.txt + python dev.py +``` + +Install the project dependencies + +- cd into the project folder +- create a new [Python virtual environment](https://docs.python.org/3/library/venv.html) (recommended but optional) +- install its dependencies with `pip install -r requirements.txt` + +## Starting the application + +Start the application using one of the following commands: + +```bash +# using the provided dev.py file (useful to debug) +python dev.py + +# or using the uvicorn CLI +uvicorn app.main:app --port 44777 --lifespan on --reload ``` And navigate to the local page, opening a browser at [`http://localhost:44777`](http://localhost:44777) @@ -57,10 +110,10 @@ The browser should display this page: Several things are happening because the web application is configured: -* to build and serve dynamic HTML pages -* to serve static files (e.g. pictures, JavaScript, CSS files) -* to expose an API and offer OpenAPI Documentation about the API -* to handle application settings and application start/stop events +- to build and serve dynamic HTML pages +- to serve static files (e.g. pictures, JavaScript, CSS files) +- to expose an API and offer OpenAPI Documentation about the API +- to handle application settings and application start/stop events Let's see these elements in order, but first let's get acquainted with the project's structure. @@ -70,61 +123,55 @@ The project is organized with the following folder structure: ``` ├── app -│   ├── (application files related to blacksheep front-end) +│   ├── (application files) │   │ │   ├── controllers │   │   └── (controller files, defining routes) │   │ +│   ├── docs +│   │   └── (files for OpenAPI Documentation) +│   │ │   ├── static │   │   └── (static files served by the web app) │   │ │   └── views │   └── (HTML templates, views compiled by the web app) │ -├── core -│   └── (core classes, common across front-end and business layer) -│ ├── domain │   └── (domain classes, POCO) │ ├── (root folder, where the main file starting the whole app resides) -├── server.py -└── settings.yaml +├── dev.py (file that can be used to start a development server, useful for debugging) +└── settings.dev.yaml (settings used when the env variable APP_ENV == dev) +└── settings.yaml (base settings file) ``` -* the `app` folder contains files that are specific to the web application, - settings, configuration, a folder for `controllers` that define routes, - folders for `static` files and one for `views` (HTML templates) -* other packages at the root of the project, like `core` and `domain`, should be +- the `app` folder contains files that are specific to the web application, + settings, a folder for `controllers` that define routes, folders for `static` + files and one for `views` (HTML templates) +- other packages at the root of the project, like `domain`, should be abstracted from the web server and should be reusable in other kinds of applications (for example, a CLI) -* the root folder contains the `server.py` file to start the application, and - a `settings.yaml` file that is loaded when the application process starts, to - read settings for the application +- the root folder contains the `dev.py` file to start the application in + development mode, and settings files with `.yaml` extension that are read + when the application starts (since the YAML format was selected when using + the `blacksheep create` command) -The project uses `onion architecture`. For example a valid scenario would be +The project uses `onion architecture`. For example, a valid scenario would be to add an additional package for the data access layer, and implement the -business logic in sub-packages inside the `domain` folder. - -The root folder also contains a LICENSE file: this refers to the project -template itself and can be deleted, a `mypy.ini` file for those who use -[`MyPy`](http://www.mypy-lang.org), and a workspace file for `Visual Studio Code`, -for developers who use this text editor. +business logic in modules inside the `domain` folder. ## Open the project with a text editor -Open the project's folder using your favorite text editor. The template -includes a workspace file for `Visual Studio Code`, which contains recommended -settings to work with Python (recommended extensions, and [`black`](https://github.com/psf/black) formatter, [`flake8`](https://flake8.pycqa.org/en/latest/), -[`mypy`](http://www.mypy-lang.org), and [`pylance`](https://marketplace.visualstudio.com/items?itemName=ms-python.vscode-pylance)). -These are of course a matter of personal preference and can be ignored or removed. +Open the project's folder using your favorite text editor. ![Visual Studio Code](./img/vs-code-mvc.png) ## Routes defined using classes (controllers) + The previous tutorial described how routes can be defined using functions: ```python -@app.route("/") +@get("/") async def home(): ... ``` @@ -146,37 +193,36 @@ class Greetings(Controller): ``` -Finally, open `app/controllers/__init__.py` and import the new controller, -adding this line to it: - -```python -from .greetings import Greetings -``` - -If the application was run using the `--reload` option, its process should -reload automatically; otherwise stop and restart the application manually.
-Navigate to [`http://localhost:44777/hello-world`](http://localhost:44777/hello-world): -it will display the response from the `Greetings.index` method. +Stop and restart the application, then navigate to +[`http://localhost:44777/hello-world`](http://localhost:44777/hello-world): it +will display the response from the `Greetings.index` method. When the path of a web request matches a route defined in a controller type, a -new instance of that `Controller` is created. In other words, every instance of controller is scoped to a specific web request. Just like function handlers, +new instance of that `Controller` is created. In other words, every instance of +controller is scoped to a specific web request. Just like function handlers, controllers support automatic injection of parameters into request handlers, and also dependency injection into their constructors (`__init__` methods). This is -an excellent feature that improves development speed and enables cleaner code -(compare this approach with a scenario where all dependencies needs to be imported - and referenced inside function bodies by hand). +a feature that improves development speed and enables cleaner code (compare +this approach with a scenario where all dependencies needs to be imported and +referenced inside function bodies by hand). The `Controller` class implements methods to return values and offers `on_request` and `on_response` extensibility points. +!!! tip "Controllers and routes automatic import" + Python modules defined inside `controllers` and `routes` packages are + automatically imported by a BlackSheep application. The automatic import + happens relatively to the namespace where the application is instantiated. + ## Server side templating (views and models) + Server side templating refers to the ability of a web application to generate -HTML pages from templates and dynamic variables. BlackSheep does this -using the wondeful [`Jinja2` library](https://palletsprojects.com/p/jinja/) +HTML pages from templates and dynamic variables. By default, BlackSheep does +this using the [`Jinja2` library](https://palletsprojects.com/p/jinja/) by the [Pallets](https://palletsprojects.com) team. -To see how this works in practice when using `Controllers`, add a new method to -the new `Greetings` controller created previously to look like this: +To see how this works in practice when using `Controllers`, edit the `Greetings` +controller created previously to look like this: ```python from blacksheep.server.controllers import Controller, get @@ -190,11 +236,11 @@ class Greetings(Controller): ``` Then, create a new folder inside `views` directory, called "greetings", and -add an HTML file named "hello.html". +add an HTML file named "hello.jinja". ![New view](./img/new-view.png) -Copy the following contents into the `hello.html`: +Copy the following contents into `hello.jinja`: ```html
@@ -206,14 +252,14 @@ Now navigate to [http://localhost:44777/hello-view](http://localhost:44777/hello to see the response from the new HTML view. Note how convention over configuration is used in this case, to determine that -`./views/greetings/hello.html` file must be used, because of the convention: -`./views/{CONTROLLER_NAME}/{METHOD_NAME}.html`. +`./views/greetings/hello.jinja` file must be used, because of the convention:
+`./views/{CONTROLLER_NAME}/{METHOD_NAME}.jinja`. The view currently is an HTML fragment, not a full document. To make it a -full page, modify `hello.html` to use the application layout: +full page, modify `hello.jinja` to use the application layout: ```html -{%- extends "layout.html" -%} +{%- extends "layout.jinja" -%} {%- block title -%} Hello Page! {%- endblock -%} @@ -235,8 +281,8 @@ full page, modify `hello.html` to use the application layout: Refresh the page at [http://localhost:44777/hello-view](http://localhost:44777/hello-view) to see the result. -In this case, a page layout is applied using: `{%- extends "layout.html" -%}`, -with several blocks going in various area of `layout.html`. For more information +In this case, a page layout is applied using: `{%- extends "layout.jinja" -%}`, +with several blocks going in various area of `layout.jinja`. For more information on layouts and features of the templating library, refer to [Jinja2 documentation](https://jinja2docs.readthedocs.io/en/stable/). @@ -247,7 +293,7 @@ To include dynamic content into an HTML template, use mustaches _`{{name}}`_ placeholders and pass a model having properties whose names match their key to the `view` function. -For example, modify `hello.html` to use dynamic content from a model: +For example, modify `hello.jinja` to use dynamic content from a model: ```html
@@ -342,10 +388,10 @@ web requests at `http://localhost:44777/scripts/example.js` will be resolved with this file and related information. When handling static files, BlackSheep automatically takes care of several details: -* it handles ETag response header, If-None-Match request header and HTTP 304 Not Modified +- it handles ETag response header, If-None-Match request header and HTTP 304 Not Modified responses if files don't change on file system -* it handles HTTP GET requests returning file information -* it handles Range requests, to support pause and restore downloads out of the box +- it handles HTTP GET requests returning file information +- it handles Range requests, to support pause and restore downloads out of the box and enable optimal support for videos (videos can be downloaded from a certain point in time) @@ -360,14 +406,41 @@ only the most common file extensions used in web applications. Paths starting with "/" are always considered absolute paths starting from the root of the web site. +## Strategy for application settings + +The `API` and the `MVC` project templates include a strategy to read and +validate application settings, from various sources, and supporting multiple +system environments (like `dev`, `test`, `prod` environments). + +- [`Pydantic`](https://docs.pydantic.dev/latest/) is always used to describe and validate application settings. +- Application settings can be read from various sources using either + `Pydantic v1 BaseSettings` class, or `essentials-configuration`. +- When using `essentials-configuration`, use the `APP_ENV` environment variable + to control the application environment and to use environment specific + settings from dedicated files using the pattern: + `settings.{{env_name}}.{{format}}`, like `settings.test.yaml`, + `settings.prod.toml`. + +For more information on application settings and the recommended way to apply +configuration depending on the application environment, refer to [_Settings_](/blacksheep/settings/). + ## Summary + This tutorial covered some higher level topics of a BlackSheep application. The general concepts presented here apply to many kinds of web framework: -* server side templating of HTML views -* serving of static files -* use of MVC architecture +- server side templating of HTML views +- serving of static files +- use of MVC architecture The next pages describes the built-in support for [dependency injection](../dependency-injection), and automatic generation of [OpenAPI Documentation](../openapi). + +!!! info "For more information..." + For more information about Server Side Rendering, read [_Templating_](/blacksheep/templating/).
+ For more information about the BlackSheep CLI, read [_More about the CLI_](/blacksheep/cli/). + +!!! tip "Don't miss the api project template" + Try also the `api` project template, to start new Web API projects that + don't handle HTML views. diff --git a/docs/openapi.md b/docs/openapi.md index e559398..3749e11 100644 --- a/docs/openapi.md +++ b/docs/openapi.md @@ -9,6 +9,7 @@ details. This page describes the following: - [X] How to handle common responses. - [X] Expose the documentation for anonymous access. - [X] Support for [ReDoc UI](https://github.com/Redocly/redoc). +- [X] How to implement a custom `UIProvider`. ## Introduction to OpenAPI Documentation Citing from the [Swagger web site](https://swagger.io/specification/), at the @@ -42,7 +43,7 @@ OpenAPI Documentation and exposing a Swagger UI in BlackSheep: ```python from dataclasses import dataclass -from blacksheep import Application +from blacksheep import Application, get from blacksheep.server.openapi.v3 import OpenAPIHandler from openapidocs.v3 import Info @@ -57,7 +58,7 @@ class Foo: foo: str -@app.route("/foo") +@get("/foo") async def get_foo() -> Foo: return Foo("Hello!") ``` @@ -159,7 +160,7 @@ instance of `OpenAPIHandler` as a decorator: ```python @docs(responses={200: "Returns a text saying OpenAPI Example"}) -@app.route("/") +@get("/") def home(): return "OpenAPI Example" ``` @@ -186,7 +187,7 @@ An endpoint description can be specified either using a `docstring`: ```python @docs(responses={200: "Returns a text saying OpenAPI Example"}) -@app.route("/") +@get("/") async def home(): """ This example is used to demonstrate support for OpenAPI in BlackSheep. @@ -202,7 +203,7 @@ Or in the `@docs` decorator: description="The endpoint itself doesn't do anything useful.", responses={200: "Returns a text saying OpenAPI Example"}, ) -@app.route("/") +@get("/") async def home(): return "OpenAPI Example" ``` @@ -224,7 +225,7 @@ To exclude certain endpoints from the API documentation, use `@docs.ignore()`: ```python @docs.ignore() -@app.route("/hidden-from-docs") +@get("/hidden-from-docs") async def hidden_endpoint(): return "This endpoint won't appear in documentation" ``` @@ -293,7 +294,7 @@ class Cat: 404: "Cat not found", }, ) -@app.route("/api/cats/{cat_id}") +@get("/api/cats/{cat_id}") def get_cat_by_id(cat_id: UUID): cat = ... # TODO: implement the logic that fetches a cat by id return json(cat) @@ -326,7 +327,7 @@ code clean: from apidocs.cats import get_cat_docs @docs(get_cat_docs) -@app.route("/api/cats/{cat_id}") +@get("/api/cats/{cat_id}") def get_cat_by_id(cat_id: UUID): cat = ... # TODO: implement the logic that fetches a cat by id return json(cat) @@ -342,7 +343,7 @@ To mark and endpoint as deprecated, use `@docs.deprecated()`: ```python @docs.deprecated() -@app.route("/some-deprecated-api") +@get("/some-deprecated-api") async def deprecated_endpoint(): return "This endpoint is deprecated" ``` @@ -501,7 +502,7 @@ docs = OpenAPIHandler(info=Info(title="Example", version="0.0.1")) docs.bind_app(app) -@app.router.get("/api/orders") +@router.get("/api/orders") async def get_orders( page: FromQuery[int] = FromQuery(1), page_size: FromQuery[int] = FromQuery(30), @@ -623,7 +624,7 @@ docs = OpenAPIHandler(info=Info(title="Example", version="0.0.1")) docs.bind_app(app) -@app.router.get("/api/orders") +@router.get("/api/orders") @docs( parameters={ "page": ParameterInfo(description="Page number"), @@ -653,7 +654,7 @@ The following sections show the previous example re-written to use docstrings. ```python - @app.router.get("/api/orders") + @router.get("/api/orders") async def get_orders( page: FromQuery[int] = FromQuery(1), page_size: FromQuery[int] = FromQuery(30), @@ -673,7 +674,7 @@ The following sections show the previous example re-written to use docstrings. ```python - @app.router.get("/api/orders") + @router.get("/api/orders") async def get_orders( page: FromQuery[int] = FromQuery(1), page_size: FromQuery[int] = FromQuery(30), @@ -692,7 +693,7 @@ The following sections show the previous example re-written to use docstrings. ```python - @app.router.get("/api/orders") + @router.get("/api/orders") async def get_orders( page: FromQuery[int] = FromQuery(1), page_size: FromQuery[int] = FromQuery(30), @@ -756,6 +757,109 @@ docs.ui_providers.append(ReDocUIProvider()) docs.include = lambda path, _: path.startswith("/api/") ``` +### How to implement a custom UIProvider + +The BlackSheep package includes some static files to offer a good user +experience in some circumstances. These include HTML pages used when enabling +Swagger UI or ReDoc UI. + +To control those pages, for example to alter the HTML structure or use different +sources for JavaScript and CSS files (which by the way could be the BlackSheep +application serving the OpenAPI specification files), it is recommended to: + +- define a custom implementation of `UIProvider` +- maintain the desired HTML file + +Example: + + +```python +from dataclasses import dataclass +from pathlib import Path + +from blacksheep import Application +from blacksheep.server.openapi.v3 import OpenAPIHandler +from blacksheep.server.openapi.ui import SwaggerUIProvider, UIOptions +from openapidocs.v3 import Info + +app = Application() + + +class CustomUIProvider(SwaggerUIProvider): + def get_openapi_ui_html(self, options: UIOptions) -> str: + _template = Path("example.html").read_text() + return _template.replace("{options.spec_url}", options.spec_url) + + +docs = OpenAPIHandler(info=Info(title="Example API", version="0.0.1")) +# Set the UI provider as desired: +docs.ui_providers = [CustomUIProvider()] +docs.bind_app(app) + + +@dataclass +class Foo: + foo: str + + +@route("/foo") +async def get_foo() -> Foo: + return Foo("Hello!") +``` + +_example.html_: + +```html + + + + My desired title + + + + +
+ + + + +``` + +Python code highlight: + +```diff ++from blacksheep.server.openapi.ui import SwaggerUIProvider, UIOptions +from openapidocs.v3 import Info + +app = Application() + + ++class CustomUIProvider(SwaggerUIProvider): ++ def get_openapi_ui_html(self, options: UIOptions) -> str: ++ _template = Path("example.html").read_text() ++ return _template.replace("{options.spec_url}", options.spec_url) + + +docs = OpenAPIHandler(info=Info(title="Example API", version="0.0.1")) +# Set the UI provider as desired: ++docs.ui_providers = [CustomUIProvider()] +docs.bind_app(app) +``` + ### Changing operations ids When OpenAPI Documentation is generated, operation ids are obtained from the name of the Python function definitions. @@ -764,7 +868,7 @@ For example, having a `get_foo` request handler, generates an object having `operationId` equal to "get_foo": ```python -@app.router.get("/foo") +@router.get("/foo") async def get_foo() -> Foo: return Foo("Hello!") ``` diff --git a/docs/openid-connect.md b/docs/openid-connect.md index 42520e0..7262c76 100644 --- a/docs/openid-connect.md +++ b/docs/openid-connect.md @@ -5,7 +5,7 @@ meaning that it can be easily integrated with identity provider services such as: * [Auth0](https://auth0.com) -* [Azure Active Directory](https://azure.microsoft.com/en-us/services/active-directory/) +* [Entra ID](https://www.microsoft.com/en-us/security/business/identity-access/microsoft-entra-id) * [Azure Active Directory B2C](https://docs.microsoft.com/en-us/azure/active-directory-b2c/overview) * [Okta](https://www.okta.com) @@ -45,7 +45,7 @@ use_openid_connect( ) -@app.route("/") +@get("/") async def home(user: Identity): if user.is_authenticated(): response = pretty_json(user.claims) @@ -66,7 +66,7 @@ request by an authentication middleware, and can be read as in the provided examples: ```python -@app.route("/") +@get("/") async def home(user: Identity): if user.is_authenticated(): ... @@ -74,13 +74,13 @@ async def home(user: Identity): ### use_openid_connect -| Parameter | Type, default | Description | -| ------------------ | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| app | Application | Instance of BlackSheep application. | -| settings | OpenIDSettings | Instance of OpenIDSettings. | -| tokens_store | Optional[BaseTokensStore] = None | Optional instance of `BaseTokensStore`, used to store and restore `access_token`s and `refresh_token`s. | -| parameters_builder | Optional[ParametersBuilder] = None | Optional instance of `ParametersBuilder`, used to handle parameters configured in redirects and requests to the authorization server. | -| is_default | bool = True | If default, clients are automatically redirected to the `sign-in` page when a non-authenticated user tries to access in `GET` a web page that requires authentication. | +| Parameter | Type, default | Description | +| ------------------ | ----------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| app | Application | Instance of BlackSheep application. | +| settings | OpenIDSettings | Instance of OpenIDSettings. | +| auth_handler | Optional[OpenIDTokensHandler] = None (CookiesOpenIDTokensHandler) | Instance of OpenIDTokensHandler that can handle tokens for requests and responses for the OpenID Connect flow. This class is responsible of communicating tokens to clients, and restoring tokens context for following requests. | +| parameters_builder | Optional[ParametersBuilder] = None | Optional instance of `ParametersBuilder`, used to handle parameters configured in redirects and requests to the authorization server. | +| is_default | bool = True | If default, clients are automatically redirected to the `sign-in` page when a non-authenticated user tries to access in `GET` a web page that requires authentication. | ### OpenIDSettings @@ -97,10 +97,13 @@ The `OpenIDSettings` class has the following properties: | logout_path | str = "/sign-out" | The local path to the sign-out endpoint (this removes authentication cookie). | | post_logout_redirect_path | str = "/" | The local path to which a user is redirected after signing-out. | | callback_path | str = "/authorization-callback" | The local path to handle the redirect after a user signs-in (the reply_url in the identity server must be configured accordingly). | +| refresh_token_path | str = "/refresh-token" | The local path used to handle refresh tokens to obtain new tokens . | | scope | str = "openid profile email" | The scope of the request, by default an `id_token` is obtained with email and profile. | +| response_type | str = "code" | Type of OAuth response. | | redirect_uri | Optional[str] = None | If specified, the redirect URL that must match the one configured for the application. If not provided, a redirect_url is obtained automatically (see note 🗡️). | | scheme_name | str = "OpenIDConnect" | The name of the authentication scheme, affecting the name of authentication cookies (see note 🍒). | | error_redirect_path | Optional[str] = None | If specified, the local path to which a user is redirected in case of error. | +| end_session_endpoint | Optional[str] = None | If specified, the local path to which the user can log out. | Notes: @@ -121,56 +124,60 @@ application obtains both an `id_token` and an `access_token` for an API, looks like the following: ```python -import os -from blacksheep import Application, html, pretty_json -from blacksheep.server.authentication.oidc import ( - OpenIDSettings, - use_openid_connect, - CookiesTokensStore, -) -from guardpost.authentication import Identity +""" +This example shows how to configure an OpenID Connect integration with Auth0, obtaining +an id_token, an access_token, and a refresh_token. The id_token is exchanged with the +client using a response cookie (also used to authenticate users +for following requests), while access token and the refresh token are not stored and +can only be accessed using optional events. +""" +import uvicorn +from blacksheep.server.application import Application +from blacksheep.server.authentication.oidc import OpenIDSettings, use_openid_connect +from dotenv import load_dotenv -app = Application() +from common.routes import register_routes +from common.secrets import Secrets + +load_dotenv() +secrets = Secrets.from_env() +app = Application(show_error_details=True) -# basic Auth0 integration that handles only an id_token +# Auth0 with custom scope use_openid_connect( app, OpenIDSettings( - authority="", - audience="", - client_id="", - client_secret=os.environ["AUTH0_APP_SECRET"], - callback_path="", - scope="openid profile custom_scope", + authority="https://neoteroi.eu.auth0.com", + audience="http://localhost:5000/api/todos", + client_id="OOGPl4dgG7qKsm2IOWq72QhXV4wsLhbQ", + client_secret=secrets.auth0_client_secret, + callback_path="/signin-oidc", + scope="openid profile read:todos", + error_redirect_path="/sign-in-error", ), - tokens_store=CookiesTokensStore(), ) +register_routes(app) -@app.route("/") -async def home(user: Identity): - if user.is_authenticated(): - response = pretty_json(user.claims) - - return response - return html("Sign in
") +if __name__ == "__main__": + uvicorn.run(app, host="127.0.0.1", port=5000, log_level="debug") ``` -An integration with `Azure Active Directory`: +An integration with `Entra ID`: ```python -use_openid_connect( +handler = use_openid_connect( app, OpenIDSettings( - authority="https://login.microsoftonline.com//v2.0/", - client_id="", - client_secret=os.environ["AAD_APP_SECRET"], - scope="openid profile custom_scope", + authority="https://login.microsoftonline.com/b62b317a-19c2-40c0-8650-2d9672324ac4/v2.0/", + client_id="499adb65-5e26-459e-bc35-b3e1b5f71a9d", + client_secret=secrets.aad_client_secret, + scope="openid profile offline_access email " + "api://65d21481-4f1a-4731-9508-ad965cb4d59f/example", ), - tokens_store=CookiesTokensStore(), ) ``` @@ -180,12 +187,12 @@ An integration with `Okta`, using the `default` authorization server: use_openid_connect( app, OpenIDSettings( - discovery_endpoint="https://.okta.com/oauth2/default/.well-known/oauth-authorization-server", - client_id="", - client_secret=os.environ["OKTA_APP_SECRET"], - scope="openid email custom_scope", + discovery_endpoint="https://dev-34685660.okta.com/oauth2/default/.well-known/oauth-authorization-server", + client_id="0oa2gy88qiVyuOClI5d7", + client_secret=secrets.okta_client_secret, + callback_path="/authorization-code/callback", + scope="openid read:todos", ), - tokens_store=CookiesTokensStore(), ) ``` @@ -238,17 +245,20 @@ async def on_error(context, data: Dict[str, Any]): ## Storing tokens By default, `access_token`(s) and `refresh_token`(s) are not stored. To store -them, the `use_openid_connect` function supports a `BaseTokensStore` parameter -that will handle storing and restoring tokens. A concrete implementation is +them, the `auth_handler.tokens_store` property. The examples repository includes +an example that shows how to use `Redis` to store tokens: +_[Redis example](https://github.com/Neoteroi/BlackSheep-Examples/blob/main/oidc/scopes_redis_aad.py)_ + +A concrete implementation is provided in `CookiesTokenStore`, storing tokens in cookies. It is possible to -create custom implementations of the `BaseTokensStore`, to use other mechanisms, +create custom implementations of the `TokensStore`, to use other mechanisms, for example to store tokens in a Redis cache. When a user is authenticated, and has an `access_token` (and/or a `refresh_token`), they are accessible through the `Identity`: ```python -@app.route("/") +@get("/") async def home(user: Identity): if user.is_authenticated(): print(user.access_token) @@ -256,7 +266,7 @@ async def home(user: Identity): ... ``` -To see how to use a `BaseTokensStore`, refer to the examples above that use +To see how to use a `TokensStore`, refer to the examples above that use the built-in `CookiesTokensStore`. ## Useful references diff --git a/docs/remotes.md b/docs/remotes.md index 3552d8d..874015c 100644 --- a/docs/remotes.md +++ b/docs/remotes.md @@ -16,7 +16,7 @@ For example: original client IP address must also be forwarded in a header. * the *path* of web requests can be changed while being proxied (e.g. NGINX configured to proxy requests to `/example` to the root `/` of a web - application) + application). This information may be important in request processing, for example in redirects, authentication, link generation when absolute URLs are needed, and diff --git a/docs/request-handlers.md b/docs/request-handlers.md index dba6100..ca04e9c 100644 --- a/docs/request-handlers.md +++ b/docs/request-handlers.md @@ -2,7 +2,7 @@ The previous pages describe that a request handler in BlackSheep is a function associated to a route, having the responsibility of handling web requests. -This page describes `request handlers` in detail, presenting the following: +This page describes `request handlers` in detail, covering the following: - [X] Request handler normalization. - [X] Using asynchronous and synchronous code. @@ -22,11 +22,10 @@ async def normal_handler(request: Request) -> Response: To be a request handler, a function must be associated to a route: ```python -from blacksheep import Application, Request, Response, text +from blacksheep import Application, Request, Response, get, text app = Application() -get = app.router.get @get("/") @@ -51,6 +50,14 @@ def sync_handler(request: Request) -> Response: ``` +!!! danger "Avoid blocking code in synchronous methods!" + When a request handler is defined as synchronous method, BlackSheep + assumes that the author of the code knows what they are doing and about + asynchronous programming, and the response can be returned immediately + without I/O or CPU intensive operations that would block the event loop. + BlackSheep does nothing to prevent blocking the event loop, if you add + blocking operations in your code. + Similarly, request handlers are normalized when their function signature is different than the normal one. For example a request handler can be defined without arguments, and returning a plain `str` or an instance of an object @@ -84,8 +91,8 @@ def get_example_cat() -> Cat: ### Automatic binding of parameters -An important feature enabled by function normalization is the automatic binding -of request parameters, as described in the `Getting Started` pages. Common +An important feature enabled by function normalization is automatic binding of +request parameters, as described in the `Getting Started` pages. Common scenarios are using route parameters, and query string parameters: ```python @@ -109,6 +116,7 @@ from the query string and parsed, if present, otherwise default values are used. ### Explicit and implicit binding + All examples so far showed how to use implicit binding of request parameters. In the `get_cats` example above, all parameters are _implicitly_ bound from the request query string. To enable more scenarios, `BlackSheep` provides also @@ -141,12 +149,14 @@ async def create_cat( More details about bindings are described in _[Binders](../binders/)_. ### Normalization and OpenAPI Documentation + Request handler normalization enables also a more accurate generation of [OpenAPI Documentation](../openapi/), since the web framework knows that request handlers need input from query string, routes, headers, cookies, etc.; and produce responses of a certain type. ## Using asynchronous and synchronous code. + BlackSheep supports both asynchronous and synchronous request handlers. Request handlers don't need to be asynchronous in those scenarios when the response is well-known and can be produced without doing any I/O bound operation or any @@ -154,11 +164,10 @@ CPU intensive operation. This is the case for example of redirects, and the previous "Hello, There!" example: ```python -from blacksheep import Application, Request, Response, text, redirect +from blacksheep import Application, Request, Response, get, text, redirect app = Application() -get = app.router.get @get("/sync") @@ -172,10 +181,10 @@ def redirect_example() -> Response: ``` Request handlers that do I/O bound operations or CPU intensive operations -should be instead `async`, to not impede the work of the web server's loop. For -example, if information are fetched from a database or a remote API when -handling a web request handler, it is a good practice to use asynchronous code -to reduce RAM consumption and not impede the event loop of the web application. +should be instead `async`, to not hinder the performance of the web server. For +example, if information is fetched from a database or a remote API when +handling a web request handler, it is correct to use asynchronous code +to reduce RAM consumption and not block the event loop of the web application. !!! warning If an operation is CPU intensive (e.g. involving file operations, diff --git a/docs/requests.md b/docs/requests.md index d56e804..c612f1e 100644 --- a/docs/requests.md +++ b/docs/requests.md @@ -19,11 +19,10 @@ It is possible to read query and route parameters from an instance of request headers can be read from the request: ```python -from blacksheep import Application, Request, Response, text +from blacksheep import Application, Request, Response, get, text app = Application() -get = app.router.get @get("/{something}") @@ -53,11 +52,10 @@ into the desired type, and improve development experience and source code. The same example can be achieved in the following way: ```python -from blacksheep import Application, Request, Response, text, FromHeader, FromQuery +from blacksheep import Application, Request, Response, get, text, FromHeader, FromQuery app = Application() -get = app.router.get class FromAcceptHeader(FromHeader[str]): @@ -90,10 +88,9 @@ Something: example ```python from typing import Optional -from blacksheep import Application, Response, text, FromHeader, FromCookie +from blacksheep import Application, Response, get, text, FromHeader, FromCookie app = Application() -get = app.router.get class FromAcceptHeader(FromHeader[str]): @@ -125,7 +122,7 @@ kinds. ```python from dataclasses import dataclass - from blacksheep import FromJSON + from blacksheep import FromJSON, post @dataclass @@ -178,7 +175,7 @@ kinds. === "Using binders (recommended)" ```python - from blacksheep import FromForm + from blacksheep import FromForm, post class SomethingInput: @@ -293,7 +290,7 @@ the event loop: === "Directly from the request" ```python - from blacksheep import created + from blacksheep import created, post @post("/upload") diff --git a/docs/responses.md b/docs/responses.md index 2bc4173..7700961 100644 --- a/docs/responses.md +++ b/docs/responses.md @@ -15,10 +15,9 @@ The following example shows how to use the low level objects to create a response with status 200 and body "Hello, World": ```python -from blacksheep import Application, Response, Content +from blacksheep import Application, Response, Content, get app = Application() -get = app.router.get @get("/") @@ -37,10 +36,9 @@ For example, the `json` function in `blacksheep.server.responses` produces a response object having a JSON body. ```python -from blacksheep import Application, json +from blacksheep import Application, get, json app = Application() -get = app.router.get @get("/") @@ -59,10 +57,9 @@ Produces the following response body: The framework also allows to define a request handler this way: ```python -from blacksheep import Application +from blacksheep import Application, get app = Application() -get = app.router.get @get("/") @@ -147,11 +144,10 @@ not strings. To set a cookie, use the `set_cookie` method of the `Response` class: ```python -from blacksheep import Application, json +from blacksheep import Application, get, json from blacksheep.cookies import Cookie app = Application() -get = app.router.get @get("/") @@ -175,11 +171,10 @@ The following example shows how to set a cookie with `HttpOnly` and lasting ```python from datetime import datetime, timedelta -from blacksheep import Application, Response, json +from blacksheep import Application, Response, get, json from blacksheep.cookies import Cookie -app = Application(show_error_details=True) -get = app.router.get +app = Application() @get("/") @@ -191,7 +186,7 @@ def home() -> Response: "foo2", "value2", http_only=True, - expires=datetime.utcnow() + timedelta(minutes=15), + expires=datetime.now() + timedelta(minutes=15), ) ) @@ -200,17 +195,17 @@ def home() -> Response: Cookie's options: -| Parameter | Type (default value) | Description | -| ------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| **name** | `str` | Cookie's name. | -| **value** | `str` | Cookie's value. | -| **expires** | `datetime | null` (`null`) | The maximum lifetime of the cookie as an HTTP-date timestamp. If unspecified, the cookie becomes a session cookie. A session finishes when the client shuts down, and session cookies will be removed. | -| **domain** | `str | null` (`null`) | Host to which the cookie will be sent. | -| **path** | `str | null` (`null`) | Optional path to restrict access to the cookie. | -| **http_only** | `bool` (`False`) | Optional boolean to forbid JavaScript access to the cookie. | -| **secure** | `bool` (`False`) | Optionally instructs browsers to send the cookie only over HTTPS (or `localhost`). | -| **max_age** | `int` (`-1`) | Optional number of seconds until the cookie expires. A zero or negative number will expire the cookie immediately. | -| **same_site** | `CookieSameSiteMode` (CookieSameSiteMode.UNDEFINED) | Controls the cookie's `Same-Site` attribute. | +| Parameter | Type (default value) | Description | +| ------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | +| **name** | `str` | Cookie's name. | +| **value** | `str` | Cookie's value. | +| **expires** | `datetime | null` (`null`) | The maximum lifetime of the cookie as an HTTP-date timestamp. If unspecified, the cookie becomes a session cookie. A session finishes when the client shuts down, and session cookies will be removed. | +| **domain** | `str | null` (`null`) | Host to which the cookie will be sent. | +| **path** | `str | null` (`null`) | Optional path to restrict access to the cookie. | +| **http_only** | `bool` (`False`) | Optional boolean to forbid JavaScript access to the cookie. | +| **secure** | `bool` (`False`) | Optionally instructs browsers to send the cookie only over HTTPS (or `localhost`). | +| **max_age** | `int` (`-1`) | Optional number of seconds until the cookie expires. A zero or negative number will expire the cookie immediately. | +| **same_site** | `CookieSameSiteMode` (CookieSameSiteMode.UNDEFINED) | Controls the cookie's `Same-Site` attribute. | #### `CookieSameSiteMode` enum @@ -284,12 +279,12 @@ using a `StreamedContent` object bound to a generator yielding bytes. ```python import asyncio -from blacksheep import Application, Response, StreamedContent +from blacksheep import Application, Response, StreamedContent, get -app = Application(show_error_details=True) +app = Application() -@app.router.get("/chunked-text") +@get("/chunked-text") async def get_chunked_text(request): async def provider(): yield b"Lorem " @@ -308,12 +303,12 @@ Alternatively, it is possible to use the `file` function from ```python import asyncio -from blacksheep import Application, file, ContentDispositionType +from blacksheep import Application, ContentDispositionType, file, get app = Application(show_error_details=True) -@app.router.get("/chunked-text") +@router.get("/chunked-text") async def get_chunked_text(request): async def provider(): yield b"Lorem " diff --git a/docs/routing.md b/docs/routing.md index c7a6a25..1397f94 100644 --- a/docs/routing.md +++ b/docs/routing.md @@ -13,6 +13,8 @@ This page describes: - [X] How to use route parameters. - [X] How to define a catch-all route. - [X] How to define a fallback route. +- [X] How to use sub-routers and filters. +- [X] How to use the default router and other routers. ## Defining request handlers @@ -50,48 +52,21 @@ The following example shows how to define a request handler for the root path of a web application "/": ```python -from blacksheep import Application, text - -app = Application(show_error_details=True) - - -@app.router.get("/") -def hello_world(): - return "Hello World" -``` - -It is possible to assign router methods to variables, to reduce code verbosity: - -```python -from blacksheep import Application, text - -app = Application(show_error_details=True) -get = app.router.get -post = app.router.post +from blacksheep import get @get("/") def hello_world(): return "Hello World" - - -@post("/message") -def create_message(text: str): - return "TODO" - ``` -Alternatively, the application offers a `route` method: +Alternatively, the application router offers a `route` method: ```python - -@app.route("/foo") -async def example_foo(): - # HTTP GET /foo - return "Hello, World!" +from blacksheep import route -@app.route("/example", methods=["GET", "HEAD", "TRACE"]) +@route("/example", methods=["GET", "HEAD", "TRACE"]) async def example(): # HTTP GET /example # HTTP HEAD /example @@ -183,8 +158,7 @@ def get_cat(cat_id): ... ``` -It is also possible to specify the expected type, using standard `typing` -annotations: +It is also possible to specify the expected type, using `typing` annotations: ```python @@ -253,11 +227,11 @@ The following value patterns are built-in: | Value pattern | Description | | ------------- | --------------------------------------------------------------------------------- | -| str | Any value that doesn't contain a slash "/". | -| int | Any value that contains only numeric characters. | -| float | Any value that contains only numeric characters and eventually a dot with digits. | -| path | Any value to the end of the path. | -| uuid | Any value that matches the UUID value pattern. | +| `str` | Any value that doesn't contain a slash "/". | +| `int` | Any value that contains only numeric characters. | +| `float` | Any value that contains only numeric characters and eventually a dot with digits. | +| `path` | Any value to the end of the path. | +| `uuid` | Any value that matches the UUID value pattern. | To define custom value patterns, extend the `Route.value_patterns` dictionary. The key of the dictionary is the name used by the parameter, while the value is @@ -319,3 +293,214 @@ def fallback(): app.router.fallback = fallback ``` + +## Using sub-routers and filters + +The `Router` class supports filters for routes and sub-routers. In the following +example, a web request for the root of the service "/" having a request header +"X-Area" == "Test" gets the reply of the `test_home` request handler, without +such header the reply of the `home` request handler. + +```python +from blacksheep import Application, Router + + +test_router = Router(headers={"X-Area": "Test"}) + +router = Router(sub_routers=[test_router]) + +@router.get("/") +def home(): + return "Home 1" + +@test_router.get("/") +def test_home(): + return "Home 2" + + +app = Application(router=router) + +``` + +A router can have filters based on headers, host name, query string parameters, +and custom user-defined filters. + +Query string filters can be defined using the `params` parameter, by host using +the `host` parameter: + +```python +filter_by_query = Router(params={"version": "1"}) + +filter_by_host = Router(host="neoteroi.xyz") +``` + +To define a custom filter, define a type of `RouteFilter` and set it using the +`filters` parameter: + +```python +from blacksheep import Application, Request, Router +from blacksheep.server.routing import RouteFilter + + +class CustomFilter(RouteFilter): + + def handle(self, request: Request) -> bool: + # implement here the desired logic + return True + + +example_router = Router(filters=[CustomFilter()]) +``` + +## Using the default router and other routers + +The examples in the documentation show how to register routes using methods +imported from the BlackSheep package: + +```python +from blacksheep import get + +@get("") +async def home(): + ... +``` + +Or, for controllers: + +```python +from blacksheep.server.controllers import Controller, get + + +class Home(Controller): + + @get("/") + async def index(self): + ... +``` + +In this case routes are registered using default singleton routers, used if an +application is instantiated without specifying a router: + +```python +from blacksheep import Application + + +# This application uses the default sigleton routers exposed by BlackSheep: +app = Application() +``` + +This works in most scenarios, when a single `Application` instance per process +is used. For more complex scenarios, it is possible to instantiate a router +and use it as desired: + +```python +# app/router.py + +from blacksheep import Router + + +router = Router() +``` + +And use it when registering routes: + +```python +from app.router import router + + +@router.get("/") +async def home(): + ... +``` + +It is also possible to expose the router methods to reduce code verbosity, like +the BlackSheep package does: + +```python +# app/router.py + +from blacksheep import Router + + +router = Router() + + +get = router.get +post = router.post + +# ... +``` + + +```python +from app.router import get + + +@get("/") +async def home(): + ... +``` + +Then specify the router when instantiating the application: + +```python +from blacksheep import Application + +from app.router import router + + +# This application uses the router instantiated in app.router: +app = Application(router=router) +``` + +### Controllers dedicated router + +Controllers need a different kind of router, an instance of +`blacksheep.server.routing.RoutesRegistry`. If using dedicated router for +controllers is desired, do instead: + +```python +# app/controllers.py + +from blacksheep import RoutesRegistry + + +controllers_router = RoutesRegistry() + + +get = controllers_router.get +post = controllers_router.post + +# ... +``` + +Then when defining your controllers: + +```python +from blacksheep.server.controllers import Controller + +from app.controllers import get, post + + +class Home(Controller): + + @get("/") + async def index(self): + ... +``` + +```python +from blacksheep import Application + +from app.controllers import controllers_router + + +# This application uses the controllers' router instantiated in app.controllers: +app = Application() +app.controllers_router = controllers_router +``` + +!!! info "About Router and RoutesRegistry" + Controllers routes use a "RoutesRegistry" to support dynamic generation of + paths by controller class name. Controllers routes are evaluated and merged + into `Application.router` when the application starts. diff --git a/docs/sessions.md b/docs/sessions.md index f968a08..221dfea 100644 --- a/docs/sessions.md +++ b/docs/sessions.md @@ -8,7 +8,7 @@ built-in classes. To enable sessions, use the `app.use_sessions` method as in the example below: ```python -from blacksheep import Application, Request, text +from blacksheep import Application, Request, get, text app = Application() @@ -16,7 +16,7 @@ app = Application() app.use_sessions("") -@app.route("/") +@get("/") def home(request: Request): session = request.session @@ -27,14 +27,13 @@ def home(request: Request): The `use_sessions` method accepts the following parameters: -| Name | Description | Defaults to | -| --------------- | --------------------------------------------------------------------------------------- | ------------------------------------- | -| secret_key | required secret key used for signing | N/A | -| session_cookie | optional session cookie name | "session" | -| serializer | optional `blacksheep.sessions.Serializer` to serialize and deserialize session values | `blacksheep.sessions.JSONSerializer` | -| signer | optional `itsdangerous.Serializer` to sign and encrypt the session cookie | `itsdangerous.URLSafeTimedSerializer` | -| encryptor | (**deprecated**) optional `blacksheep.sessions.Encryptor` to encrypt the session cookie | `None` | -| session_max_age | Optional session max age, in **seconds** | `None` | +| Name | Description | Defaults to | +| --------------- | ------------------------------------------------------------------------------------- | ------------------------------------- | +| secret_key | required secret key used for signing | N/A | +| session_cookie | optional session cookie name | "session" | +| serializer | optional `blacksheep.sessions.Serializer` to serialize and deserialize session values | `blacksheep.sessions.JSONSerializer` | +| signer | optional `itsdangerous.Serializer` to sign and encrypt the session cookie | `itsdangerous.URLSafeTimedSerializer` | +| session_max_age | Optional session max age, in **seconds** | `None` | ```python def use_sessions( @@ -44,7 +43,6 @@ The `use_sessions` method accepts the following parameters: session_cookie: str = "session", serializer: Optional[SessionSerializer] = None, signer: Optional[Signer] = None, - encryptor: Optional[Encryptor] = None, session_max_age: Optional[int] = None, ) -> None: ... @@ -57,6 +55,7 @@ protection](../dataprotection/) for more information on how tokens are signed and encrypted. ## Using sessions + When sessions are enabled, they are always populated for the `request` object, and can be accessed through the `request.session` property. @@ -64,7 +63,7 @@ The sessions middleware takes care of setting a response cookie whenever the session is modified, session cookies are signed and encrypted by default. ```python -@app.route("/") +@get("/") def home(request: Request): session = request.session diff --git a/docs/static-files.md b/docs/static-files.md index 345bcff..c171faf 100644 --- a/docs/static-files.md +++ b/docs/static-files.md @@ -71,7 +71,9 @@ files with these extensions are served (case insensitive check): '.eot', '.svg', '.mp4', -'.mp3' +'.mp3', +'.webp', +'.webm' ``` To configure extensions, use the dedicated parameter: diff --git a/docs/templating.md b/docs/templating.md index 75b9cc4..b39641a 100644 --- a/docs/templating.md +++ b/docs/templating.md @@ -1,15 +1,16 @@ # Server Side Rendering (SSR) Server side templating refers to the ability of a web application to generate -HTML pages from templates and dynamic variables. BlackSheep does this using the -wondeful [`Jinja2` library](https://palletsprojects.com/p/jinja/) by the -[Pallets](https://palletsprojects.com) team. +HTML pages from templates and dynamic variables. By default, BlackSheep does +this using [`Jinja2` library](https://palletsprojects.com/p/jinja/) by the +[Pallets](https://palletsprojects.com) team, but it supports custom renderers. This page describes: - [X] How to configure server side templating. - [X] Returning views using response functions. - [X] Returning views using the MVC features. +- [X] Using alternatives to `Jinja2`. !!! info The [BlackSheep MVC project @@ -18,33 +19,26 @@ This page describes: configured. ## Configuration + This example shows how to use Jinja2 templating engine with BlackSheep: ```python -from blacksheep import Application -from blacksheep.server.templating import use_templates -from jinja2 import PackageLoader - -app = Application(show_error_details=True, debug=True) -get = app.router.get +from blacksheep import Application, get +from blacksheep.server.responses import view -# NB: this example requires a package called "app"; -# containing a 'templates' folder -# The server file must be in the same folder that contains "app" -view = use_templates(app, loader=PackageLoader("app", "templates")) +app = Application() @get("/") def home(): return view("home", {"example": "Hello", "foo": "World"}) - ``` The expected folder structure for this example: ``` ⬑ app - ⬑ templates - home.html <-- template file loaded by `view` function + ⬑ views + home.jinja <-- template file loaded by `view` function __init__.py server.py @@ -55,8 +49,6 @@ server.py - -

{{example}}

@@ -65,48 +57,37 @@ server.py ``` -If the `use_templates` function is called more than once, the Jinja2 -environment is configured only once, but new `view` functions are returned. It -is recommended to keep this setup in a single file, and import the `view` -function in files that define routes for the application. - ## Async mode It is possible to enable Jinja2 [async -mode](http://jinja.pocoo.org/docs/2.10/api/#async-support), using the parameter -`enable_async`. When `enable_async` is true, the function returned by -`use_templates` is asynchronous: +mode](http://jinja.pocoo.org/docs/2.10/api/#async-support), in the following +way: ```python -from blacksheep import Application -from blacksheep.server.templating import use_templates -from jinja2 import PackageLoader - -app = Application(show_error_details=True, debug=True) -get = app.router.get - -# NB: this example requires a package called "app"; -# containing a 'templates' folder -# The server file must be in the same folder that contains "app" -view = use_templates(app, loader=PackageLoader("app", "templates"), enable_async=True) +from blacksheep import Application, get +from blacksheep.server.rendering.jinja2 import JinjaRenderer +from blacksheep.server.responses import view_async +from blacksheep.settings.html import html_settings +app = Application() +html_settings.use(JinjaRenderer(enable_async=True)) @get("/") async def home(): - return await view("home", {"example": "Hello", "foo": "World"}) + return await view_async("home", {"example": "Hello", "foo": "World"}) ``` ## Loading templates -It is possible to load templates by name including '.html', or without file -extension; '.html' extension is added automatically. Extension must be lower -case. +It is possible to load templates by name including '.jinja', or without file +extension; '.jinja' extension is added automatically. The extension must be +lower case. ```python @get("/") async def home(request): - return view("home.html", {"example": "Hello", "foo": "World"}) + return view("home.jinja", {"example": "Hello", "foo": "World"}) # or... @@ -119,44 +100,81 @@ async def home(request): ## Helpers and filters -To configure custom helpers and filters for Jinja, it is possible to access -its `Environment` using the `templates_environment` property of the application, -once server side templating is configured. +To configure custom helpers and filters for Jinja, access the renderer through +`blacksheep.settings.html.html_settings`: ``` . ├── app │   ├── __init__.py │   └── views -│   └── index.html +│   └── index.jinja └── server.py ``` ```python -# server.py -from blacksheep import Application -from blacksheep.server.templating import use_templates -from jinja2 import PackageLoader, Environment +from datetime import datetime -app = Application(show_error_details=True) +from blacksheep.server import Application +from blacksheep.server.rendering.jinja2 import JinjaRenderer +from blacksheep.settings.html import html_settings -view = use_templates(app, PackageLoader("app", "views")) +def configure_templating( + application: Application +) -> None: + """ + Configures server side rendering for HTML views. + """ + renderer = html_settings.renderer + assert isinstance(renderer, JinjaRenderer) + def get_copy(): + now = datetime.now() + return "{} {}".format(now.year, "Example") -def example(): - return "This is an example" + helpers = {"_": lambda x: x, "copy": get_copy} - -app.templates_environment.globals.update({"my_function": example}) # <<< - - -@app.route("/") -async def home(): - return view("index.html", {}) + env = renderer.env + env.globals.update(helpers) ``` ```html - +

Hello, World!

-{{ my_function() }} +{{ copy() }} +``` + +## Using alternatives to Jinja2 + +To use alternative classes for server side rendering: + +1. Define an implementation of `blacksheep.server.rendering.abc.Renderer` +2. Configure it using `from blacksheep.settings.html import html_settings` + +```python +from blacksheep.server.csrf import AntiForgeryHandler +from blacksheep.settings.html import html_settings +from blacksheep.server.rendering.abc import Renderer + + +class CustomRenderer(Renderer): + + def render(self, template: str, model, **kwargs) -> str: + """Renders a view synchronously.""" + ... + + async def render_async(self, template: str, model, **kwargs) -> str: + """Renders a view asynchronously.""" + ... + + def bind_antiforgery_handler(self, handler: AntiForgeryHandler) -> None: + """ + Applies extensions for an antiforgery handler. + + This method can be used to generate HTML fragments containing + anti-forgery tokens, for the built-in implementation of AF validation. + """ + + +html_settings.use(CustomRenderer()) ``` diff --git a/docs/testing.md b/docs/testing.md index 8470c6e..8ebe5a7 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -27,13 +27,13 @@ A basic example of the `TestClient` would look like this: ```python import asyncio -from blacksheep import Application +from blacksheep import Application, get from blacksheep.testing import TestClient app = Application() -@app.route("/") +@get("/") async def hello(name: str = "World"): return f"Hello, {name}!" @@ -152,44 +152,23 @@ class CreateToDoInput(BaseModel): description: str ``` -To define the API, create a `router.py` file in the `app.routes` package and -copy the following contents into it: - -```python -# ./app/routes/router.py - -from blacksheep.server.routing import Router - - -router = Router() - -get = router.get -post = router.post -delete = router.delete -``` - -!!! info - 💡 Declaring the router in a dedicated file is useful to reduce code verbosity - when defining request handlers. - -Then create a `todos.py` file in `app.routes` package, that will contain the +Create a `todos.py` file in `app.routes` package, that will contain the definition of the TODOs API. Start with the following contents: ```python # ./app/routes/todos.py -from .router import get, post, delete +from blacksheep import get, post, delete from domain import ToDo, CreateToDoInput -from typing import List, Optional @get("/api/todos") -async def get_todos() -> List[ToDo]: +async def get_todos() -> list[ToDo]: ... @get("/api/todos/{todo_id}") -async def get_todo(todo_id) -> Optional[ToDo]: +async def get_todo(todo_id) -> ToDo | None: ... @@ -204,33 +183,17 @@ async def delete_todo(todo_id) -> None: ``` -Edit the `__init__.py` file in `app.routes` package, to load -the API definition: - -```python -# ./app/routes/__init__.py - -from .router import * -from .todos import * -``` - Create a `main.py` file in `app` package, that declares an application: ```python -# ./app/main.py from blacksheep import Application -from .routes import router - - -app = Application(router=router) +app = Application() ``` And finally a `server.py` file at the project's root: ```python -# ./server.py - from app.main import app ``` @@ -262,11 +225,10 @@ Documentation: ```python from blacksheep import Application -from .routes import router from .docs import docs # +++ -app = Application(router=router) +app = Application() docs.bind_app(app) # +++ ``` @@ -292,15 +254,11 @@ API to work with data stored in memory: ```python # ./app/routes/todos.py -from typing import Dict, List, Optional - -from blacksheep import not_found +from blacksheep import get, delete, not_found, post from domain import CreateToDoInput, ToDo -from .router import delete, get, post - -_MOCKED: Dict[int, ToDo] = { +_MOCKED: dict[int, ToDo] = { 1: ToDo( id=1, title="BlackSheep Documentation", @@ -320,16 +278,16 @@ _MOCKED: Dict[int, ToDo] = { @get("/api/todos") -async def get_todos() -> List[ToDo]: +async def get_todos() -> list[ToDo]: return list(_MOCKED.values()) @get("/api/todos/{todo_id}") -async def get_todo(todo_id: int) -> Optional[ToDo]: +async def get_todo(todo_id: int) -> ToDo | None: try: return _MOCKED[todo_id] except KeyError: - return not_found() + return not_found() # type: ignore @post("/api/todos") @@ -345,7 +303,6 @@ async def delete_todo(todo_id: int) -> None: del _MOCKED[todo_id] except KeyError: pass - ``` Now that the API is mocked, let's see how to add tests for it. @@ -416,20 +373,10 @@ Finally, define a first test for the TODOs API: ```python # ./tests/test_todos_api.py -from typing import Any - import pytest -from blacksheep.contents import Content +from blacksheep.contents import JSONContent from blacksheep.testing import TestClient from domain import CreateToDoInput, ToDo -from essentials.json import dumps - - -def json_content(data: Any) -> Content: - return Content( - b"application/json", - dumps(data, separators=(",", ":")).encode("utf8"), - ) @pytest.mark.asyncio @@ -442,7 +389,7 @@ async def test_create_and_get_todo(test_client: TestClient) -> None: response = await test_client.post( "/api/todos", - content=json_content(create_input), + content=JSONContent(create_input), ) assert response is not None diff --git a/docs/versions/migrating-to-v2.md b/docs/versions/migrating-to-v2.md new file mode 100644 index 0000000..e006aa2 --- /dev/null +++ b/docs/versions/migrating-to-v2.md @@ -0,0 +1,476 @@ +This page describes the most relevant differences between version 1 and +version 2 of the web framework. The most relevant changes are: + +- [X] Improved project templates and the `blacksheep-cli` to bootstrap new projects. +- [X] Automatic import of `routes` and `controllers`. +- [X] Added functions to support the notion of application environment. +- [X] Improved dependency injection, with support for alternatives to `rodi`. +- [X] Improved server side rendering, with support for alternatives to `Jinja2`. +- [X] Added support for dependency injection in authentication and authorization handlers. +- [X] Removed the `@app.route` decorator and moved it to the `Router` class. +- [X] Improved the `Router` class to support sub-routers and filters. +- [X] Improved the OIDC features to support storing tokens in the HTML5 Storage + API instead of cookies. +- [X] Some classes have been renamed to better follow Python naming conventions. + +The full list of changes is at the bottom of this page. It includes changes +that were applied to version 1 of the framework, too. + +## BlackSheep-CLI + +The second version of the framework features improved project templates, with +a dedicated CLI for project scaffolding. For more information on the CLI, read +[_More about the CLI_](/blacksheep/cli/). + +![CLI help](/blacksheep/img/cli-help.png) + +The improved project templates also include a strategy to validate settings +using [`Pydantic`](https://docs.pydantic.dev/latest/). + +## Automatic import of routes and controllers + +The second version of the framework includes features to considerably reduce +code verbosity when defining routes and controllers. + +The framework now exposes methods of a default singleton `Router` instance, to +be used to register routes independently from application instantiation. This +enables a much cleaner code API, and consistent with the existing API to +register controllers. + +```python +from blacksheep import get, post + + +@get("/api/examples") +async def get_examples() -> list[str]: + ... + + +@post("/api/examples") +async def add_example(self, example: str): + ... +``` + +For more information on the above, read [_Using the default router and other routers_](/blacksheep/routing/#using-the-default-router-and-other-routers). + +All modules inside `routes` and `controllers` packages are imported +automatically in v2. Automatic import works relatively to where a BlackSheep +application is instantiated. In the structure described below, the modules in +`app.controllers` and `app.routes` namespace are imported automatically when an +application is instantiated inside `app.main`. + +``` +app/ +├── __init__.py +├── controllers +│   ├── __init__.py +│   ├── home.py +│   └── example.py +├── routes +│   ├── __init__.py +│   └── example.py +└──main.py +``` + +The difference in code verbosity is considerable, because previously definining +routes and controllers explicitly was not sufficient to have them registered in +applications. + +## Notion of application environment + +The namespace `blacksheep.server.env` provide an abstraction layer to support +the notion of _application environment_. It provides functions that can be used +to apply logic depending on whether the application is running for local +development, or a different kind of environment (e.g. `dev`, `test`, `prod`). + +These functions use the environment variable `APP_ENV` to determine the type +of environment, defaulting to `production` if such variable is missing. + +```python +from blacksheep.server.env import is_development, is_production + +# is_development returns true if APP_ENV (lower) is in {"local", "dev", "development"} +# is_production returns true if APP_ENV (lower) is missing or in {"prod", "production"} +``` + +## Changes to dependency injection + +In v2, `rodi` and `BlackSheep` have been modified to enable alternative +implementations of dependency injection. `rodi` now defines a +`ContainerProtocol` with a basic API to register and resolve dependencies, and +`BlackSheep` relies on that protocol instead of its specific implementation in +`rodi`. + +For more information, read the [_dedicated part in the Dependency Injection_](/blacksheep/dependency-injection/#the-container-protocol) page. + +## Changes to server side rendering + +`BlackSheep` v2 has been modified to not be strictly related to `Jinja2` for +templates rendering. To achieve this, two new namespaces have been added: + +- `blacksheep.server.rendering.abc`, defining an abstract `Renderer` class, +- `blacksheep.settings.html`, defining a code API to control renderer settings + +The code API of the `view` and `view_async` functions in the +`blacksheep.server.responses` namespace has been improved, using the renderer +configured in `blacksheep.settings.html`. + +The following examples show how a view can be rendered, having a template +defined at the path `views/home.jinja`: + +=== "Now in v2" + + ```python + from blacksheep import Application, get + from blacksheep.server.responses import view + + app = Application() + + + @get("/") + def home(): + return view("home", {"example": "Hello", "foo": "World"}) + ``` + +=== "Before in v1" + + ```python + from blacksheep import Application + from blacksheep.server.templating import use_templates + from jinja2 import PackageLoader + + app = Application() + get = app.router.get + + view = use_templates(app, loader=PackageLoader("app", "views")) + + + @get("/") + def home(): + return view("home", {"example": "Hello", "foo": "World"}) + ``` + +Template: + +```html + + + + + + +

{{example}}

+

{{foo}}

+ + +``` + +For more information, read the updated +[_page describing Server Side Rendering_](/blacksheep/templating/). + +## Improvements to authentication and authorization handlers + +`GuardPost` has been modified to support the new `ContainerProtocol` in `rodi`, +and authentication and authorization handlers now support dependency injection. + +## Removed the app.route method + +The `Application` class was modified to remove the `route` method, which is now +available in the `Router` class. The reason for this change it to make the +code API consistent between methods used to register request handlers. +The `route` method of the singleton default `Router` instance is also exposed +by `blacksheep` package like the other methods to register request handlers. + +=== "Now in v2" + + ```python + from blacksheep import Application, route + + app = Application() + + + @route("/") + def home(): + return "Example" + ``` + +=== "Before in v1" + + ```python + from blacksheep import Application + + app = Application() + + + @app.route("/") + def home(): + return "Example" + ``` + +## Improvements to the Router class + +The `Router` class has been improved to support sub-routers and filters. +For more information, read [_Using sub-routers and filters_](/blacksheep/routing/#using-sub-routers-and-filters). + +## Improvements to OIDC support + +The functions that implement OpenID Connect (OIDC) support have been improved to +support storing tokens (id_token, access_token, refresh_token) in any kind of +store, and with built-in support for the HTML5 Storage API. + +!!! into "Examples in GitHub" + Refer to the [OIDC examples](https://github.com/Neoteroi/BlackSheep-Examples/tree/main/oidc) + +The following **partial** example shows how to use the `use_openid_connect` +function to configure a web app to: + +- use OpenID Connect with [Entra ID](https://www.microsoft.com/en-us/security/business/identity-access/microsoft-entra-id) to implement authentication +- store `id_token`, `access_token`, and `refresh_token` using the HTML5 + Storage API +- configure the back-end API to use `JWT Bearer` authentication (clients must + send requests with `Authorization: Bearer ` headers) + +```python +""" +This example shows how to configure an OpenID Connect integration having tokens +exchanged with the client using the HTML5 Storage API, instead of response cookies. +This scenario enables better reusability of web APIs. +See how the id_token is used in ./static/index.html to authenticate following requests +('Authorization: Bearer ***' headers), and how the refresh token endpoint can be used +to obtain fresh tokens. +""" +import uvicorn +from blacksheep.server.application import Application +from blacksheep.server.authentication.jwt import JWTBearerAuthentication +from blacksheep.server.authentication.oidc import ( + JWTOpenIDTokensHandler, + OpenIDSettings, + use_openid_connect, +) +from dotenv import load_dotenv + +from common.routes import register_routes +from common.secrets import Secrets + +load_dotenv() +secrets = Secrets.from_env() +app = Application(show_error_details=True) + + +AUTHORITY = ( + "https://login.microsoftonline.com/b62b317a-19c2-40c0-8650-2d9672324ac4/v2.0" +) +CLIENT_ID = "499adb65-5e26-459e-bc35-b3e1b5f71a9d" +use_openid_connect( + app, + OpenIDSettings( + authority=AUTHORITY, + client_id=CLIENT_ID, + client_secret=secrets.aad_client_secret, + scope=( + "openid profile offline_access email " + "api://65d21481-4f1a-4731-9508-ad965cb4d59f/example" + ), + ), + auth_handler=JWTOpenIDTokensHandler( + JWTBearerAuthentication( + authority=AUTHORITY, + valid_audiences=[CLIENT_ID], + ), + ), +) + +register_routes(app, static_home=True) + + +if __name__ == "__main__": + uvicorn.run(app, host="127.0.0.1", port=5000, log_level="debug") + +``` + +## Changes to follow naming conventions + +Some classes have been renamed to better follow Python naming conventions. For +example the aliases 'HtmlContent' and 'JsonContent' that were kept for backward +compatibility in `v1`, as alternative names for `HTMLContent` and +`JSONContent`, were removed in `v2`. + +## List of changes + +The full list of changes in alpha versions released for `v2`: + +- Renames the `plugins` namespace to `settings`. +- Upgrades `rodi` to v2, which includes improvements. +- Adds support for alternative implementation of containers for dependency + injection, using the new `ContainerProtocol` in `rodi`. +- Upgrades `guardpost` to v1, which includes support for + dependency injection in authentication handlers and authorization requirements. +- Adds support for Binders instantiated using dependency injection. However, + binders are still instantiated once per request handler and are still + singletons. +- Adds a method to make the `Request` object accessible through dependency + injection (`register_http_context`). This is not a recommended practice, + but it can be desired in some circumstances. +- Removes the direct dependency on `Jinja2` and adds support for alternative + ways to achieve Server Side Rendering (SSR) of HTML; however, `Jinja2` is still + the default library if the user doesn´t specify how HTML should be rendered. +- Adds options to control `Jinja2` settings through environment variables. +- Removes the deprecated `ServeFilesOptions` class. +- Improves how custom binders can be defined, reducing code verbosity for + custom types. This is an important feature to implement common validation of + common parameters across multiple endpoints. +- Adds support for binder types defining OpenAPI Specification for their + parameters. +- Fixes bug #305 (`ClientSession ssl=False` not working as intended). +- Refactors the classes for OpenID Connect integration to support alternative + ways to share tokens with clients, and JWT Bearer token authentication out + of the box, in alternative to cookie based authentication. +- It adds built-in support for storing tokens (`id_token`, `access_token`, and + `refresh_token`) using the HTML5 Storage API (supportin `localStorage` and + `sessionStorage`). Refresh tokens, if present, are automatically protected to + prevent leaking. See [the OIDC + examples](https://github.com/Neoteroi/BlackSheep-Examples/tree/main/oidc) for + more information. +- Renames `blacksheep.server.authentication.oidc.TokensStore` to `TokensStore`. +- Removes the `tokens_store` parameter from the `use_openid_connect` method; + it is still available as optional parameter of the two built-in classes used + to handle tokens. +- Replaces `request.identity` with `request.user`. The property `identity` is + still kept for backward compatibility, but it will be removed in `v3`. +- Removes 'HtmlContent' and 'JsonContent' that were kept as alternative names + for `HTMLContent` and `JSONContent`. +- Refactors the `ClientSession` to own by default a connections pool, if none + is specified for it. The connections pool is automatically disposed when the + client is exited, if it was created for the client. +- Makes the `ClientSession` more user friendly, supporting headers defined as + `dict[str, str]` or `list[tuple[str, str]]`. +- Improves the type annotations of the `ClientSession`. +- Corrects a bug in the `ClientSession` that would cause a task lock when the + connection is lost while downloading files. +- Corrects a bug in the `ClientSession` causing `set-cookie` headers to not be + properly handled during redirects. +- Renames the client connection pool classes to remove the prefix "Client". +- Corrects bug of the `Request` class that would prevent setting `url` using a + string instead of an instance of `URL`. +- Corrects bug of the `Request` class that prevented the `host` property from + working properly after updating `url` (causing `follow_redirects` to not work + properly in `ClientSession`. +- Upgrades the `essentials-openapi` dependency, fixing [#316](https://github.com/Neoteroi/BlackSheep/issues/316). +- Corrects the `Request` class to not generate more than one `Cookie` header + when multiple cookies are set, to [respect the specification](https://www.rfc-editor.org/rfc/rfc6265#section-5.4). +- Adds `@app.lifespan` to support registering objects that must be initialized + at application start, and disposed at application shutdown. + The solution supports registering as many objects as desired. +- Adds features to handle `cache-control` response headers: a decorator for + request handlers and a middleware to set a default value for all `GET` + requests resulting in responses with status `200`. +- Adds features to control `cache-control` header for the default document + (e.g. `index.html`) when serving static files; + see [issue 297](https://github.com/Neoteroi/BlackSheep/issues/297). +- Fixes bug in `sessions` that prevented updating the session data when using + the `set` and `__delitem__` methods; + [scottrutherford](https://github.com/scottrutherford)'s contribution. + +`@app.lifespan` example: + +```python +from blacksheep import Application +from blacksheep.client.session import ClientSession + +app = Application() + + +@app.lifespan +async def register_http_client(): + async with ClientSession() as client: + print("HTTP client created and registered as singleton") + app.services.register(ClientSession, instance=client) + yield + + print("HTTP client disposed") + + +@router.get("/") +async def home(http_client: ClientSession): + print(http_client) + return {"ok": True, "client_instance_id": id(http_client)} + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run(app, host="127.0.0.1", port=44777, log_level="debug", lifespan="on") +``` + +- Adds support for user defined filters for server routes (`RouteFilter` class). +- Adds built-in support for routing based on request headers. +- Adds built-in support for routing based on request query parameters. +- Adds built-in support for routing based on host header value. +- Adds a `query.setter` to the `Request` class, to set queries using + `dict[str, str | sequence[str]]` as input. +- The functions registered to application events don't need anymore to define + the `app` argument (they can be functions without any argument). +- Adds `Cache-Control: no-cache, no-store' to all responses generated for the + OpenID Connect flow. +- Adds support for automatic import of modules defined under `controllers` and + `routes` packages, relatively to where the `Application` class is + instantiated. Fix #334. +- Adds a `GzipMiddleware` that can be used to enable `gzip` compression, using + the built-in module. Contributed by @tyzhnenko :sparkles: +- Improves how tags are generated for OpenAPI Documentation: adds the + possibility to document tags explicitly and control their order, otherwise + sorts them alphabetically by default, when using controllers or specifying + tags for routes. Contributed by @tyzhnenko :sparkles: +- Adds a strategy to control features depending on application environment: + `is_development`, `is_production` depending on `APP_ENV` environment + variable. For more information, see [_Defining application + environment_](https://www.neoteroi.dev/blacksheep/settings/#defining-application-environment). +- Makes the client `ConnectionPools` a context manager, its `__exit__` method + closes all its `TCP-IP` connections. +- Improves exception handling so it is possible to specify how specific types + of `HTTPException` must be handled (#342). +- Improves the error message when a list of objects if expected for an incoming + request body, and a non-list value is received (#341). +- Replaces `chardet` and `cchardet` with `charset-normalizer`. Contributed by + @mementum. +- Upgrades all dependencies. +- Adopts `pyproject.toml`. +- Fixes bug in CORS handling when [multiple origins are + allowed](https://github.com/Neoteroi/BlackSheep/issues/364). +- Adds a `Vary: Origin` response header for CORS requests when the value of + `Access-Control-Allow-Origin` header is a specific URL. +- Adds algorithms parameter to JWTBearerAuthentication constructor, by @tyzhnenko. +- Improves the code API to define security definitions in OpenAPI docs, by @tyzhnenko. +- Applies a correction to the auto-import function for routes and controllers. +- Add support for `StreamedContent` with specific content length; fixing + [#374](https://github.com/Neoteroi/BlackSheep/issues/374) both on the client + and the server side. +- Fix [#373](https://github.com/Neoteroi/BlackSheep/issues/373), about missing + closing ASGI message when an async generator does not yield a closing empty + bytes sequence (`b""`). +- Make version dynamic in `pyproject.toml`, simplifying how the version can be + queried at runtime (see [#362](https://github.com/Neoteroi/BlackSheep/issues/362)). +- Fix [#372](https://github.com/Neoteroi/BlackSheep/issues/372). Use the ASGI + scope `root_path` when possible, as `base_path`. +- Fix [#371](https://github.com/Neoteroi/BlackSheep/issues/371). Returns status + 403 Forbidden when the user is authenticated but not authorized to perform an + action. +- Fixes `TypeError` when writing a request without host header. +- Add support for `Pydantic` `v2`: meaning feature parity with support for + Pydantic v1 (generating OpenAPI Documentation). +- Add support for `Union` types in sub-properties of request handlers input and + output types, for generating OpenAPI Documentation, both using simple classes + and Pydantic [#389](https://github.com/Neoteroi/BlackSheep/issues/389) +- Resolves bug in `2.0a10` caused by incompatibility issue with `Cython 3`. +- Pins `Cython` to `3.0.2` in the build job. +- Fixes bug #394, causing the `Content` max body size to be 2147483647. + (C int max value). Reported and fixed by @thomafred. +- Add support for `.jinja` extension by @thearchitector. +- Makes the `.jinja` extension default for Jinja templates. +- Adds support for Python 3.12, by [@bymoye](https://github.com/bymoye) +- Replaces `pkg_resources` with `importlib.resources` for all supported Python + versions except for `3.8`. +- Runs tests against Pydantic `2.4.2` instead of Pydantic `2.0` to check + support for Pydantic v2. +- Adds `.webp` and `.webm` to the list of extensions of files that are served + by default. diff --git a/docs/websocket.md b/docs/websocket.md index e007d50..de5c07b 100644 --- a/docs/websocket.md +++ b/docs/websocket.md @@ -11,9 +11,10 @@ or [Hypercorn](https://pgjones.gitlab.io/hypercorn/)). ## Creating a WebSocket route -If you want your request handler to act as a WebSocket handler, use the `ws` decorator or -a corresponding `add_ws` method provided by the app router. -Note that the `ws` decorator doesn't have a default path pattern, so you must pass it. +If you want your request handler to act as a WebSocket handler, use the `ws` +decorator or a corresponding `add_ws` method provided by the app router. Note +that the `ws` decorator doesn't have a default path pattern, so you must pass +it. You can use route parameters just like with the regular request handlers. @@ -21,13 +22,13 @@ You can use route parameters just like with the regular request handlers. === "Using `ws` decorator" ```py - from blacksheep import Application, WebSocket + from blacksheep import Application, WebSocket, ws app = Application() - @app.router.ws("/ws/{client_id}") - async def ws(websocket: WebSocket, client_id: str): + @ws("/ws/{client_id}") + async def ws_handler(websocket: WebSocket, client_id: str): ... ``` @@ -39,39 +40,37 @@ You can use route parameters just like with the regular request handlers. app = Application() - async def ws(websocket: WebSocket, client_id: str): + async def ws_handler(websocket: WebSocket, client_id: str): ... - app.router.add_ws("/ws/{client_id}", ws) + app.router.add_ws("/ws/{client_id}", ws_handler) ``` -A `WebSocket` object will be bound to a parameter injected into your handler function -when the client will try to connect to the endpoint. +A `WebSocket` object will be bound to a parameter injected into your handler +function when the client will try to connect to the endpoint. !!! warning "Be careful" - Make sure that your function either has a parameter named **websocket** or a parameter - with an arbitrary name, annotated with the `WebSocket` class. Otherwise, the route - will not function properly. - - + Make sure that your function either has a parameter named **websocket** or + a parameter with an arbitrary name, annotated with the `WebSocket` class. + Otherwise, the route will not function properly. ## Accepting the connection -The `WebSocket` class provides the `accept` method to accept a connection, passing -optional parameters to the client. These optional parameters are **headers** which -will be sent back to the client with the handshake response and **subprotocol** -that your application agrees to accept. +The `WebSocket` class provides the `accept` method to accept a connection, +passing optional parameters to the client. These optional parameters are +**headers** which will be sent back to the client with the handshake response +and **subprotocol** that your application agrees to accept. !!! info The [MDN article](https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API/Writing_WebSocket_servers) - on writing WebSocket servers has some additional information regarding subprotocols - and response headers. + on writing WebSocket servers has some additional information regarding + subprotocols and response headers. ```py -@app.router.ws("/ws") -async def ws(websocket: WebSocket): - # Parameters are purely optional. +@ws("/ws") +async def ws_handler(websocket: WebSocket): + # Parameters are optional. await websocket.accept( headers=[(b"x-custom-header", b"custom-value")], subprotocol="custom-protocol" @@ -83,14 +82,17 @@ As soon as the connection is accepted, you can start receiving and sending messa ## Communicating with the client There are 3 helper method pairs to communicate with the client: -`receive_text`/`send_text`, `receive_bytes`/`send_bytes` and `receive_json`/`send_json`. +`receive_text`/`send_text`, `receive_bytes`/`send_bytes` and +`receive_json`/`send_json`. -There is also the `receive` method that allows for receiving raw WebSocket messages. -Although most of the time you'll want to use one of the helper methods. +There is also the `receive` method that allows for receiving raw WebSocket +messages. Although most of the time you'll want to use one of the helper +methods. -All send methods accept an argument of data to be sent. `receive_json`/`send_json` also -accepts a **mode** argument. It defaults to `MessageMode.TEXT` and can be set to -`MessageMode.BYTES` if, for example, your client sends you encoded JSON strings. +All send methods accept an argument of data to be sent. +`receive_json`/`send_json` also accepts a **mode** argument. It defaults to +`MessageMode.TEXT` and can be set to `MessageMode.BYTES` if, for example, your +client sends you encoded JSON strings. Below is a simple example of an echo WebSocket handler. @@ -101,7 +103,7 @@ until either the client disconnects or the server shut down. === "Text" ```py - @app.router.ws("/ws") + @ws("/ws") async def echo(websocket: WebSocket): await websocket.accept() @@ -114,7 +116,7 @@ until either the client disconnects or the server shut down. === "Bytes" ```py - @app.router.ws("/ws") + @ws("/ws") async def echo(websocket: WebSocket): await websocket.accept() @@ -127,7 +129,7 @@ until either the client disconnects or the server shut down. === "JSON" ```py - @app.router.ws("/ws") + @ws("/ws") async def echo(websocket: WebSocket): await websocket.accept() @@ -139,18 +141,18 @@ until either the client disconnects or the server shut down. ## Handling client disconnect -In event of client disconnect, the ASGI server will close the connection and send the -corresponding message to your app. Upon receiving this message `WebSocket` object will -raise the `WebSocketDisconnectError` exception. +In event of client disconnect, the ASGI server will close the connection and +send the corresponding message to your app. Upon receiving this message +`WebSocket` object will raise the `WebSocketDisconnectError` exception. You'll likely want to catch it and handle it somehow. ```py -from blacksheep import WebSocket, WebSocketDisconnectError +from blacksheep import WebSocket, WebSocketDisconnectError, ws ... -@app.router.ws("/ws") +@ws("/ws") async def echo(websocket: WebSocket): await websocket.accept() @@ -164,9 +166,5 @@ async def echo(websocket: WebSocket): ## Example: chat application -[Here](https://github.com/Neoteroi/BlackSheep-Examples/tree/main/websocket-chat) you can -find the example app using BlackSheep and VueJS. It implements a naive chat application. - -!!! warning - This code is just an example! It would be much more complex - if you would like to build a real chat app. +[Here](https://github.com/Neoteroi/BlackSheep-Examples/tree/main/websocket-chat) +you can find a basic example app using BlackSheep and VueJS. diff --git a/mkdocs.yml b/mkdocs.yml index a5ef613..9785fab 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -43,6 +43,8 @@ nav: - Background tasks: background-tasks.md - Mounting apps: mounting.md - HTTP Client: client.md + - More about the CLI: cli.md + - From V1 to V2: versions/migrating-to-v2.md - Develop using HTTPS: develop-with-https.md - ASGI: asgi.md - Extensions: extensions.md @@ -51,6 +53,10 @@ nav: - Neoteroi docs home: "/" theme: + features: + - navigation.footer + - content.code.copy + - content.action.view palette: - scheme: slate toggle: @@ -69,7 +75,7 @@ theme: repo: fontawesome/brands/github extra: - version: 1 + version: 2 is_current_version: true extra_css: @@ -87,7 +93,11 @@ markdown_extensions: - admonition - markdown.extensions.codehilite: guess_lang: false - - pymdownx.superfences + - pymdownx.superfences: + custom_fences: + - name: mermaid + class: mermaid + format: !!python/name:pymdownx.superfences.fence_code_format - pymdownx.tasklist: custom_checkbox: true - pymdownx.tabbed: diff --git a/overrides/main.html b/overrides/main.html index 1bb985a..03d9e7f 100644 --- a/overrides/main.html +++ b/overrides/main.html @@ -25,7 +25,7 @@

Version {{config.extra.version}}

This documentation refers to the version {{config.extra.version}}.x of the web framework. - The current version of the main branch is documented here.

+ The most recent version of the framework is documented here.

{% endif %} {{ super() }} diff --git a/overrides/partials/content.html b/overrides/partials/content.html index ad24d1e..63fa92f 100644 --- a/overrides/partials/content.html +++ b/overrides/partials/content.html @@ -5,13 +5,6 @@ {% if not "\x3ch1" in page.content %}

{{ page.title | d(config.site_name, true)}}

{% endif %} -{% if not config.extra.is_current_version %} -
-

Version {{config.extra.version}}

-

This documentation refers to the version {{config.extra.version}}.x of the web framework. - The current version of the main branch is documented here.

-
-{% endif %} {{ page.content }} {% if page.meta and ( page.meta.git_revision_date_localized or