diff --git a/docs/api.rst b/docs/api.rst deleted file mode 100644 index 0b71162..0000000 --- a/docs/api.rst +++ /dev/null @@ -1,101 +0,0 @@ -API Reference -============= - -Core API --------- - -.. autoclass:: microdot.Microdot - :members: - -.. autoclass:: microdot.Request - :members: - -.. autoclass:: microdot.Response - :members: - -.. autoclass:: microdot.URLPattern - :members: - -Multipart Forms ---------------- - -.. automodule:: microdot.multipart - :members: - -WebSocket ---------- - -.. automodule:: microdot.websocket - :members: - -Server-Sent Events (SSE) ------------------------- - -.. automodule:: microdot.sse - :members: - -Templates (uTemplate) ---------------------- - -.. automodule:: microdot.utemplate - :members: - -Templates (Jinja) ------------------ - -.. automodule:: microdot.jinja - :members: - -User Sessions -------------- - -.. automodule:: microdot.session - :members: - -Authentication --------------- - -.. automodule:: microdot.auth - :inherited-members: - :special-members: __call__ - :members: - -User Logins ------------ - -.. automodule:: microdot.login - :inherited-members: - :special-members: __call__ - :members: - -Cross-Origin Resource Sharing (CORS) ------------------------------------- - -.. automodule:: microdot.cors - :members: - -Cross-Site Request Forgery (CSRF) Protection --------------------------------------------- - -.. automodule:: microdot.csrf - :members: - -Test Client ------------ - -.. automodule:: microdot.test_client - :members: - -ASGI ----- - -.. autoclass:: microdot.asgi.Microdot - :members: - :exclude-members: shutdown, run - -WSGI ----- - -.. autoclass:: microdot.wsgi.Microdot - :members: - :exclude-members: shutdown, run diff --git a/docs/api/asgi.rst b/docs/api/asgi.rst new file mode 100644 index 0000000..6ad5e90 --- /dev/null +++ b/docs/api/asgi.rst @@ -0,0 +1,6 @@ +ASGI +---- + +.. autoclass:: microdot.asgi.Microdot + :members: + :exclude-members: shutdown, run diff --git a/docs/api/auth.rst b/docs/api/auth.rst new file mode 100644 index 0000000..9588614 --- /dev/null +++ b/docs/api/auth.rst @@ -0,0 +1,7 @@ +Authentication +-------------- + +.. automodule:: microdot.auth + :inherited-members: + :special-members: __call__ + :members: diff --git a/docs/api/cors.rst b/docs/api/cors.rst new file mode 100644 index 0000000..0a39f44 --- /dev/null +++ b/docs/api/cors.rst @@ -0,0 +1,5 @@ +Cross-Origin Resource Sharing (CORS) +------------------------------------ + +.. automodule:: microdot.cors + :members: diff --git a/docs/api/csrf.rst b/docs/api/csrf.rst new file mode 100644 index 0000000..7cf6e90 --- /dev/null +++ b/docs/api/csrf.rst @@ -0,0 +1,5 @@ +Cross-Site Request Forgery (CSRF) Protection +-------------------------------------------- + +.. automodule:: microdot.csrf + :members: diff --git a/docs/api/index.rst b/docs/api/index.rst new file mode 100644 index 0000000..80198c0 --- /dev/null +++ b/docs/api/index.rst @@ -0,0 +1,21 @@ +API Reference +============= + +.. toctree:: + :maxdepth: 1 + + microdot + multipart + websocket + sse + utemplate + jinja + sessions + auth + login + cors + csrf + test_client + asgi + wsgi + diff --git a/docs/api/jinja.rst b/docs/api/jinja.rst new file mode 100644 index 0000000..106d29b --- /dev/null +++ b/docs/api/jinja.rst @@ -0,0 +1,5 @@ +Templates (Jinja) +----------------- + +.. automodule:: microdot.jinja + :members: diff --git a/docs/api/login.rst b/docs/api/login.rst new file mode 100644 index 0000000..38ff258 --- /dev/null +++ b/docs/api/login.rst @@ -0,0 +1,7 @@ +User Logins +----------- + +.. automodule:: microdot.login + :inherited-members: + :special-members: __call__ + :members: diff --git a/docs/api/microdot.rst b/docs/api/microdot.rst new file mode 100644 index 0000000..fe3c287 --- /dev/null +++ b/docs/api/microdot.rst @@ -0,0 +1,14 @@ +Core API +-------- + +.. autoclass:: microdot.Microdot + :members: + +.. autoclass:: microdot.Request + :members: + +.. autoclass:: microdot.Response + :members: + +.. autoclass:: microdot.URLPattern + :members: diff --git a/docs/api/multipart.rst b/docs/api/multipart.rst new file mode 100644 index 0000000..56cea02 --- /dev/null +++ b/docs/api/multipart.rst @@ -0,0 +1,5 @@ +Multipart Forms +--------------- + +.. automodule:: microdot.multipart + :members: diff --git a/docs/api/sessions.rst b/docs/api/sessions.rst new file mode 100644 index 0000000..12350dd --- /dev/null +++ b/docs/api/sessions.rst @@ -0,0 +1,5 @@ +User Sessions +------------- + +.. automodule:: microdot.session + :members: diff --git a/docs/api/sse.rst b/docs/api/sse.rst new file mode 100644 index 0000000..2d06265 --- /dev/null +++ b/docs/api/sse.rst @@ -0,0 +1,5 @@ +Server-Sent Events (SSE) +------------------------ + +.. automodule:: microdot.sse + :members: diff --git a/docs/api/test_client.rst b/docs/api/test_client.rst new file mode 100644 index 0000000..d64c40c --- /dev/null +++ b/docs/api/test_client.rst @@ -0,0 +1,5 @@ +Test Client +----------- + +.. automodule:: microdot.test_client + :members: diff --git a/docs/api/utemplate.rst b/docs/api/utemplate.rst new file mode 100644 index 0000000..848ea07 --- /dev/null +++ b/docs/api/utemplate.rst @@ -0,0 +1,5 @@ +Templates (uTemplate) +--------------------- + +.. automodule:: microdot.utemplate + :members: diff --git a/docs/api/websocket.rst b/docs/api/websocket.rst new file mode 100644 index 0000000..01b7fd3 --- /dev/null +++ b/docs/api/websocket.rst @@ -0,0 +1,5 @@ +WebSocket +--------- + +.. automodule:: microdot.websocket + :members: diff --git a/docs/api/wsgi.rst b/docs/api/wsgi.rst new file mode 100644 index 0000000..5b0d1df --- /dev/null +++ b/docs/api/wsgi.rst @@ -0,0 +1,6 @@ +WSGI +---- + +.. autoclass:: microdot.wsgi.Microdot + :members: + :exclude-members: shutdown, run diff --git a/docs/conf.py b/docs/conf.py index 986fe2d..8d8ffbf 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -46,7 +46,8 @@ exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # -html_theme = 'alabaster' +html_theme = 'furo' +html_title = 'Microdot' # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, @@ -58,12 +59,6 @@ html_css_files = [ ] html_theme_options = { - 'github_user': 'miguelgrinberg', - 'github_repo': 'microdot', - 'github_banner': True, - 'github_button': True, - 'github_type': 'star', - 'fixed_sidebar': True, } autodoc_default_options = { diff --git a/docs/contributing.rst b/docs/contributing.rst new file mode 100644 index 0000000..34eefef --- /dev/null +++ b/docs/contributing.rst @@ -0,0 +1,7 @@ +Contributing +------------ + +Thank you for your interest in Microdot! + +Please visit the `GitHub repository `_ +to learn about the project and find open issues and pull requests. diff --git a/docs/extensions.rst b/docs/extensions.rst deleted file mode 100644 index 53fb57a..0000000 --- a/docs/extensions.rst +++ /dev/null @@ -1,885 +0,0 @@ -Core Extensions ---------------- - -Microdot is a highly extensible web application framework. The extensions -described in this section are maintained as part of the Microdot project in -the same source code repository. - -Multipart Forms -~~~~~~~~~~~~~~~ - -.. list-table:: - :align: left - - * - Compatibility - - | CPython & MicroPython - - * - Required Microdot source files - - | `multipart.py `_ - | `helpers.py `_ - - * - Required external dependencies - - | None - - * - Examples - - | `formdata.py `_ - -The multipart extension handles multipart forms, including those that have file -uploads. - -The :func:`with_form_data ` decorator -provides the simplest way to work with these forms. With this decorator added -to the route, whenever the client sends a multipart request the -:attr:`request.form ` and -:attr:`request.files ` properties are populated with -the submitted data. For form fields the field values are always strings. For -files, they are instances of the -:class:`FileUpload ` class. - -Example:: - - from microdot.multipart import with_form_data - - @app.post('/upload') - @with_form_data - async def upload(request): - print('form fields:', request.form) - print('files:', request.files) - -One disadvantage of the ``@with_form_data`` decorator is that it has to copy -any uploaded files to memory or temporary disk files, depending on their size. -The :attr:`FileUpload.max_memory_size ` -attribute can be used to control the cutoff size above which a file upload -is transferred to a temporary file. - -A more performant alternative to the ``@with_form_data`` decorator is the -:class:`FormDataIter ` class, which iterates -over the form fields sequentially, giving the application the option to parse -the form fields on the fly and decide what to copy and what to discard. When -using ``FormDataIter`` the ``request.form`` and ``request.files`` attributes -are not used. - -Example:: - - - from microdot.multipart import FormDataIter - - @app.post('/upload') - async def upload(request): - async for name, value in FormDataIter(request): - print(name, value) - -For fields that contain an uploaded file, the ``value`` returned by the -iterator is the same ``FileUpload`` instance. The application can choose to -save the file with the :meth:`save() ` -method, or read it with the :meth:`read() ` -method, optionally passing a size to read it in chunks. The -:meth:`copy() ` method is also available to -apply the copying logic used by the ``@with_form_data`` decorator, which is -inefficient but allows the file to be set aside to be processed later, after -the remaining form fields. - -WebSocket -~~~~~~~~~ - -.. list-table:: - :align: left - - * - Compatibility - - | CPython & MicroPython - - * - Required Microdot source files - - | `websocket.py `_ - | `helpers.py `_ - - * - Required external dependencies - - | None - - * - Examples - - | `echo.py `_ - -The WebSocket extension gives the application the ability to handle WebSocket -requests. The :func:`with_websocket ` -decorator is used to mark a route handler as a WebSocket handler. Decorated -routes receive a WebSocket object as a second argument. The WebSocket object -provides ``send()`` and ``receive()`` asynchronous methods to send and receive -messages respectively. - -Example:: - - from microdot.websocket import with_websocket - - @app.route('/echo') - @with_websocket - async def echo(request, ws): - while True: - message = await ws.receive() - await ws.send(message) - -To end the WebSocket connection, the route handler can exit, without returning -anything:: - - @app.route('/echo') - @with_websocket - async def echo(request, ws): - while True: - message = await ws.receive() - if message == 'exit': - break - await ws.send(message) - await ws.send('goodbye') - -If the client ends the WebSocket connection from their side, the route function -is cancelled. The route function can catch the ``CancelledError`` exception -from asyncio to perform cleanup tasks:: - - @app.route('/echo') - @with_websocket - async def echo(request, ws): - try: - while True: - message = await ws.receive() - await ws.send(message) - except asyncio.CancelledError: - print('Client disconnected!') - -Server-Sent Events -~~~~~~~~~~~~~~~~~~ - -.. list-table:: - :align: left - - * - Compatibility - - | CPython & MicroPython - - * - Required Microdot source files - - | `sse.py `_ - | `helpers.py `_ - - * - Required external dependencies - - | None - - * - Examples - - | `counter.py `_ - -The Server-Sent Events (SSE) extension simplifies the creation of a streaming -endpoint that follows the SSE web standard. The :func:`with_sse ` -decorator is used to mark a route as an SSE handler. Decorated routes receive -an SSE object as second argument. The SSE object provides a ``send()`` -asynchronous method to send an event to the client. - -Example:: - - from microdot.sse import with_sse - - @app.route('/events') - @with_sse - async def events(request, sse): - for i in range(10): - await asyncio.sleep(1) - await sse.send({'counter': i}) # unnamed event - await sse.send('end', event='comment') # named event - -To end the SSE connection, the route handler can exit, without returning -anything, as shown in the above examples. - -If the client ends the SSE connection from their side, the route function is -cancelled. The route function can catch the ``CancelledError`` exception from -asyncio to perform cleanup tasks:: - - @app.route('/events') - @with_sse - async def events(request, sse): - try: - i = 0 - while True: - await asyncio.sleep(1) - await sse.send({'counter': i}) - i += 1 - except asyncio.CancelledError: - print('Client disconnected!') - -.. note:: - The SSE protocol is unidirectional, so there is no ``receive()`` method in - the SSE object. For bidirectional communication with the client, use the - WebSocket extension. - -Templates -~~~~~~~~~ - -Many web applications use HTML templates for rendering content to clients. -Microdot includes extensions to render templates with the -`utemplate `_ package on CPython and -MicroPython, and with `Jinja `_ only on -CPython. - -Using the uTemplate Engine -^^^^^^^^^^^^^^^^^^^^^^^^^^ - -.. list-table:: - :align: left - - * - Compatibility - - | CPython & MicroPython - - * - Required Microdot source files - - | `utemplate.py `_ - - * - Required external dependencies - - | `utemplate `_ - - * - Examples - - | `hello.py `_ - -The :class:`Template ` class is used to load a -template. The argument is the template filename, relative to the templates -directory, which is *templates* by default. - -The ``Template`` object has a :func:`render() ` -method that renders the template to a string. This method receives any -arguments that are used by the template. - -Example:: - - from microdot.utemplate import Template - - @app.get('/') - async def index(req): - return Template('index.html').render() - -The ``Template`` object also has a :func:`generate() ` -method, which returns a generator instead of a string. The -:func:`render_async() ` and -:func:`generate_async() ` methods -are the asynchronous versions of these two methods. - -The default location from where templates are loaded is the *templates* -subdirectory. This location can be changed with the -:func:`Template.initialize ` class -method:: - - Template.initialize('my_templates') - -By default templates are automatically compiled the first time they are -rendered, or when their last modified timestamp is more recent than the -compiledo file's timestamp. This loading behavior can be changed by switching -to a different template loader. For example, if the templates are pre-compiled, -the timestamp check and compile steps can be removed by switching to the -"compiled" template loader:: - - from utemplate import compiled - from microdot.utemplate import Template - - Template.initialize(loader_class=compiled.Loader) - -Consult the `uTemplate documentation `_ -for additional information regarding template loaders. - -Using the Jinja Engine -^^^^^^^^^^^^^^^^^^^^^^ - -.. list-table:: - :align: left - - * - Compatibility - - | CPython only - - * - Required Microdot source files - - | `jinja.py `_ - - * - Required external dependencies - - | `Jinja2 `_ - - * - Examples - - | `hello.py `_ - -The :class:`Template ` class is used to load a -template. The argument is the template filename, relative to the templates -directory, which is *templates* by default. - -The ``Template`` object has a :func:`render() ` -method that renders the template to a string. This method receives any -arguments that are used by the template. - -Example:: - - from microdot.jinja import Template - - @app.get('/') - async def index(req): - return Template('index.html').render() - -The ``Template`` object also has a :func:`generate() ` -method, which returns a generator instead of a string. - -The default location from where templates are loaded is the *templates* -subdirectory. This location can be changed with the -:func:`Template.initialize ` class method:: - - Template.initialize('my_templates') - -The ``initialize()`` method also accepts ``enable_async`` argument, which -can be set to ``True`` if asynchronous rendering of templates is desired. If -this option is enabled, then the -:func:`render_async() ` and -:func:`generate_async() ` methods -must be used. - -.. note:: - The Jinja extension is not compatible with MicroPython. - -Secure User Sessions -~~~~~~~~~~~~~~~~~~~~ - -.. list-table:: - :align: left - - * - Compatibility - - | CPython & MicroPython - - * - Required Microdot source files - - | `session.py `_ - | `helpers.py `_ - - * - Required external dependencies - - | CPython: `PyJWT `_ - | MicroPython: `jwt.py `_, - `hmac.py `_ - - * - Examples - - | `login.py `_ - -The session extension provides a secure way for the application to maintain -user sessions. The session data is stored as a signed cookie in the client's -browser, in `JSON Web Token (JWT) `_ -format. - -To work with user sessions, the application first must configure a secret key -that will be used to sign the session cookies. It is very important that this -key is kept secret, as its name implies. An attacker who is in possession of -this key can generate valid user session cookies with any contents. - -To initialize the session extension and configure the secret key, create a -:class:`Session ` object:: - - Session(app, secret_key='top-secret') - -The :func:`with_session ` decorator is the -most convenient way to retrieve the session at the start of a request:: - - from microdot import Microdot, redirect - from microdot.session import Session, with_session - - app = Microdot() - Session(app, secret_key='top-secret') - - @app.route('/', methods=['GET', 'POST']) - @with_session - async def index(req, session): - username = session.get('username') - if req.method == 'POST': - username = req.form.get('username') - session['username'] = username - session.save() - return redirect('/') - if username is None: - return 'Not logged in' - else: - return 'Logged in as ' + username - - @app.post('/logout') - @with_session - async def logout(req, session): - session.delete() - return redirect('/') - -The :func:`save() ` and -:func:`delete() ` methods are used to update -and destroy the user session respectively. - -Authentication -~~~~~~~~~~~~~~ - -.. list-table:: - :align: left - - * - Compatibility - - | CPython & MicroPython - - * - Required Microdot source files - - | `auth.py `_ - - * - Required external dependencies - - | None - - * - Examples - - | `basic_auth.py `_ - | `token_auth.py `_ - -The authentication extension provides helper classes for two commonly used -authentication patterns, described below. - -Basic Authentication -^^^^^^^^^^^^^^^^^^^^ - -`Basic Authentication `_ -is a method of authentication that is part of the HTTP specification. It allows -clients to authenticate to a server using a username and a password. Web -browsers have native support for Basic Authentication and will automatically -prompt the user for a username and a password when a protected resource is -accessed. - -To use Basic Authentication, create an instance of the :class:`BasicAuth ` -class:: - - from microdot.auth import BasicAuth - - auth = BasicAuth(app) - -Next, create an authentication function. The function must accept a request -object and a username and password pair provided by the user. If the -credentials are valid, the function must return an object that represents the -user. If the authentication function cannot validate the user provided -credentials it must return ``None``. Decorate the function with -``@auth.authenticate``:: - - @auth.authenticate - async def verify_user(request, username, password): - user = await load_user_from_database(username) - if user and user.verify_password(password): - return user - -To protect a route with authentication, add the ``auth`` instance as a -decorator:: - - @app.route('/') - @auth - async def index(request): - return f'Hello, {request.g.current_user}!' - -While running an authenticated request, the user object returned by the -authenticaction function is accessible as ``request.g.current_user``. - -If an endpoint is intended to work with or without authentication, then it can -be protected with the ``auth.optional`` decorator:: - - @app.route('/') - @auth.optional - async def index(request): - if request.g.current_user: - return f'Hello, {request.g.current_user}!' - else: - return 'Hello, anonymous user!' - -As shown in the example, a route can check ``request.g.current_user`` to -determine if the user is authenticated or not. - -Token Authentication -^^^^^^^^^^^^^^^^^^^^ - -To set up token authentication, create an instance of -:class:`TokenAuth `:: - - from microdot.auth import TokenAuth - - auth = TokenAuth() - -Then add a function that verifies the token and returns the user it belongs to, -or ``None`` if the token is invalid or expired:: - - @auth.authenticate - async def verify_token(request, token): - return load_user_from_token(token) - -As with Basic authentication, the ``auth`` instance is used as a decorator to -protect your routes, and the authenticated user is accessible from the request -object as ``request.g.current_user``:: - - @app.route('/') - @auth - async def index(request): - return f'Hello, {request.g.current_user}!' - -Optional authentication can also be used with tokens:: - - @app.route('/') - @auth.optional - async def index(request): - if request.g.current_user: - return f'Hello, {request.g.current_user}!' - else: - return 'Hello, anonymous user!' - -User Logins -~~~~~~~~~~~ - -.. list-table:: - :align: left - - * - Compatibility - - | CPython & MicroPython - - * - Required Microdot source files - - | `login.py `_ - | `session.py `_ - | `helpers.py `_ - * - Required external dependencies - - | CPython: `PyJWT `_ - | MicroPython: `jwt.py `_, - `hmac.py `_ - * - Examples - - | `login.py `_ - -The login extension provides user login functionality. The logged in state of -the user is stored in the user session cookie, and an optional "remember me" -cookie can also be added to keep the user logged in across browser sessions. - -To use this extension, create instances of the -:class:`Session ` and :class:`Login ` -class:: - - Session(app, secret_key='top-secret!') - login = Login() - -The ``Login`` class accept an optional argument with the URL of the login page. -The default for this URL is */login*. - -The application must represent users as objects with an ``id`` attribute. A -function decorated with ``@login.user_loader`` is used to load a user object:: - - @login.user_loader - async def get_user(user_id): - return database.get_user(user_id) - -The application must implement the login form. At the point in which the user -credentials have been received and verified, a call to the -:func:`login_user() ` function must be made to -record the user in the user session:: - - @app.route('/login', methods=['GET', 'POST']) - async def login(request): - # ... - if user.check_password(password): - return await login.login_user(request, user, remember=remember_me) - return redirect('/login') - -The optional ``remember`` argument is used to add a remember me cookie that -will log the user in automatically in future sessions. A value of ``True`` will -keep the log in active for 30 days. Alternatively, an integer number of days -can be passed in this argument. - -Any routes that require the user to be logged in must be decorated with -:func:`@login `:: - - @app.route('/') - @login - async def index(request): - # ... - -Routes that are of a sensitive nature can be decorated with -:func:`@login.fresh ` -instead. This decorator requires that the user has logged in during the current -session, and will ask the user to logged in again if the session was -authenticated through a remember me cookie:: - - @app.get('/fresh') - @login.fresh - async def fresh(request): - # ... - -To log out a user, the :func:`logout_user() ` -is used:: - - @app.post('/logout') - @login - async def logout(request): - await login.logout_user(request) - return redirect('/') - -Cross-Origin Resource Sharing (CORS) -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -.. list-table:: - :align: left - - * - Compatibility - - | CPython & MicroPython - - * - Required Microdot source files - - | `cors.py `_ - - * - Required external dependencies - - | None - - * - Examples - - | `app.py `_ - -The CORS extension provides support for `Cross-Origin Resource Sharing -(CORS) `_. CORS is a -mechanism that allows web applications running on different origins to access -resources from each other. For example, a web application running on -``https://example.com`` can access resources from ``https://api.example.com``. - -To enable CORS support, create an instance of the -:class:`CORS ` class and configure the desired options. -Example:: - - from microdot import Microdot - from microdot.cors import CORS - - app = Microdot() - cors = CORS(app, allowed_origins=['https://example.com'], - allow_credentials=True) - -Cross-Site Request Forgery (CSRF) Protection -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -.. list-table:: - :align: left - - * - Compatibility - - | CPython & MicroPython - - * - Required Microdot source files - - | `csrf.py `_ - - * - Required external dependencies - - | None - - * - Examples - - | `app.py `_ - -The CSRF extension provides protection against `Cross-Site Request Forgery -(CSRF) `_ attacks. This -protection defends against attackers attempting to submit forms or other -state-changing requests from their own site on behalf of unsuspecting victims, -while taking advantage of the victims previously established sessions or -cookies to impersonate them. - -This extension checks the ``Sec-Fetch-Site`` header sent by all modern web -browsers to achieve this protection. As a fallback mechanism for older browsers -that do not support this header, this extension can be linked to the CORS -extension to validate the ``Origin`` header. If you are interested in the -details of this protection mechanism, it is described in the -`OWASP CSRF Prevention Cheat Sheet `_ -page. - -.. note:: - As of December 2025, OWASP considers the use of Fetch Metadata Headers for - CSRF protection a - `defense in depth `_ - technique that is insufficient on its own. - - There is an interesting - `discussion `_ on - this topic in the OWASP GitHub repository where it appears to be agreement - that this technique provides complete protection for the vast majority of - use cases. If you are unsure if this method works for your use case, please - read this discussion to have more context and make the right decision. - -To enable CSRF protection, create an instance of the -:class:`CSRF ` class and configure the desired options. -Example:: - - from microdot import Microdot - from microdot.cors import CORS - from microdot.csrf import CSRF - - app = Microdot() - cors = CORS(app, allowed_origins=['https://example.com']) - csrf = CSRF(app, cors) - -This will protect all routes that use a state-changing method (``POST``, -``PUT``, ``PATCH`` or ``DELETE``) and will return a 403 status code response to -any requests that fail the CSRF check. - -If there are routes that need to be exempted from the CSRF check, they can be -decorated with the :meth:`csrf.exempt ` decorator:: - - @app.post('/webhook') - @csrf.exempt - async def webhook(request): - # ... - -For some applications it may be more convenient to have CSRF checks turned off -by default, and only apply them to explicitly selected routes. In this case, -pass ``protect_all=False`` when you construct the ``CSRF`` instance and use the -:meth:`csrf.protect ` decorator:: - - csrf = CSRF(app, cors, protect_all=False) - - @app.post('/submit-form') - @csrf.protect - async def submit_form(request): - # ... - -By default, requests coming from different subdomains are considered to be -cross-site, and as such they will not pass the CSRF check. If you'd like -subdomain requests to be considered safe, then set the -``allow_subdomains=True`` option when you create the ``CSRF`` class. - -.. note:: - This extension is designed to block requests issued by web browsers when - they are found to be unsafe or unauthorized by the application owner. The - method used to determine if a request should be allowed or not is based on - the value of headers that are only sent by web browsers. Clients other than - web browsers are not affected by this extension and can send requests - freely. - -Test Client -~~~~~~~~~~~ - -.. list-table:: - :align: left - - * - Compatibility - - | CPython & MicroPython - - * - Required Microdot source files - - | `test_client.py `_ - - * - Required external dependencies - - | None - -The Microdot Test Client is a utility class that can be used in tests to send -requests into the application without having to start a web server. - -Example:: - - from microdot import Microdot - from microdot.test_client import TestClient - - app = Microdot() - - @app.route('/') - def index(req): - return 'Hello, World!' - - async def test_app(): - client = TestClient(app) - response = await client.get('/') - assert response.text == 'Hello, World!' - -See the documentation for the :class:`TestClient ` -class for more details. - -Production Deployments -~~~~~~~~~~~~~~~~~~~~~~ - -The ``Microdot`` class creates its own simple web server. This is enough for an -application deployed with MicroPython, but when using CPython it may be useful -to use a separate, battle-tested web server. To address this need, Microdot -provides extensions that implement the ASGI and WSGI protocols. - -Using an ASGI Web Server -^^^^^^^^^^^^^^^^^^^^^^^^ - -.. list-table:: - :align: left - - * - Compatibility - - | CPython only - - * - Required Microdot source files - - | `asgi.py `_ - - * - Required external dependencies - - | An ASGI web server, such as `Uvicorn `_. - - * - Examples - - | `hello_asgi.py `_ - | `hello_asgi.py (uTemplate) `_ - | `hello_asgi.py (Jinja) `_ - | `echo_asgi.py (WebSocket) `_ - -The ``asgi`` module provides an extended ``Microdot`` class that -implements the ASGI protocol and can be used with a compliant ASGI server such -as `Uvicorn `_. - -To use an ASGI web server, the application must import the -:class:`Microdot ` class from the ``asgi`` module:: - - from microdot.asgi import Microdot - - app = Microdot() - - @app.route('/') - async def index(req): - return 'Hello, World!' - -The ``app`` application instance created from this class can be used as the -ASGI callable with any complaint ASGI web server. If the above example -application was stored in a file called *test.py*, then the following command -runs the web application using the Uvicorn web server:: - - uvicorn test:app - -When using the ASGI support, the ``scope`` dictionary provided by the web -server is available to request handlers as ``request.asgi_scope``. - -The application instance can be initialized with ``lifespan_startup`` and -``lifespan_shutdown`` arguments, which are invoked when the web server sends -the ASGI lifespan signals with the ASGI scope as only argument:: - - async def startup(scope): - pass - - async def shutdown(scope): - pass - - app = Microdot(lifespan_startup=startup, lifespan_shutdown=shutdown) - -Using a WSGI Web Server -^^^^^^^^^^^^^^^^^^^^^^^ - -.. list-table:: - :align: left - - * - Compatibility - - | CPython only - - * - Required Microdot source files - - | `wsgi.py `_ - - * - Required external dependencies - - | A WSGI web server, such as `Gunicorn `_. - - * - Examples - - | `hello_wsgi.py `_ - | `hello_wsgi.py (uTemplate) `_ - | `hello_wsgi.py (Jinja) `_ - | `echo_wsgi.py (WebSocket) `_ - - -The ``wsgi`` module provides an extended ``Microdot`` class that implements the -WSGI protocol and can be used with a compliant WSGI web server such as -`Gunicorn `_ or -`uWSGI `_. - -To use a WSGI web server, the application must import the -:class:`Microdot ` class from the ``wsgi`` module:: - - from microdot.wsgi import Microdot - - app = Microdot() - - @app.route('/') - def index(req): - return 'Hello, World!' - -The ``app`` application instance created from this class can be used as a WSGI -callbable with any complaint WSGI web server. If the above application -was stored in a file called *test.py*, then the following command runs the -web application using the Gunicorn web server:: - - gunicorn test:app - -When using the WSGI support, the ``environ`` dictionary provided by the web -server is available to request handlers as ``request.environ``. - -.. note:: - In spite of WSGI being a synchronous protocol, the Microdot application - internally runs under an asyncio event loop. For that reason, the - recommendation to prefer ``async def`` handlers over ``def`` still applies - under WSGI. Consult the :ref:`Concurrency` section for a discussion of how - the two types of functions are handled by Microdot. diff --git a/docs/extensions/auth.rst b/docs/extensions/auth.rst new file mode 100644 index 0000000..e24b37d --- /dev/null +++ b/docs/extensions/auth.rst @@ -0,0 +1,112 @@ +Authentication +~~~~~~~~~~~~~~ + +.. list-table:: + :align: left + + * - Compatibility + - | CPython & MicroPython + + * - Required Microdot source files + - | `auth.py `_ + + * - Required external dependencies + - | None + + * - Examples + - | `basic_auth.py `_ + | `token_auth.py `_ + +The authentication extension provides helper classes for two commonly used +authentication patterns, described below. + +Basic Authentication +^^^^^^^^^^^^^^^^^^^^ + +`Basic Authentication `_ +is a method of authentication that is part of the HTTP specification. It allows +clients to authenticate to a server using a username and a password. Web +browsers have native support for Basic Authentication and will automatically +prompt the user for a username and a password when a protected resource is +accessed. + +To use Basic Authentication, create an instance of the :class:`BasicAuth ` +class:: + + from microdot.auth import BasicAuth + + auth = BasicAuth(app) + +Next, create an authentication function. The function must accept a request +object and a username and password pair provided by the user. If the +credentials are valid, the function must return an object that represents the +user. If the authentication function cannot validate the user provided +credentials it must return ``None``. Decorate the function with +``@auth.authenticate``:: + + @auth.authenticate + async def verify_user(request, username, password): + user = await load_user_from_database(username) + if user and user.verify_password(password): + return user + +To protect a route with authentication, add the ``auth`` instance as a +decorator:: + + @app.route('/') + @auth + async def index(request): + return f'Hello, {request.g.current_user}!' + +While running an authenticated request, the user object returned by the +authenticaction function is accessible as ``request.g.current_user``. + +If an endpoint is intended to work with or without authentication, then it can +be protected with the ``auth.optional`` decorator:: + + @app.route('/') + @auth.optional + async def index(request): + if request.g.current_user: + return f'Hello, {request.g.current_user}!' + else: + return 'Hello, anonymous user!' + +As shown in the example, a route can check ``request.g.current_user`` to +determine if the user is authenticated or not. + +Token Authentication +^^^^^^^^^^^^^^^^^^^^ + +To set up token authentication, create an instance of +:class:`TokenAuth `:: + + from microdot.auth import TokenAuth + + auth = TokenAuth() + +Then add a function that verifies the token and returns the user it belongs to, +or ``None`` if the token is invalid or expired:: + + @auth.authenticate + async def verify_token(request, token): + return load_user_from_token(token) + +As with Basic authentication, the ``auth`` instance is used as a decorator to +protect your routes, and the authenticated user is accessible from the request +object as ``request.g.current_user``:: + + @app.route('/') + @auth + async def index(request): + return f'Hello, {request.g.current_user}!' + +Optional authentication can also be used with tokens:: + + @app.route('/') + @auth.optional + async def index(request): + if request.g.current_user: + return f'Hello, {request.g.current_user}!' + else: + return 'Hello, anonymous user!' diff --git a/docs/extensions/cors.rst b/docs/extensions/cors.rst new file mode 100644 index 0000000..de3c1d6 --- /dev/null +++ b/docs/extensions/cors.rst @@ -0,0 +1,34 @@ +Cross-Origin Resource Sharing (CORS) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. list-table:: + :align: left + + * - Compatibility + - | CPython & MicroPython + + * - Required Microdot source files + - | `cors.py `_ + + * - Required external dependencies + - | None + + * - Examples + - | `app.py `_ + +The CORS extension provides support for `Cross-Origin Resource Sharing +(CORS) `_. CORS is a +mechanism that allows web applications running on different origins to access +resources from each other. For example, a web application running on +``https://example.com`` can access resources from ``https://api.example.com``. + +To enable CORS support, create an instance of the +:class:`CORS ` class and configure the desired options. +Example:: + + from microdot import Microdot + from microdot.cors import CORS + + app = Microdot() + cors = CORS(app, allowed_origins=['https://example.com'], + allow_credentials=True) diff --git a/docs/extensions/csrf.rst b/docs/extensions/csrf.rst new file mode 100644 index 0000000..2dde5fc --- /dev/null +++ b/docs/extensions/csrf.rst @@ -0,0 +1,94 @@ +Cross-Site Request Forgery (CSRF) Protection +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. list-table:: + :align: left + + * - Compatibility + - | CPython & MicroPython + + * - Required Microdot source files + - | `csrf.py `_ + + * - Required external dependencies + - | None + + * - Examples + - | `app.py `_ + +The CSRF extension provides protection against `Cross-Site Request Forgery +(CSRF) `_ attacks. This +protection defends against attackers attempting to submit forms or other +state-changing requests from their own site on behalf of unsuspecting victims, +while taking advantage of the victims previously established sessions or +cookies to impersonate them. + +This extension checks the ``Sec-Fetch-Site`` header sent by all modern web +browsers to achieve this protection. As a fallback mechanism for older browsers +that do not support this header, this extension can be linked to the CORS +extension to validate the ``Origin`` header. If you are interested in the +details of this protection mechanism, it is described in the +`OWASP CSRF Prevention Cheat Sheet `_ +page. + +.. note:: + As of December 2025, OWASP considers the use of Fetch Metadata Headers for + CSRF protection a + `defense in depth `_ + technique that is insufficient on its own. + + There is an interesting + `discussion `_ on + this topic in the OWASP GitHub repository where it appears to be agreement + that this technique provides complete protection for the vast majority of + use cases. If you are unsure if this method works for your use case, please + read this discussion to have more context and make the right decision. + +To enable CSRF protection, create an instance of the +:class:`CSRF ` class and configure the desired options. +Example:: + + from microdot import Microdot + from microdot.cors import CORS + from microdot.csrf import CSRF + + app = Microdot() + cors = CORS(app, allowed_origins=['https://example.com']) + csrf = CSRF(app, cors) + +This will protect all routes that use a state-changing method (``POST``, +``PUT``, ``PATCH`` or ``DELETE``) and will return a 403 status code response to +any requests that fail the CSRF check. + +If there are routes that need to be exempted from the CSRF check, they can be +decorated with the :meth:`csrf.exempt ` decorator:: + + @app.post('/webhook') + @csrf.exempt + async def webhook(request): + # ... + +For some applications it may be more convenient to have CSRF checks turned off +by default, and only apply them to explicitly selected routes. In this case, +pass ``protect_all=False`` when you construct the ``CSRF`` instance and use the +:meth:`csrf.protect ` decorator:: + + csrf = CSRF(app, cors, protect_all=False) + + @app.post('/submit-form') + @csrf.protect + async def submit_form(request): + # ... + +By default, requests coming from different subdomains are considered to be +cross-site, and as such they will not pass the CSRF check. If you'd like +subdomain requests to be considered safe, then set the +``allow_subdomains=True`` option when you create the ``CSRF`` class. + +.. note:: + This extension is designed to block requests issued by web browsers when + they are found to be unsafe or unauthorized by the application owner. The + method used to determine if a request should be allowed or not is based on + the value of headers that are only sent by web browsers. Clients other than + web browsers are not affected by this extension and can send requests + freely. diff --git a/docs/extensions/index.rst b/docs/extensions/index.rst new file mode 100644 index 0000000..2b4f8c9 --- /dev/null +++ b/docs/extensions/index.rst @@ -0,0 +1,21 @@ +Core Extensions +--------------- + +Microdot is a highly extensible web application framework. The extensions +described in this section are maintained as part of the Microdot project in +the same source code repository. + +.. toctree:: + :maxdepth: 1 + + multipart + websocket + sse + templates + sessions + auth + login + cors + csrf + test_client + production diff --git a/docs/extensions/login.rst b/docs/extensions/login.rst new file mode 100644 index 0000000..8be569d --- /dev/null +++ b/docs/extensions/login.rst @@ -0,0 +1,85 @@ +User Logins +~~~~~~~~~~~ + +.. list-table:: + :align: left + + * - Compatibility + - | CPython & MicroPython + + * - Required Microdot source files + - | `login.py `_ + | `session.py `_ + | `helpers.py `_ + * - Required external dependencies + - | CPython: `PyJWT `_ + | MicroPython: `jwt.py `_, + `hmac.py `_ + * - Examples + - | `login.py `_ + +The login extension provides user login functionality. The logged in state of +the user is stored in the user session cookie, and an optional "remember me" +cookie can also be added to keep the user logged in across browser sessions. + +To use this extension, create instances of the +:class:`Session ` and :class:`Login ` +class:: + + Session(app, secret_key='top-secret!') + login = Login() + +The ``Login`` class accept an optional argument with the URL of the login page. +The default for this URL is */login*. + +The application must represent users as objects with an ``id`` attribute. A +function decorated with ``@login.user_loader`` is used to load a user object:: + + @login.user_loader + async def get_user(user_id): + return database.get_user(user_id) + +The application must implement the login form. At the point in which the user +credentials have been received and verified, a call to the +:func:`login_user() ` function must be made to +record the user in the user session:: + + @app.route('/login', methods=['GET', 'POST']) + async def login(request): + # ... + if user.check_password(password): + return await login.login_user(request, user, remember=remember_me) + return redirect('/login') + +The optional ``remember`` argument is used to add a remember me cookie that +will log the user in automatically in future sessions. A value of ``True`` will +keep the log in active for 30 days. Alternatively, an integer number of days +can be passed in this argument. + +Any routes that require the user to be logged in must be decorated with +:func:`@login `:: + + @app.route('/') + @login + async def index(request): + # ... + +Routes that are of a sensitive nature can be decorated with +:func:`@login.fresh ` +instead. This decorator requires that the user has logged in during the current +session, and will ask the user to logged in again if the session was +authenticated through a remember me cookie:: + + @app.get('/fresh') + @login.fresh + async def fresh(request): + # ... + +To log out a user, the :func:`logout_user() ` +is used:: + + @app.post('/logout') + @login + async def logout(request): + await login.logout_user(request) + return redirect('/') diff --git a/docs/extensions/multipart.rst b/docs/extensions/multipart.rst new file mode 100644 index 0000000..81aeaf2 --- /dev/null +++ b/docs/extensions/multipart.rst @@ -0,0 +1,73 @@ +Multipart Forms +~~~~~~~~~~~~~~~ + +.. list-table:: + :align: left + + * - Compatibility + - | CPython & MicroPython + + * - Required Microdot source files + - | `multipart.py `_ + | `helpers.py `_ + + * - Required external dependencies + - | None + + * - Examples + - | `formdata.py `_ + +The multipart extension handles multipart forms, including those that have file +uploads. + +The :func:`with_form_data ` decorator +provides the simplest way to work with these forms. With this decorator added +to the route, whenever the client sends a multipart request the +:attr:`request.form ` and +:attr:`request.files ` properties are populated with +the submitted data. For form fields the field values are always strings. For +files, they are instances of the +:class:`FileUpload ` class. + +Example:: + + from microdot.multipart import with_form_data + + @app.post('/upload') + @with_form_data + async def upload(request): + print('form fields:', request.form) + print('files:', request.files) + +One disadvantage of the ``@with_form_data`` decorator is that it has to copy +any uploaded files to memory or temporary disk files, depending on their size. +The :attr:`FileUpload.max_memory_size ` +attribute can be used to control the cutoff size above which a file upload +is transferred to a temporary file. + +A more performant alternative to the ``@with_form_data`` decorator is the +:class:`FormDataIter ` class, which iterates +over the form fields sequentially, giving the application the option to parse +the form fields on the fly and decide what to copy and what to discard. When +using ``FormDataIter`` the ``request.form`` and ``request.files`` attributes +are not used. + +Example:: + + + from microdot.multipart import FormDataIter + + @app.post('/upload') + async def upload(request): + async for name, value in FormDataIter(request): + print(name, value) + +For fields that contain an uploaded file, the ``value`` returned by the +iterator is the same ``FileUpload`` instance. The application can choose to +save the file with the :meth:`save() ` +method, or read it with the :meth:`read() ` +method, optionally passing a size to read it in chunks. The +:meth:`copy() ` method is also available to +apply the copying logic used by the ``@with_form_data`` decorator, which is +inefficient but allows the file to be set aside to be processed later, after +the remaining form fields. diff --git a/docs/extensions/production.rst b/docs/extensions/production.rst new file mode 100644 index 0000000..7e15668 --- /dev/null +++ b/docs/extensions/production.rst @@ -0,0 +1,120 @@ +Production Deployments +~~~~~~~~~~~~~~~~~~~~~~ + +The ``Microdot`` class creates its own simple web server. This is enough for an +application deployed with MicroPython, but when using CPython it may be useful +to use a separate, battle-tested web server. To address this need, Microdot +provides extensions that implement the ASGI and WSGI protocols. + +Using an ASGI Web Server +^^^^^^^^^^^^^^^^^^^^^^^^ + +.. list-table:: + :align: left + + * - Compatibility + - | CPython only + + * - Required Microdot source files + - | `asgi.py `_ + + * - Required external dependencies + - | An ASGI web server, such as `Uvicorn `_. + + * - Examples + - | `hello_asgi.py `_ + | `hello_asgi.py (uTemplate) `_ + | `hello_asgi.py (Jinja) `_ + | `echo_asgi.py (WebSocket) `_ + +The ``asgi`` module provides an extended ``Microdot`` class that +implements the ASGI protocol and can be used with a compliant ASGI server such +as `Uvicorn `_. + +To use an ASGI web server, the application must import the +:class:`Microdot ` class from the ``asgi`` module:: + + from microdot.asgi import Microdot + + app = Microdot() + + @app.route('/') + async def index(req): + return 'Hello, World!' + +The ``app`` application instance created from this class can be used as the +ASGI callable with any complaint ASGI web server. If the above example +application was stored in a file called *test.py*, then the following command +runs the web application using the Uvicorn web server:: + + uvicorn test:app + +When using the ASGI support, the ``scope`` dictionary provided by the web +server is available to request handlers as ``request.asgi_scope``. + +The application instance can be initialized with ``lifespan_startup`` and +``lifespan_shutdown`` arguments, which are invoked when the web server sends +the ASGI lifespan signals with the ASGI scope as only argument:: + + async def startup(scope): + pass + + async def shutdown(scope): + pass + + app = Microdot(lifespan_startup=startup, lifespan_shutdown=shutdown) + +Using a WSGI Web Server +^^^^^^^^^^^^^^^^^^^^^^^ + +.. list-table:: + :align: left + + * - Compatibility + - | CPython only + + * - Required Microdot source files + - | `wsgi.py `_ + + * - Required external dependencies + - | A WSGI web server, such as `Gunicorn `_. + + * - Examples + - | `hello_wsgi.py `_ + | `hello_wsgi.py (uTemplate) `_ + | `hello_wsgi.py (Jinja) `_ + | `echo_wsgi.py (WebSocket) `_ + + +The ``wsgi`` module provides an extended ``Microdot`` class that implements the +WSGI protocol and can be used with a compliant WSGI web server such as +`Gunicorn `_ or +`uWSGI `_. + +To use a WSGI web server, the application must import the +:class:`Microdot ` class from the ``wsgi`` module:: + + from microdot.wsgi import Microdot + + app = Microdot() + + @app.route('/') + def index(req): + return 'Hello, World!' + +The ``app`` application instance created from this class can be used as a WSGI +callbable with any complaint WSGI web server. If the above application +was stored in a file called *test.py*, then the following command runs the +web application using the Gunicorn web server:: + + gunicorn test:app + +When using the WSGI support, the ``environ`` dictionary provided by the web +server is available to request handlers as ``request.environ``. + +.. note:: + In spite of WSGI being a synchronous protocol, the Microdot application + internally runs under an asyncio event loop. For that reason, the + recommendation to prefer ``async def`` handlers over ``def`` still applies + under WSGI. Consult the :ref:`Concurrency` section for a discussion of how + the two types of functions are handled by Microdot. diff --git a/docs/extensions/sessions.rst b/docs/extensions/sessions.rst new file mode 100644 index 0000000..4c9a5d0 --- /dev/null +++ b/docs/extensions/sessions.rst @@ -0,0 +1,68 @@ +Secure User Sessions +~~~~~~~~~~~~~~~~~~~~ + +.. list-table:: + :align: left + + * - Compatibility + - | CPython & MicroPython + + * - Required Microdot source files + - | `session.py `_ + | `helpers.py `_ + + * - Required external dependencies + - | CPython: `PyJWT `_ + | MicroPython: `jwt.py `_, + `hmac.py `_ + + * - Examples + - | `login.py `_ + +The session extension provides a secure way for the application to maintain +user sessions. The session data is stored as a signed cookie in the client's +browser, in `JSON Web Token (JWT) `_ +format. + +To work with user sessions, the application first must configure a secret key +that will be used to sign the session cookies. It is very important that this +key is kept secret, as its name implies. An attacker who is in possession of +this key can generate valid user session cookies with any contents. + +To initialize the session extension and configure the secret key, create a +:class:`Session ` object:: + + Session(app, secret_key='top-secret') + +The :func:`with_session ` decorator is the +most convenient way to retrieve the session at the start of a request:: + + from microdot import Microdot, redirect + from microdot.session import Session, with_session + + app = Microdot() + Session(app, secret_key='top-secret') + + @app.route('/', methods=['GET', 'POST']) + @with_session + async def index(req, session): + username = session.get('username') + if req.method == 'POST': + username = req.form.get('username') + session['username'] = username + session.save() + return redirect('/') + if username is None: + return 'Not logged in' + else: + return 'Logged in as ' + username + + @app.post('/logout') + @with_session + async def logout(req, session): + session.delete() + return redirect('/') + +The :func:`save() ` and +:func:`delete() ` methods are used to update +and destroy the user session respectively. diff --git a/docs/extensions/sse.rst b/docs/extensions/sse.rst new file mode 100644 index 0000000..64997cf --- /dev/null +++ b/docs/extensions/sse.rst @@ -0,0 +1,60 @@ +Server-Sent Events +~~~~~~~~~~~~~~~~~~ + +.. list-table:: + :align: left + + * - Compatibility + - | CPython & MicroPython + + * - Required Microdot source files + - | `sse.py `_ + | `helpers.py `_ + + * - Required external dependencies + - | None + + * - Examples + - | `counter.py `_ + +The Server-Sent Events (SSE) extension simplifies the creation of a streaming +endpoint that follows the SSE web standard. The :func:`with_sse ` +decorator is used to mark a route as an SSE handler. Decorated routes receive +an SSE object as second argument. The SSE object provides a ``send()`` +asynchronous method to send an event to the client. + +Example:: + + from microdot.sse import with_sse + + @app.route('/events') + @with_sse + async def events(request, sse): + for i in range(10): + await asyncio.sleep(1) + await sse.send({'counter': i}) # unnamed event + await sse.send('end', event='comment') # named event + +To end the SSE connection, the route handler can exit, without returning +anything, as shown in the above examples. + +If the client ends the SSE connection from their side, the route function is +cancelled. The route function can catch the ``CancelledError`` exception from +asyncio to perform cleanup tasks:: + + @app.route('/events') + @with_sse + async def events(request, sse): + try: + i = 0 + while True: + await asyncio.sleep(1) + await sse.send({'counter': i}) + i += 1 + except asyncio.CancelledError: + print('Client disconnected!') + +.. note:: + The SSE protocol is unidirectional, so there is no ``receive()`` method in + the SSE object. For bidirectional communication with the client, use the + WebSocket extension. diff --git a/docs/extensions/templates.rst b/docs/extensions/templates.rst new file mode 100644 index 0000000..95b37d5 --- /dev/null +++ b/docs/extensions/templates.rst @@ -0,0 +1,123 @@ +Templates +~~~~~~~~~ + +Many web applications use HTML templates for rendering content to clients. +Microdot includes extensions to render templates with the +`utemplate `_ package on CPython and +MicroPython, and with `Jinja `_ only on +CPython. + +Using the uTemplate Engine +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. list-table:: + :align: left + + * - Compatibility + - | CPython & MicroPython + + * - Required Microdot source files + - | `utemplate.py `_ + + * - Required external dependencies + - | `utemplate `_ + + * - Examples + - | `hello.py `_ + +The :class:`Template ` class is used to load a +template. The argument is the template filename, relative to the templates +directory, which is *templates* by default. + +The ``Template`` object has a :func:`render() ` +method that renders the template to a string. This method receives any +arguments that are used by the template. + +Example:: + + from microdot.utemplate import Template + + @app.get('/') + async def index(req): + return Template('index.html').render() + +The ``Template`` object also has a :func:`generate() ` +method, which returns a generator instead of a string. The +:func:`render_async() ` and +:func:`generate_async() ` methods +are the asynchronous versions of these two methods. + +The default location from where templates are loaded is the *templates* +subdirectory. This location can be changed with the +:func:`Template.initialize ` class +method:: + + Template.initialize('my_templates') + +By default templates are automatically compiled the first time they are +rendered, or when their last modified timestamp is more recent than the +compiledo file's timestamp. This loading behavior can be changed by switching +to a different template loader. For example, if the templates are pre-compiled, +the timestamp check and compile steps can be removed by switching to the +"compiled" template loader:: + + from utemplate import compiled + from microdot.utemplate import Template + + Template.initialize(loader_class=compiled.Loader) + +Consult the `uTemplate documentation `_ +for additional information regarding template loaders. + +Using the Jinja Engine +^^^^^^^^^^^^^^^^^^^^^^ + +.. list-table:: + :align: left + + * - Compatibility + - | CPython only + + * - Required Microdot source files + - | `jinja.py `_ + + * - Required external dependencies + - | `Jinja2 `_ + + * - Examples + - | `hello.py `_ + +The :class:`Template ` class is used to load a +template. The argument is the template filename, relative to the templates +directory, which is *templates* by default. + +The ``Template`` object has a :func:`render() ` +method that renders the template to a string. This method receives any +arguments that are used by the template. + +Example:: + + from microdot.jinja import Template + + @app.get('/') + async def index(req): + return Template('index.html').render() + +The ``Template`` object also has a :func:`generate() ` +method, which returns a generator instead of a string. + +The default location from where templates are loaded is the *templates* +subdirectory. This location can be changed with the +:func:`Template.initialize ` class method:: + + Template.initialize('my_templates') + +The ``initialize()`` method also accepts ``enable_async`` argument, which +can be set to ``True`` if asynchronous rendering of templates is desired. If +this option is enabled, then the +:func:`render_async() ` and +:func:`generate_async() ` methods +must be used. + +.. note:: + The Jinja extension is not compatible with MicroPython. diff --git a/docs/extensions/test_client.rst b/docs/extensions/test_client.rst new file mode 100644 index 0000000..d0f6eea --- /dev/null +++ b/docs/extensions/test_client.rst @@ -0,0 +1,36 @@ +Test Client +~~~~~~~~~~~ + +.. list-table:: + :align: left + + * - Compatibility + - | CPython & MicroPython + + * - Required Microdot source files + - | `test_client.py `_ + + * - Required external dependencies + - | None + +The Microdot Test Client is a utility class that can be used in tests to send +requests into the application without having to start a web server. + +Example:: + + from microdot import Microdot + from microdot.test_client import TestClient + + app = Microdot() + + @app.route('/') + def index(req): + return 'Hello, World!' + + async def test_app(): + client = TestClient(app) + response = await client.get('/') + assert response.text == 'Hello, World!' + +See the documentation for the :class:`TestClient ` +class for more details. diff --git a/docs/extensions/websocket.rst b/docs/extensions/websocket.rst new file mode 100644 index 0000000..f398885 --- /dev/null +++ b/docs/extensions/websocket.rst @@ -0,0 +1,63 @@ +WebSocket +~~~~~~~~~ + +.. list-table:: + :align: left + + * - Compatibility + - | CPython & MicroPython + + * - Required Microdot source files + - | `websocket.py `_ + | `helpers.py `_ + + * - Required external dependencies + - | None + + * - Examples + - | `echo.py `_ + +The WebSocket extension gives the application the ability to handle WebSocket +requests. The :func:`with_websocket ` +decorator is used to mark a route handler as a WebSocket handler. Decorated +routes receive a WebSocket object as a second argument. The WebSocket object +provides ``send()`` and ``receive()`` asynchronous methods to send and receive +messages respectively. + +Example:: + + from microdot.websocket import with_websocket + + @app.route('/echo') + @with_websocket + async def echo(request, ws): + while True: + message = await ws.receive() + await ws.send(message) + +To end the WebSocket connection, the route handler can exit, without returning +anything:: + + @app.route('/echo') + @with_websocket + async def echo(request, ws): + while True: + message = await ws.receive() + if message == 'exit': + break + await ws.send(message) + await ws.send('goodbye') + +If the client ends the WebSocket connection from their side, the route function +is cancelled. The route function can catch the ``CancelledError`` exception +from asyncio to perform cleanup tasks:: + + @app.route('/echo') + @with_websocket + async def echo(request, ws): + try: + while True: + message = await ws.receive() + await ws.send(message) + except asyncio.CancelledError: + print('Client disconnected!') diff --git a/docs/freezing.rst b/docs/implementation/freezing.rst similarity index 100% rename from docs/freezing.rst rename to docs/implementation/freezing.rst diff --git a/docs/implementation/index.rst b/docs/implementation/index.rst new file mode 100644 index 0000000..05fa153 --- /dev/null +++ b/docs/implementation/index.rst @@ -0,0 +1,11 @@ +Implementation notes +-------------------- + +This section covers some implementation aspects of Microdot. + +.. toctree:: + :maxdepth: 1 + + migrating + freezing + diff --git a/docs/migrating.rst b/docs/implementation/migrating.rst similarity index 100% rename from docs/migrating.rst rename to docs/implementation/migrating.rst diff --git a/docs/index.rst b/docs/index.rst index aaddcd0..b245271 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -14,13 +14,14 @@ systems with limited resources such as microcontrollers. Both standard Python (CPython) and `MicroPython `_ are supported. .. toctree:: - :maxdepth: 3 + :maxdepth: 2 intro - extensions - migrating - freezing - api + users-guide/index + extensions/index + implementation/index + api/index + contributing * :ref:`genindex` * :ref:`search` diff --git a/docs/intro.rst b/docs/intro.rst index 48438cd..a8d4edc 100644 --- a/docs/intro.rst +++ b/docs/intro.rst @@ -1,13 +1,14 @@ Installation ------------ -The installation method is different depending on the version of Python. +The installation method is different depending on which flavor of Python you +are using. CPython Installation ~~~~~~~~~~~~~~~~~~~~ For use with standard Python (CPython) projects, Microdot and all of its core -extensions are installed with ``pip``:: +extensions are installed with ``pip`` or any of its alternatives:: pip install microdot @@ -17,970 +18,27 @@ MicroPython Installation For MicroPython, the recommended approach is to manually copy the necessary source files from the `GitHub repository `_ -into your device, ideally after -`compiling `_ -them to *.mpy* files. These source files can also be -`frozen `_ -and incorporated into a custom MicroPython firmware. +into your device. Use the following guidelines to know what files to copy: * For a minimal setup with only the base web server functionality, copy `microdot.py `_ - into your project. -* For a configuration that includes one or more optional extensions, create a - *microdot* directory in your device and copy the following files: + to your device. +* For a configuration that includes one or more of the optional extensions, + create a *microdot* directory in your device and copy the following files: * `__init__.py `_ * `microdot.py `_ * any needed `extensions `_. +Some of the low end devices are perfectly capable of running Microdot once +compiled, but do not have enough RAM for the compiler. For these cases you can +`pre-compile `_ +the files to *.mpy* files for the version of MicroPython that you use in your +device. -Getting Started ---------------- +If space in your device is extremely tight, you may also consider +`freezing `_ +the Microdot files and incorporating them into a custom MicroPython firmware. -This section describes the main features of Microdot in an informal manner. - -For detailed reference information, consult the :ref:`API Reference`. - -If you are familiar with releases of Microdot before 2.x, review the -:ref:`Migration Guide `. - -A Simple Microdot Web Server -~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -The following is an example of a simple web server:: - - from microdot import Microdot - - app = Microdot() - - @app.route('/') - async def index(request): - return 'Hello, world!' - - app.run() - -The script imports the :class:`Microdot ` class and creates -an application instance from it. - -The application instance provides a :func:`route() ` -decorator, which is used to define one or more routes, as needed by the -application. - -The ``route()`` decorator takes the path portion of the URL as an -argument, and maps it to the decorated function, so that the function is called -when the client requests the URL. - -When the function is called, it is passed a :class:`Request ` -object as an argument, which provides access to the information passed by the -client. The value returned by the function is sent back to the client as the -response. - -Microdot is an asynchronous framework that uses the ``asyncio`` package. Route -handler functions can be defined as ``async def`` or ``def`` functions, but -``async def`` functions are recommended for performance. - -The :func:`run() ` method starts the application's web -server on port 5000 by default, and creates its own asynchronous loop. This -method blocks while it waits for connections from clients. - -For some applications it may be necessary to run the web server alongside other -asynchronous tasks, on an already running loop. In that case, instead of -``app.run()`` the web server can be started by invoking the -:func:`start_server() ` coroutine as shown in -the following example:: - - import asyncio - from microdot import Microdot - - app = Microdot() - - @app.route('/') - async def index(request): - return 'Hello, world!' - - async def main(): - # start the server in a background task - server = asyncio.create_task(app.start_server()) - - # ... do other asynchronous work here ... - - # cleanup before ending the application - await server - - asyncio.run(main()) - -Running with CPython -^^^^^^^^^^^^^^^^^^^^ - -.. list-table:: - :align: left - - * - Required Microdot source files - - | `microdot.py `_ - - * - Required external dependencies - - | None - - * - Examples - - | `hello.py `_ - -When using CPython, you can start the web server by running the script that -has the ``app.run()`` call at the bottom:: - - python main.py - -After starting the script, open a web browser and navigate to -*http://localhost:5000/* to access the application at the default address for -the Microdot web server. From other computers in the same network, use the IP -address or hostname of the computer running the script instead of -``localhost``. - -Running with MicroPython -^^^^^^^^^^^^^^^^^^^^^^^^ - -.. list-table:: - :align: left - - * - Required Microdot source files - - | `microdot.py `_ - - * - Required external dependencies - - | None - - * - Examples - - | `hello.py `_ - | `gpio.py `_ - -When using MicroPython, you can upload a *main.py* file containing the web -server code to your device, along with the required Microdot files, as defined -in the :ref:`MicroPython Installation` section. - -MicroPython will automatically run *main.py* when the device is powered on, so -the web server will automatically start. The application can be accessed on -port 5000 at the device's IP address. As indicated above, the port can be -changed by passing the ``port`` argument to the ``run()`` method. - -.. note:: - Microdot does not configure the network interface of the device in which it - is running. If your device requires a network connection to be made in - advance, for example to a Wi-Fi access point, this must be configured before - the ``run()`` method is invoked. - -Web Server Configuration -^^^^^^^^^^^^^^^^^^^^^^^^ - -The :func:`run() ` and -:func:`start_server() ` methods support a few -arguments to configure the web server. - -- ``port``: The port number to listen on. Pass the desired port number in this - argument to use a port different than the default of 5000. For example:: - - app.run(port=6000) - -- ``host``: The IP address of the network interface to listen on. By default - the server listens on all available interfaces. To listen only on the local - loopback interface, pass ``'127.0.0.1'`` as value for this argument. -- ``debug``: when set to ``True``, the server ouputs logging information to the - console. The default is ``False``. -- ``ssl``: an ``SSLContext`` instance that configures the server to use TLS - encryption, or ``None`` to disable TLS use. The default is ``None``. The - following example demonstrates how to configure the server with an SSL - certificate stored in *cert.pem* and *key.pem* files:: - - import ssl - - # ... - - sslctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) - sslctx.load_cert_chain('cert.pem', 'key.pem') - app.run(port=4443, debug=True, ssl=sslctx) - -.. note:: - When using CPython, the certificate and key files must be given in PEM - format. When using MicroPython, these files must be given in DER format. - -Defining Routes -~~~~~~~~~~~~~~~ - -The :func:`route() ` decorator is used to associate an -application URL with the function that handles it. The only required argument -to the decorator is the path portion of the URL. - -The following example creates a route for the root URL of the application:: - - @app.route('/') - async def index(request): - return 'Hello, world!' - -When a client requests the root URL (for example, *http://localhost:5000/*), -Microdot will call the ``index()`` function, passing it a -:class:`Request ` object. The return value of the function -is the response that is sent to the client. - -Below is another example, this one with a route for a URL with two components -in its path:: - - @app.route('/users/active') - async def active_users(request): - return 'Active users: Susan, Joe, and Bob' - -The complete URL that maps to this route is -*http://localhost:5000/users/active*. - -An application can include multiple routes. Microdot uses the path portion of -the URL to determine the correct route function to call for each incoming -request. - -Choosing the HTTP Method -^^^^^^^^^^^^^^^^^^^^^^^^ - -All the example routes shown above are associated with ``GET`` requests, which -are the default. Applications often need to define routes for other HTTP -methods, such as ``POST``, ``PUT``, ``PATCH`` and ``DELETE``. The ``route()`` -decorator takes a ``methods`` optional argument, in which the application can -provide a list of HTTP methods that the route should be associated with on the -given path. - -The following example defines a route that handles ``GET`` and ``POST`` -requests within the same function:: - - @app.route('/invoices', methods=['GET', 'POST']) - async def invoices(request): - if request.method == 'GET': - return 'get invoices' - elif request.method == 'POST': - return 'create an invoice' - -As an alternative to the example above, in which a single function is used to -handle multiple HTTP methods, sometimes it may be desirable to write a separate -function for each HTTP method. The above example can be implemented with two -routes as follows:: - - @app.route('/invoices', methods=['GET']) - async def get_invoices(request): - return 'get invoices' - - @app.route('/invoices', methods=['POST']) - async def create_invoice(request): - return 'create an invoice' - -Microdot provides the :func:`get() `, -:func:`post() `, :func:`put() `, -:func:`patch() `, and -:func:`delete() ` decorators as shortcuts for the -corresponding HTTP methods. The two example routes above can be written more -concisely with them:: - - @app.get('/invoices') - async def get_invoices(request): - return 'get invoices' - - @app.post('/invoices') - async def create_invoice(request): - return 'create an invoice' - -Including Dynamic Components in the URL Path -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -The examples shown above all use hardcoded URL paths. Microdot also supports -the definition of routes that have dynamic components in the path. For example, -the following route associates all URLs that have a path following the pattern -*http://localhost:5000/users/* with the ``get_user()`` function:: - - @app.get('/users/') - async def get_user(request, username): - return 'User: ' + username - -As shown in the example, a path component that is enclosed in angle brackets -is considered a placeholder. Microdot accepts any values for that portion of -the URL path, and passes the value received to the function as an argument -after the request object. - -Routes are not limited to a single dynamic component. The following route shows -how multiple dynamic components can be included in the path:: - - @app.get('/users//') - async def get_user(request, firstname, lastname): - return 'User: ' + firstname + ' ' + lastname - -Dynamic path components are considered to be strings by default. An explicit -type can be specified as a prefix, separated from the dynamic component name by -a colon. The following route has two dynamic components declared as an integer -and a string respectively:: - - @app.get('/users//') - async def get_user(request, id, username): - return 'User: ' + username + ' (' + str(id) + ')' - -If a dynamic path component is defined as an integer, the value passed to the -route function is also an integer. If the client sends a value that is not an -integer in the corresponding section of the URL path, then the URL will not -match and the route will not be called. - -A special type ``path`` can be used to capture the remainder of the path as a -single argument. The difference between an argument of type ``path`` and one of -type ``string`` is that the latter stops capturing when a ``/`` appears in the -URL:: - - @app.get('/tests/') - async def get_test(request, path): - return 'Test: ' + path - -The ``re`` type allows the application to provide a custom regular expression -for the dynamic component. The next example defines a route that only matches -usernames that begin with an upper or lower case letter, followed by a sequence -of letters or numbers:: - - @app.get('/users/') - async def get_user(request, username): - return 'User: ' + username - -The ``re`` type returns the URL component as a string, which sometimes may not -be the most convenient. To convert a path component to something more -meaningful than a string, the application can register a custom URL component -type and provide a parser function that performs the conversion. In the -following example, a ``hex`` custom type is registered to automatically -convert hex numbers given in the path to numbers:: - - from microdot import URLPattern - - URLPattern.register_type('hex', parser=lambda value: int(value, 16)) - - @app.get('/users/') - async def get_user(request, user_id): - user = get_user_by_id(user_id) - # ... - -In addition to the parser, the custom URL component can include a pattern, -given as a regular expression. When a pattern is provided, the URL component -will only match if the regular expression matches the value passed in the URL. -The ``hex`` example above can be expanded with a pattern as follows:: - - URLPattern.register_type('hex', pattern='[0-9a-fA-F]+', - parser=lambda value: int(value, 16)) - -In cases where a pattern isn't provided, or when the pattern is unable to -filter out all invalid values, the parser function can return ``None`` to -indicate a failed match. The next example shows how the parser for the ``hex`` -type can be expanded to do that:: - - def hex_parser(value): - try: - return int(value, 16) - except ValueError: - return None - - URLPattern.register_type('hex', parser=hex_parser) - -.. note:: - Dynamic path components are passed to route functions as keyword arguments, - so the names of the function arguments must match the names declared in the - path specification. - -Before and After Request Handlers -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -It is common for applications to need to perform one or more actions before a -request is handled. Examples include authenticating and/or authorizing the -client, opening a connection to a database, or checking if the requested -resource can be obtained from a cache. The -:func:`before_request() ` decorator registers -a function to be called before the request is dispatched to the route function. - -The following example registers a before-request handler that ensures that the -client is authenticated before the request is handled:: - - @app.before_request - async def authenticate(request): - user = authorize(request) - if not user: - return 'Unauthorized', 401 - request.g.user = user - -Before-request handlers receive the request object as an argument. If the -function returns a value, Microdot sends it to the client as the response, and -does not invoke the route function. This gives before-request handlers the -power to intercept a request if necessary. The example above uses this -technique to prevent an unauthorized user from accessing the requested -route. - -After-request handlers registered with the -:func:`after_request() ` decorator are called -after the route function returns a response. Their purpose is to perform any -common closing or cleanup tasks. The next example shows a combination of -before- and after-request handlers that print the time it takes for a request -to be handled:: - - @app.before_request - async def start_timer(request): - request.g.start_time = time.time() - - @app.after_request - async def end_timer(request, response): - duration = time.time() - request.g.start_time - print(f'Request took {duration:0.2f} seconds') - -After-request handlers receive the request and response objects as arguments, -and they can return a modified response object to replace the original. If -no value is returned from an after-request handler, then the original response -object is used. - -The after-request handlers are only invoked for successful requests. The -:func:`after_error_request() ` -decorator can be used to register a function that is called after an error -occurs. The function receives the request and the error response and is -expected to return an updated response object after performing any necessary -cleanup. - -.. note:: - The :ref:`request.g ` object used in many of the above - examples is a special object that allows the before- and after-request - handlers, as well as the route function to share data during the life of the - request. - -Error Handlers -^^^^^^^^^^^^^^ - -When an error occurs during the handling of a request, Microdot ensures that -the client receives an appropriate error response. Some of the common errors -automatically handled by Microdot are: - -- 400 for malformed requests. -- 404 for URLs that are unknown. -- 405 for URLs that are known, but not implemented for the requested HTTP - method. -- 413 for requests that are larger than the allowed size. -- 500 when the application raises an unhandled exception. - -While the above errors are fully complaint with the HTTP specification, the -application might want to provide custom responses for them. The -:func:`errorhandler() ` decorator registers -functions to respond to specific error codes. The following example shows a -custom error handler for 404 errors:: - - @app.errorhandler(404) - async def not_found(request): - return {'error': 'resource not found'}, 404 - -The ``errorhandler()`` decorator has a second form, in which it takes an -exception class as an argument. Microdot will invoke the handler when an -unhandled exception that is an instance of the given class is raised. The next -example provides a custom response for division by zero errors:: - - @app.errorhandler(ZeroDivisionError) - async def division_by_zero(request, exception): - return {'error': 'division by zero'}, 500 - -When the raised exception class does not have an error handler defined, but -one or more of its parent classes do, Microdot makes an attempt to invoke the -most specific handler. - -Mounting a Sub-Application -^^^^^^^^^^^^^^^^^^^^^^^^^^ - -Small Microdot applications can be written as a single source file, but this -is not the best option for applications that pass a certain size. To make it -simpler to write large applications, Microdot supports the concept of -sub-applications that can be "mounted" on a larger application, possibly with -a common URL prefix applied to all of its routes. For developers familiar with -the Flask framework, this is a similar concept to Flask's blueprints. - -Consider, for example, a *customers.py* sub-application that implements -operations on customers:: - - from microdot import Microdot - - customers_app = Microdot() - - @customers_app.get('/') - async def get_customers(request): - # return all customers - - @customers_app.post('/') - async def new_customer(request): - # create a new customer - -Similar to the above, the *orders.py* sub-application implements operations on -customer orders:: - - from microdot import Microdot - - orders_app = Microdot() - - @orders_app.get('/') - async def get_orders(request): - # return all orders - - @orders_app.post('/') - async def new_order(request): - # create a new order - -Now the main application, which is stored in *main.py*, can import and mount -the sub-applications to build the larger combined application:: - - from microdot import Microdot - from customers import customers_app - from orders import orders_app - - def create_app(): - app = Microdot() - app.mount(customers_app, url_prefix='/customers') - app.mount(orders_app, url_prefix='/orders') - return app - - app = create_app() - app.run() - -The resulting application will have the customer endpoints available at -*/customers/* and the order endpoints available at */orders/*. - -.. note:: - During the handling of a request, the - :attr:`Request.url_prefix ` attribute is - set to the URL prefix under which the sub-application was mounted, or an - empty string if the endpoint did not come from a sub-application or the - sub-application was mounted without a URL prefix. It is possible to issue a - redirect that is relative to the sub-application as follows:: - - return redirect(request.url_prefix + '/relative-url') - -When mounting an application as shown above, before-request, after-request and -error handlers defined in the sub-application are copied over to the main -application at mount time. Once installed in the main application, these -handlers will apply to the whole application and not just the sub-application -in which they were created. - -The :func:`mount() ` method has a ``local`` argument -that defaults to ``False``. When this argument is set to ``True``, the -before-request, after-request and error handlers defined in the sub-application -will only apply to the sub-application. - -Shutting Down the Server -^^^^^^^^^^^^^^^^^^^^^^^^ - -Web servers are designed to run forever, and are often stopped by sending them -an interrupt signal. But having a way to gracefully stop the server is -sometimes useful, especially in testing environments. Microdot provides a -:func:`shutdown() ` method that can be invoked -during the handling of a route to gracefully shut down the server when that -request completes. The next example shows how to use this feature:: - - @app.get('/shutdown') - async def shutdown(request): - request.app.shutdown() - return 'The server is shutting down...' - -The request that invokes the ``shutdown()`` method will complete, and then the -server will not accept any new requests and stop once any remaining requests -complete. At this point the ``app.run()`` call will return. - -The Request Object -~~~~~~~~~~~~~~~~~~ - -The :class:`Request ` object encapsulates all the information -passed by the client. It is passed as an argument to route handlers, as well as -to before-request, after-request and error handlers. - -Request Attributes -^^^^^^^^^^^^^^^^^^ - -The request object provides access to the request attributes, including: - -- :attr:`method `: The HTTP method of the request. -- :attr:`path `: The path of the request. -- :attr:`args `: The query string parameters of the - request, as a :class:`MultiDict ` object. -- :attr:`headers `: The headers of the request, as a - dictionary. -- :attr:`cookies `: The cookies that the client sent - with the request, as a dictionary. -- :attr:`content_type `: The content type - specified by the client, or ``None`` if no content type was specified. -- :attr:`content_length `: The content - length of the request, or 0 if no content length was specified. -- :attr:`json `: The parsed JSON data in the request - body. See :ref:`below ` for additional details. -- :attr:`form `: The parsed form data in the request - body, as a dictionary. See :ref:`below
` for additional details. -- :attr:`files `: A dictionary with the file uploads - included in the request body. Note that file uploads are only supported when - the :ref:`Multipart Forms` extension is used. -- :attr:`client_addr `: The network address of - the client, as a tuple (host, port). -- :attr:`app `: The application instance that created the - request. -- :attr:`g `: The ``g`` object, where handlers can store - request-specific data to be shared among handlers. See :ref:`The "g" Object` - for details. - -JSON Payloads -^^^^^^^^^^^^^ - -When the client sends a request that contains JSON data in the body, the -application can access the parsed JSON data using the -:attr:`json ` attribute. The following example shows how -to use this attribute:: - - @app.post('/customers') - async def create_customer(request): - customer = request.json - # do something with customer - return {'success': True} - -.. note:: - The client must set the ``Content-Type`` header to ``application/json`` for - the ``json`` attribute of the request object to be populated. - -Form Data -^^^^^^^^^ - -The request object also supports standard HTML form submissions through the -:attr:`form ` attribute, which presents the form data -as a :class:`MultiDict ` object. Example:: - - @app.route('/', methods=['GET', 'POST']) - async def index(req): - name = 'Unknown' - if req.method == 'POST': - name = req.form.get('name') - return f'Hello {name}' - -.. note:: - Form submissions automatically parsed when the ``Content-Type`` header is - set by the client to ``application/x-www-form-urlencoded``. For form - submissions that use the ``multipart/form-data`` content type the - :ref:`Multipart Forms` extension must be used. - -Accessing the Raw Request Body -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -For cases in which neither JSON nor form data is expected, the -:attr:`body ` request attribute returns the entire body -of the request as a byte sequence. - -If the expected body is too large to fit safely in memory, the application can -use the :attr:`stream ` request attribute to read the -body contents as a file-like object. The -:attr:`max_body_length ` attribute of the -request object defines the size at which bodies are streamed instead of loaded -into memory. - -Cookies -^^^^^^^ - -Cookies that are sent by the client are made available through the -:attr:`cookies ` attribute of the request object in -dictionary form. - -The "g" Object -^^^^^^^^^^^^^^ - -Sometimes applications need to store data during the lifetime of a request, so -that it can be shared between the before- and after-request handlers, the -route function and any error handlers. The request object provides the -:attr:`g ` attribute for that purpose. - -In the following example, a before request handler authorizes the client and -stores the username so that the route function can use it:: - - @app.before_request - async def authorize(request): - username = authenticate_user(request) - if not username: - return 'Unauthorized', 401 - request.g.username = username - - @app.get('/') - async def index(request): - return f'Hello, {request.g.username}!' - -Request-Specific After-Request Handlers -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -Sometimes applications need to perform operations on the response object -before it is sent to the client, for example to set or remove a cookie. A good -option to use for this is to define a request-specific after-request handler -using the :func:`after_request ` decorator. -Request-specific after-request handlers are called by Microdot after the route -function returns and all the application-wide after-request handlers have been -called. - -The next example shows how a cookie can be updated using a request-specific -after-request handler defined inside a route function:: - - @app.post('/logout') - async def logout(request): - @request.after_request - def reset_session(request, response): - response.set_cookie('session', '', http_only=True) - return response - - return 'Logged out' - -Request Limits -^^^^^^^^^^^^^^ - -To help prevent malicious attacks, Microdot provides some configuration options -to limit the amount of information that is accepted: - -- :attr:`max_content_length `: The - maximum size accepted for the request body, in bytes. When a client sends a - request that is larger than this, the server will respond with a 413 error. - The default is 16KB. -- :attr:`max_body_length `: The maximum - size that is loaded in the :attr:`body ` attribute, in - bytes. Requests that have a body that is larger than this size but smaller - than the size set for ``max_content_length`` can only be accessed through the - :attr:`stream ` attribute. The default is also 16KB. -- :attr:`max_readline `: The maximum allowed - size for a request line, in bytes. The default is 2KB. - -The following example configures the application to accept requests with -payloads up to 1MB in size, but prevents requests that are larger than 8KB from -being loaded into memory:: - - from microdot import Request - - Request.max_content_length = 1024 * 1024 - Request.max_body_length = 8 * 1024 - -Responses -~~~~~~~~~ - -The value or values that are returned from the route function are used by -Microdot to build the response that is sent to the client. The following -sections describe the different types of responses that are supported. - -The Three Parts of a Response -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -Route functions can return one, two or three values. The first and most -important value is the response body:: - - @app.get('/') - async def index(request): - return 'Hello, World!' - -In the above example, Microdot issues a standard 200 status code response -indicating a successful request. The body of the response is the -``'Hello, World!'`` string returned by the function. Microdot includes default -headers with this response, including the ``Content-Type`` header set to -``text/plain`` to indicate a response in plain text. - -The application can provide its own status code as a second value returned from -the route to override the 200 default. The example below returns a 202 status -code:: - - @app.get('/') - async def index(request): - return 'Hello, World!', 202 - -The application can also return a third value, a dictionary with additional -headers that are added to, or replace the default ones included by Microdot. -The next example returns an HTML response, instead of the default plain text -response:: - - @app.get('/') - async def index(request): - return '

Hello, World!

', 202, {'Content-Type': 'text/html'} - -If the application does not need to return a body, then it can omit it and -have the status code as the first or only returned value:: - - @app.get('/') - async def index(request): - return 204 - -Likewise, if the application needs to return a body and custom headers, but -does not need to change the default status code, then it can return two values, -omitting the status code:: - - @app.get('/') - async def index(request): - return '

Hello, World!

', {'Content-Type': 'text/html'} - -Lastly, the application can also return a :class:`Response ` -object containing all the details of the response as a single value. - -JSON Responses -^^^^^^^^^^^^^^ - -If the application needs to return a response with JSON formatted data, it can -return a dictionary or a list as the first value, and Microdot will -automatically format the response as JSON. - -Example:: - - @app.get('/') - async def index(request): - return {'hello': 'world'} - -.. note:: - A ``Content-Type`` header set to ``application/json`` is automatically added - to the response. - -Redirects -^^^^^^^^^ - -The :func:`redirect ` function is a helper that -creates redirect responses:: - - from microdot import redirect - - @app.get('/') - async def index(request): - return redirect('/about') - -File Responses -^^^^^^^^^^^^^^ - -The :func:`send_file ` function builds a response -object for a file:: - - from microdot import send_file - - @app.get('/') - async def index(request): - return send_file('/static/index.html') - -A suggested caching duration can be returned to the client in the ``max_age`` -argument:: - - from microdot import send_file - - @app.get('/') - async def image(request): - return send_file('/static/image.jpg', max_age=3600) # in seconds - -.. note:: - Unlike other web frameworks, Microdot does not automatically configure a - route to serve static files. The following is an example route that can be - added to the application to serve static files from a *static* directory in - the project:: - - @app.route('/static/') - async def static(request, path): - if '..' in path: - # directory traversal is not allowed - return 'Not found', 404 - return send_file('static/' + path, max_age=86400) - -Streaming Responses -^^^^^^^^^^^^^^^^^^^ - -Instead of providing a response as a single value, an application can opt to -return a response that is generated in chunks, by returning a Python generator. -The example below returns all the numbers in the fibonacci sequence below 100:: - - @app.get('/fibonacci') - async def fibonacci(request): - async def generate_fibonacci(): - a, b = 0, 1 - while a < 100: - yield str(a) + '\n' - a, b = b, a + b - - return generate_fibonacci() - -.. note:: - Under CPython, the generator function can be a ``def`` or ``async def`` - function, as well as a class-based generator. - - Under MicroPython, asynchronous generator functions are not supported, so - only ``def`` generator functions can be used. Asynchronous class-based - generators are supported. - -Changing the Default Response Content Type -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -Microdot uses a ``text/plain`` content type by default for responses that do -not explicitly include the ``Content-Type`` header. The application can change -this default by setting the desired content type in the -:attr:`default_content_type ` attribute -of the :class:`Response ` class. - -The example that follows configures the application to use ``text/html`` as -default content type:: - - from microdot import Response - - Response.default_content_type = 'text/html' - -Setting Cookies -^^^^^^^^^^^^^^^ - -Many web applications rely on cookies to maintain client state between -requests. Cookies can be set with the ``Set-Cookie`` header in the response, -but since this is such a common practice, Microdot provides the -:func:`set_cookie() ` method in the response -object to add a properly formatted cookie header to the response. - -Given that route functions do not normally work directly with the response -object, the recommended way to set a cookie is to do it in a -:ref:`request-specific after-request handler `. - -Example:: - - @app.get('/') - async def index(request): - @request.after_request - async def set_cookie(request, response): - response.set_cookie('name', 'value') - return response - - return 'Hello, World!' - -Another option is to create a response object directly in the route function:: - - @app.get('/') - async def index(request): - response = Response('Hello, World!') - response.set_cookie('name', 'value') - return response - -.. note:: - Standard cookies do not offer sufficient privacy and security controls, so - never store sensitive information in them unless you are adding additional - protection mechanisms such as encryption or cryptographic signing. The - :ref:`session ` extension implements signed - cookies that prevent tampering by malicious actors. - -Concurrency -~~~~~~~~~~~ - -Microdot implements concurrency through the ``asyncio`` package, which means -that applications must be careful to prevent blocking in their handlers. - -"async def" handlers -^^^^^^^^^^^^^^^^^^^^ - -The recommendation for route handlers in Microdot is to use asynchronous -functions, declared as ``async def``. Microdot executes these handler -functions as native asynchronous tasks. The standard considerations for writing -asynchronous code apply, and in particular blocking calls should be avoided to -ensure the application runs smoothly and is always responsive. - -"def" handlers -^^^^^^^^^^^^^^ - -Microdot also supports the use of synchronous route handlers, declared as -standard ``def`` functions. These handlers are handled differently under -CPython and MicroPython. - -When running on CPython, Microdot executes synchronous handlers in a -`thread executor `_, -which uses a thread pool. The use of blocking or CPU intensive code in these -handlers does not have such a negative effect on the application, because -handlers do not run on the same thread as the asynchronous loop. On the other -hand, the application will be affected by threading issues such as those caused -by the Global Interpreter Lock. - -Under MicroPython the situation is different. Most microcontroller boards -do not have or have very limited threading support, so Microdot executes -synchronous handlers in the main and often only thread available. This means -that these functions will block the asynchronous loop when they take too long -to complete. The use of properly written asynchronous handlers should be -preferred. diff --git a/docs/users-guide/concurrency.rst b/docs/users-guide/concurrency.rst new file mode 100644 index 0000000..2c01b7e --- /dev/null +++ b/docs/users-guide/concurrency.rst @@ -0,0 +1,36 @@ +Concurrency +~~~~~~~~~~~ + +Microdot implements concurrency through the ``asyncio`` package, which means +that applications must be careful to prevent blocking in their handlers. + +"async def" handlers +^^^^^^^^^^^^^^^^^^^^ + +The recommendation for route handlers in Microdot is to use asynchronous +functions, declared as ``async def``. Microdot executes these handler +functions as native asynchronous tasks. The standard considerations for writing +asynchronous code apply, and in particular blocking calls should be avoided to +ensure the application runs smoothly and is always responsive. + +"def" handlers +^^^^^^^^^^^^^^ + +Microdot also supports the use of synchronous route handlers, declared as +standard ``def`` functions. These handlers are handled differently under +CPython and MicroPython. + +When running on CPython, Microdot executes synchronous handlers in a +`thread executor `_, +which uses a thread pool. The use of blocking or CPU intensive code in these +handlers does not have such a negative effect on the application, because +handlers do not run on the same thread as the asynchronous loop. On the other +hand, the application will be affected by threading issues such as those caused +by the Global Interpreter Lock. + +Under MicroPython the situation is different. Most microcontroller boards +do not have or have very limited threading support, so Microdot executes +synchronous handlers in the main and often only thread available. This means +that these functions will block the asynchronous loop when they take too long +to complete. The use of properly written asynchronous handlers should be +preferred. diff --git a/docs/users-guide/defining-routes.rst b/docs/users-guide/defining-routes.rst new file mode 100644 index 0000000..4f7bc66 --- /dev/null +++ b/docs/users-guide/defining-routes.rst @@ -0,0 +1,378 @@ +Defining Routes +~~~~~~~~~~~~~~~ + +In Microdot, routes define the logic of the web application. + +The route decorator +^^^^^^^^^^^^^^^^^^^ +The :func:`route() ` decorator is used to associate an +application URL with the function that handles it. The only required argument +to the decorator is the path portion of the URL. + +The following example creates a route for the root URL of the application:: + + @app.route('/') + async def index(request): + return 'Hello, world!' + +When a client requests the root URL (for example, *http://localhost:5000/*), +Microdot will call the ``index()`` function, passing it a +:class:`Request ` object. The return value of the function +is the response that is sent to the client. + +Below is another example, this one with a route for a URL with two components +in its path:: + + @app.route('/users/active') + async def active_users(request): + return 'Active users: Susan, Joe, and Bob' + +The complete URL that maps to this route is +*http://localhost:5000/users/active*. + +An application can define multiple routes. Microdot uses the path portion of +the URL to determine the correct route function to call for each incoming +request. + +Choosing the HTTP Method +^^^^^^^^^^^^^^^^^^^^^^^^ + +All the example routes shown above are associated with ``GET`` requests, which +are the default. Applications often need to define routes for other HTTP +methods, such as ``POST``, ``PUT``, ``PATCH`` and ``DELETE``. The ``route()`` +decorator takes a ``methods`` optional argument, in which the application can +provide a list of HTTP methods that the route should be associated with on the +given path. + +The following example defines a route that handles ``GET`` and ``POST`` +requests within the same function:: + + @app.route('/invoices', methods=['GET', 'POST']) + async def invoices(request): + if request.method == 'GET': + return 'get invoices' + elif request.method == 'POST': + return 'create an invoice' + +As an alternative to the example above, in which a single function is used to +handle multiple HTTP methods, sometimes it may be desirable to write a separate +function for each HTTP method. The above example can be implemented with two +routes as follows:: + + @app.route('/invoices', methods=['GET']) + async def get_invoices(request): + return 'get invoices' + + @app.route('/invoices', methods=['POST']) + async def create_invoice(request): + return 'create an invoice' + +Microdot provides the :func:`get() `, +:func:`post() `, :func:`put() `, +:func:`patch() `, and +:func:`delete() ` decorators as shortcuts for the +corresponding HTTP methods. The two example routes above can be written more +concisely with them:: + + @app.get('/invoices') + async def get_invoices(request): + return 'get invoices' + + @app.post('/invoices') + async def create_invoice(request): + return 'create an invoice' + +Including Dynamic Components in the URL Path +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The examples shown above all use hardcoded URL paths. Microdot also supports +the definition of routes that have dynamic components in the path. For example, +the following route associates all URLs that have a path following the pattern +*http://localhost:5000/users/* with the ``get_user()`` function:: + + @app.get('/users/') + async def get_user(request, username): + return 'User: ' + username + +As shown in the example, a path component that is enclosed in angle brackets +is considered a placeholder. Microdot accepts any values for that portion of +the URL path, and passes the value received to the function as an argument +after the request object. + +Routes are not limited to a single dynamic component. The following route shows +how multiple dynamic components can be included in the path:: + + @app.get('/users//') + async def get_user(request, firstname, lastname): + return 'User: ' + firstname + ' ' + lastname + +Dynamic path components are considered to be strings by default. An explicit +type can be specified as a prefix, separated from the dynamic component name by +a colon. The following route has two dynamic components declared as an integer +and a string respectively:: + + @app.get('/users//') + async def get_user(request, id, username): + return 'User: ' + username + ' (' + str(id) + ')' + +If a dynamic path component is defined as an integer, the value passed to the +route function is also an integer. If the client sends a value that is not an +integer in the corresponding section of the URL path, then the URL will not +match and the route will not be called. + +A special type ``path`` can be used to capture the remainder of the path as a +single argument. The difference between an argument of type ``path`` and one of +type ``string`` is that the latter stops capturing when a ``/`` appears in the +URL:: + + @app.get('/tests/') + async def get_test(request, path): + return 'Test: ' + path + +The ``re`` type allows the application to provide a custom regular expression +for the dynamic component. The next example defines a route that only matches +usernames that begin with an upper or lower case letter, followed by a sequence +of letters or numbers:: + + @app.get('/users/') + async def get_user(request, username): + return 'User: ' + username + +The ``re`` type returns the URL component as a string, which sometimes may not +be the most convenient. To convert a path component to something more +meaningful than a string, the application can register a custom URL component +type and provide a parser function that performs the conversion. In the +following example, a ``hex`` custom type is registered to automatically +convert hex numbers given in the path to numbers:: + + from microdot import URLPattern + + URLPattern.register_type('hex', parser=lambda value: int(value, 16)) + + @app.get('/users/') + async def get_user(request, user_id): + user = get_user_by_id(user_id) + # ... + +In addition to the parser, the custom URL component can include a pattern, +given as a regular expression. When a pattern is provided, the URL component +will only match if the regular expression matches the value passed in the URL. +The ``hex`` example above can be expanded with a pattern as follows:: + + URLPattern.register_type('hex', pattern='[0-9a-fA-F]+', + parser=lambda value: int(value, 16)) + +In cases where a pattern isn't provided, or when the pattern is unable to +filter out all invalid values, the parser function can return ``None`` to +indicate a failed match. The next example shows how the parser for the ``hex`` +type can be expanded to do that:: + + def hex_parser(value): + try: + return int(value, 16) + except ValueError: + return None + + URLPattern.register_type('hex', parser=hex_parser) + +.. note:: + Dynamic path components are passed to route functions as keyword arguments, + so the names of the function arguments must match the names declared in the + path specification. + +Before and After Request Handlers +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +It is common for applications to need to perform one or more actions before a +request is handled. Examples include authenticating and/or authorizing the +client, opening a connection to a database, or checking if the requested +resource can be obtained from a cache. The +:func:`before_request() ` decorator registers +a function to be called before the request is dispatched to the route function. + +The following example registers a before-request handler that ensures that the +client is authenticated before the request is handled:: + + @app.before_request + async def authenticate(request): + user = authorize(request) + if not user: + return 'Unauthorized', 401 + request.g.user = user + +Before-request handlers receive the request object as an argument. If the +function returns a value, Microdot sends it to the client as the response, and +does not invoke the route function. This gives before-request handlers the +power to intercept a request if necessary. The example above uses this +technique to prevent an unauthorized user from accessing the requested +route. + +After-request handlers registered with the +:func:`after_request() ` decorator are called +after the route function returns a response. Their purpose is to perform any +common closing or cleanup tasks. The next example shows a combination of +before- and after-request handlers that print the time it takes for a request +to be handled:: + + @app.before_request + async def start_timer(request): + request.g.start_time = time.time() + + @app.after_request + async def end_timer(request, response): + duration = time.time() - request.g.start_time + print(f'Request took {duration:0.2f} seconds') + +After-request handlers receive the request and response objects as arguments, +and they can return a modified response object to replace the original. If +no value is returned from an after-request handler, then the original response +object is used. + +The after-request handlers are only invoked for successful requests. The +:func:`after_error_request() ` +decorator can be used to register a function that is called after an error +occurs. The function receives the request and the error response and is +expected to return an updated response object after performing any necessary +cleanup. + +.. note:: + The :ref:`request.g ` object used in many of the above + examples is a special object that allows the before- and after-request + handlers, as well as the route function to share data during the life of the + request. + +Error Handlers +^^^^^^^^^^^^^^ + +When an error occurs during the handling of a request, Microdot ensures that +the client receives an appropriate error response. Some of the common errors +automatically handled by Microdot are: + +- 400 for malformed requests. +- 404 for URLs that are unknown. +- 405 for URLs that are known, but not implemented for the requested HTTP + method. +- 413 for requests that are larger than the allowed size. +- 500 when the application raises an unhandled exception. + +While the above errors are fully complaint with the HTTP specification, the +application might want to provide custom responses for them. The +:func:`errorhandler() ` decorator registers +functions to respond to specific error codes. The following example shows a +custom error handler for 404 errors:: + + @app.errorhandler(404) + async def not_found(request): + return {'error': 'resource not found'}, 404 + +The ``errorhandler()`` decorator has a second form, in which it takes an +exception class as an argument. Microdot will invoke the handler when an +unhandled exception that is an instance of the given class is raised. The next +example provides a custom response for division by zero errors:: + + @app.errorhandler(ZeroDivisionError) + async def division_by_zero(request, exception): + return {'error': 'division by zero'}, 500 + +When the raised exception class does not have an error handler defined, but +one or more of its parent classes do, Microdot makes an attempt to invoke the +most specific handler. + +Mounting a Sub-Application +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Small Microdot applications can be written as a single source file, but this +is not the best option for applications that pass a certain size. To make it +simpler to write large applications, Microdot supports the concept of +sub-applications that can be "mounted" on a larger application, possibly with +a common URL prefix applied to all of its routes. For developers familiar with +the Flask framework, this is a similar concept to Flask's blueprints. + +Consider, for example, a *customers.py* sub-application that implements +operations on customers:: + + from microdot import Microdot + + customers_app = Microdot() + + @customers_app.get('/') + async def get_customers(request): + # return all customers + + @customers_app.post('/') + async def new_customer(request): + # create a new customer + +Similar to the above, the *orders.py* sub-application implements operations on +customer orders:: + + from microdot import Microdot + + orders_app = Microdot() + + @orders_app.get('/') + async def get_orders(request): + # return all orders + + @orders_app.post('/') + async def new_order(request): + # create a new order + +Now the main application, which is stored in *main.py*, can import and mount +the sub-applications to build the larger combined application:: + + from microdot import Microdot + from customers import customers_app + from orders import orders_app + + def create_app(): + app = Microdot() + app.mount(customers_app, url_prefix='/customers') + app.mount(orders_app, url_prefix='/orders') + return app + + app = create_app() + app.run() + +The resulting application will have the customer endpoints available at +*/customers/* and the order endpoints available at */orders/*. + +.. note:: + During the handling of a request, the + :attr:`Request.url_prefix ` attribute is + set to the URL prefix under which the sub-application was mounted, or an + empty string if the endpoint did not come from a sub-application or the + sub-application was mounted without a URL prefix. It is possible to issue a + redirect that is relative to the sub-application as follows:: + + return redirect(request.url_prefix + '/relative-url') + +When mounting an application as shown above, before-request, after-request and +error handlers defined in the sub-application are copied over to the main +application at mount time. Once installed in the main application, these +handlers will apply to the whole application and not just the sub-application +in which they were created. + +The :func:`mount() ` method has a ``local`` argument +that defaults to ``False``. When this argument is set to ``True``, the +before-request, after-request and error handlers defined in the sub-application +will only apply to the sub-application. + +Shutting Down the Server +^^^^^^^^^^^^^^^^^^^^^^^^ + +Web servers are designed to run forever, and are often stopped by sending them +an interrupt signal. But having a way to gracefully stop the server is +sometimes useful, especially in testing environments. Microdot provides a +:func:`shutdown() ` method that can be invoked +during the handling of a route to gracefully shut down the server when that +request completes. The next example shows how to use this feature:: + + @app.get('/shutdown') + async def shutdown(request): + request.app.shutdown() + return 'The server is shutting down...' + +The request that invokes the ``shutdown()`` method will complete, and then the +server will not accept any new requests and stop once any remaining requests +complete. At this point the ``app.run()`` call will return. diff --git a/docs/users-guide/index.rst b/docs/users-guide/index.rst new file mode 100644 index 0000000..1523fcf --- /dev/null +++ b/docs/users-guide/index.rst @@ -0,0 +1,18 @@ +User's Guide +------------ + +This section describes the main features of Microdot. + +.. toctree:: + :maxdepth: 1 + + intro + defining-routes + request-object + responses + concurrency + +For detailed reference information, consult the :ref:`API Reference`. + +If you are familiar with releases of Microdot before 2.x, review the +:ref:`Migration Guide `. diff --git a/docs/users-guide/intro.rst b/docs/users-guide/intro.rst new file mode 100644 index 0000000..c3ac419 --- /dev/null +++ b/docs/users-guide/intro.rst @@ -0,0 +1,160 @@ +Introduction +~~~~~~~~~~~~ + +This section covers how to create and run a basic Microdot web application. + +A simple web server +^^^^^^^^^^^^^^^^^^^ + +The following is an example of a simple web server:: + + from microdot import Microdot + + app = Microdot() + + @app.route('/') + async def index(request): + return 'Hello, world!' + + app.run() + +The script imports the :class:`Microdot ` class and creates +an application instance from it. + +The application instance provides a :func:`route() ` +decorator, which is used to define one or more routes, as needed by the +application. + +The ``route()`` decorator takes the path portion of the URL as an +argument, and maps it to the decorated function, so that the function is called +when the client requests the URL. + +When the function is called, it is passed a :class:`Request ` +object as an argument, which provides access to the information passed by the +client. The value returned by the function is sent back to the client as the +response. + +Microdot is an asynchronous framework that uses the ``asyncio`` package. Route +handler functions can be defined as ``async def`` or ``def`` functions, but +``async def`` functions are recommended for performance. + +The :func:`run() ` method starts the application's web +server on port 5000 by default, and creates its own asynchronous loop. This +method blocks while it waits for connections from clients. + +For some applications it may be necessary to run the web server alongside other +asynchronous tasks, on an already running loop. In that case, instead of +``app.run()`` the web server can be started by invoking the +:func:`start_server() ` coroutine as shown in +the following example:: + + import asyncio + from microdot import Microdot + + app = Microdot() + + @app.route('/') + async def index(request): + return 'Hello, world!' + + async def main(): + # start the server in a background task + server = asyncio.create_task(app.start_server()) + + # ... do other asynchronous work here ... + + # cleanup before ending the application + await server + + asyncio.run(main()) + +Running with CPython +^^^^^^^^^^^^^^^^^^^^ + +.. list-table:: + :align: left + + * - Required Microdot source files + - | `microdot.py `_ + + * - Required external dependencies + - | None + + * - Examples + - | `hello.py `_ + +When using CPython, you can start the web server by running the script that +has the ``app.run()`` call at the bottom:: + + python main.py + +After starting the script, open a web browser and navigate to +*http://localhost:5000/* to access the application at the default address for +the Microdot web server. From other computers in the same network, use the IP +address or hostname of the computer running the script instead of +``localhost``. + +Running with MicroPython +^^^^^^^^^^^^^^^^^^^^^^^^ + +.. list-table:: + :align: left + + * - Required Microdot source files + - | `microdot.py `_ + + * - Required external dependencies + - | None + + * - Examples + - | `hello.py `_ + | `gpio.py `_ + +When using MicroPython, you can upload a *main.py* file containing the web +server code to your device, along with the required Microdot files, as defined +in the :ref:`MicroPython Installation` section. + +MicroPython will automatically run *main.py* when the device is powered on, so +the web server will automatically start. The application can be accessed on +port 5000 at the device's IP address. As indicated above, the port can be +changed by passing the ``port`` argument to the ``run()`` method. + +.. note:: + Microdot does not configure the network interface of the device in which it + is running. If your device requires a network connection to be made in + advance, for example to a Wi-Fi access point, this must be configured before + the ``run()`` method is invoked. + +Web Server Configuration +^^^^^^^^^^^^^^^^^^^^^^^^ + +The :func:`run() ` and +:func:`start_server() ` methods support a few +arguments to configure the web server. + +- ``port``: The port number to listen on. Pass the desired port number in this + argument to use a port different than the default of 5000. For example:: + + app.run(port=6000) + +- ``host``: The IP address of the network interface to listen on. By default + the server listens on all available interfaces. To listen only on the local + loopback interface, pass ``'127.0.0.1'`` as value for this argument. +- ``debug``: when set to ``True``, the server ouputs logging information to the + console. The default is ``False``. +- ``ssl``: an ``SSLContext`` instance that configures the server to use TLS + encryption, or ``None`` to disable TLS use. The default is ``None``. The + following example demonstrates how to configure the server with an SSL + certificate stored in *cert.pem* and *key.pem* files:: + + import ssl + + # ... + + sslctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + sslctx.load_cert_chain('cert.pem', 'key.pem') + app.run(port=4443, debug=True, ssl=sslctx) + +.. note:: + When using CPython, the certificate and key files must be given in PEM + format. When using MicroPython, these files must be given in DER format. diff --git a/docs/users-guide/request-object.rst b/docs/users-guide/request-object.rst new file mode 100644 index 0000000..5351f91 --- /dev/null +++ b/docs/users-guide/request-object.rst @@ -0,0 +1,169 @@ +The Request Object +~~~~~~~~~~~~~~~~~~ + +The :class:`Request ` object encapsulates all the information +passed by the client. It is passed as an argument to route handlers, as well as +to before-request, after-request and error handlers. + +Request Attributes +^^^^^^^^^^^^^^^^^^ + +The request object provides access to the request attributes, including: + +- :attr:`method `: The HTTP method of the request. +- :attr:`path `: The path of the request. +- :attr:`args `: The query string parameters of the + request, as a :class:`MultiDict ` object. +- :attr:`headers `: The headers of the request, as a + dictionary. +- :attr:`cookies `: The cookies that the client sent + with the request, as a dictionary. +- :attr:`content_type `: The content type + specified by the client, or ``None`` if no content type was specified. +- :attr:`content_length `: The content + length of the request, or 0 if no content length was specified. +- :attr:`json `: The parsed JSON data in the request + body. See :ref:`below ` for additional details. +- :attr:`form `: The parsed form data in the request + body, as a dictionary. See :ref:`below ` for additional details. +- :attr:`files `: A dictionary with the file uploads + included in the request body. Note that file uploads are only supported when + the :ref:`Multipart Forms` extension is used. +- :attr:`client_addr `: The network address of + the client, as a tuple (host, port). +- :attr:`app `: The application instance that created the + request. +- :attr:`g `: The ``g`` object, where handlers can store + request-specific data to be shared among handlers. See :ref:`The "g" Object` + for details. + +JSON Payloads +^^^^^^^^^^^^^ + +When the client sends a request that contains JSON data in the body, the +application can access the parsed JSON data using the +:attr:`json ` attribute. The following example shows how +to use this attribute:: + + @app.post('/customers') + async def create_customer(request): + customer = request.json + # do something with customer + return {'success': True} + +.. note:: + The client must set the ``Content-Type`` header to ``application/json`` for + the ``json`` attribute of the request object to be populated. + +Form Data +^^^^^^^^^ + +The request object also supports standard HTML form submissions through the +:attr:`form ` attribute, which presents the form data +as a :class:`MultiDict ` object. Example:: + + @app.route('/', methods=['GET', 'POST']) + async def index(req): + name = 'Unknown' + if req.method == 'POST': + name = req.form.get('name') + return f'Hello {name}' + +.. note:: + Form submissions automatically parsed when the ``Content-Type`` header is + set by the client to ``application/x-www-form-urlencoded``. For form + submissions that use the ``multipart/form-data`` content type the + :ref:`Multipart Forms` extension must be used. + +Accessing the Raw Request Body +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +For cases in which neither JSON nor form data is expected, the +:attr:`body ` request attribute returns the entire body +of the request as a byte sequence. + +If the expected body is too large to fit safely in memory, the application can +use the :attr:`stream ` request attribute to read the +body contents as a file-like object. The +:attr:`max_body_length ` attribute of the +request object defines the size at which bodies are streamed instead of loaded +into memory. + +Cookies +^^^^^^^ + +Cookies that are sent by the client are made available through the +:attr:`cookies ` attribute of the request object in +dictionary form. + +The "g" Object +^^^^^^^^^^^^^^ + +Sometimes applications need to store data during the lifetime of a request, so +that it can be shared between the before- and after-request handlers, the +route function and any error handlers. The request object provides the +:attr:`g ` attribute for that purpose. + +In the following example, a before request handler authorizes the client and +stores the username so that the route function can use it:: + + @app.before_request + async def authorize(request): + username = authenticate_user(request) + if not username: + return 'Unauthorized', 401 + request.g.username = username + + @app.get('/') + async def index(request): + return f'Hello, {request.g.username}!' + +Request-Specific After-Request Handlers +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Sometimes applications need to perform operations on the response object +before it is sent to the client, for example to set or remove a cookie. A good +option to use for this is to define a request-specific after-request handler +using the :func:`after_request ` decorator. +Request-specific after-request handlers are called by Microdot after the route +function returns and all the application-wide after-request handlers have been +called. + +The next example shows how a cookie can be updated using a request-specific +after-request handler defined inside a route function:: + + @app.post('/logout') + async def logout(request): + @request.after_request + def reset_session(request, response): + response.set_cookie('session', '', http_only=True) + return response + + return 'Logged out' + +Request Limits +^^^^^^^^^^^^^^ + +To help prevent malicious attacks, Microdot provides some configuration options +to limit the amount of information that is accepted: + +- :attr:`max_content_length `: The + maximum size accepted for the request body, in bytes. When a client sends a + request that is larger than this, the server will respond with a 413 error. + The default is 16KB. +- :attr:`max_body_length `: The maximum + size that is loaded in the :attr:`body ` attribute, in + bytes. Requests that have a body that is larger than this size but smaller + than the size set for ``max_content_length`` can only be accessed through the + :attr:`stream ` attribute. The default is also 16KB. +- :attr:`max_readline `: The maximum allowed + size for a request line, in bytes. The default is 2KB. + +The following example configures the application to accept requests with +payloads up to 1MB in size, but prevents requests that are larger than 8KB from +being loaded into memory:: + + from microdot import Request + + Request.max_content_length = 1024 * 1024 + Request.max_body_length = 8 * 1024 diff --git a/docs/users-guide/responses.rst b/docs/users-guide/responses.rst new file mode 100644 index 0000000..1d0b43f --- /dev/null +++ b/docs/users-guide/responses.rst @@ -0,0 +1,200 @@ +Responses +~~~~~~~~~ + +The value or values that are returned from the route function are used by +Microdot to build the response that is sent to the client. The following +sections describe the different types of responses that are supported. + +The Three Parts of a Response +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Route functions can return one, two or three values. The first and most +important value is the response body:: + + @app.get('/') + async def index(request): + return 'Hello, World!' + +In the above example, Microdot issues a standard 200 status code response +indicating a successful request. The body of the response is the +``'Hello, World!'`` string returned by the function. Microdot includes default +headers with this response, including the ``Content-Type`` header set to +``text/plain`` to indicate a response in plain text. + +The application can provide its own status code as a second value returned from +the route to override the 200 default. The example below returns a 202 status +code:: + + @app.get('/') + async def index(request): + return 'Hello, World!', 202 + +The application can also return a third value, a dictionary with additional +headers that are added to, or replace the default ones included by Microdot. +The next example returns an HTML response, instead of the default plain text +response:: + + @app.get('/') + async def index(request): + return '

Hello, World!

', 202, {'Content-Type': 'text/html'} + +If the application does not need to return a body, then it can omit it and +have the status code as the first or only returned value:: + + @app.get('/') + async def index(request): + return 204 + +Likewise, if the application needs to return a body and custom headers, but +does not need to change the default status code, then it can return two values, +omitting the status code:: + + @app.get('/') + async def index(request): + return '

Hello, World!

', {'Content-Type': 'text/html'} + +Lastly, the application can also return a :class:`Response ` +object containing all the details of the response as a single value. + +JSON Responses +^^^^^^^^^^^^^^ + +If the application needs to return a response with JSON formatted data, it can +return a dictionary or a list as the first value, and Microdot will +automatically format the response as JSON. + +Example:: + + @app.get('/') + async def index(request): + return {'hello': 'world'} + +.. note:: + A ``Content-Type`` header set to ``application/json`` is automatically added + to the response. + +Redirects +^^^^^^^^^ + +The :func:`redirect ` function is a helper that +creates redirect responses:: + + from microdot import redirect + + @app.get('/') + async def index(request): + return redirect('/about') + +File Responses +^^^^^^^^^^^^^^ + +The :func:`send_file ` function builds a response +object for a file:: + + from microdot import send_file + + @app.get('/') + async def index(request): + return send_file('/static/index.html') + +A suggested caching duration can be returned to the client in the ``max_age`` +argument:: + + from microdot import send_file + + @app.get('/') + async def image(request): + return send_file('/static/image.jpg', max_age=3600) # in seconds + +.. note:: + Unlike other web frameworks, Microdot does not automatically configure a + route to serve static files. The following is an example route that can be + added to the application to serve static files from a *static* directory in + the project:: + + @app.route('/static/') + async def static(request, path): + if '..' in path: + # directory traversal is not allowed + return 'Not found', 404 + return send_file('static/' + path, max_age=86400) + +Streaming Responses +^^^^^^^^^^^^^^^^^^^ + +Instead of providing a response as a single value, an application can opt to +return a response that is generated in chunks, by returning a Python generator. +The example below returns all the numbers in the fibonacci sequence below 100:: + + @app.get('/fibonacci') + async def fibonacci(request): + async def generate_fibonacci(): + a, b = 0, 1 + while a < 100: + yield str(a) + '\n' + a, b = b, a + b + + return generate_fibonacci() + +.. note:: + Under CPython, the generator function can be a ``def`` or ``async def`` + function, as well as a class-based generator. + + Under MicroPython, asynchronous generator functions are not supported, so + only ``def`` generator functions can be used. Asynchronous class-based + generators are supported. + +Changing the Default Response Content Type +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Microdot uses a ``text/plain`` content type by default for responses that do +not explicitly include the ``Content-Type`` header. The application can change +this default by setting the desired content type in the +:attr:`default_content_type ` attribute +of the :class:`Response ` class. + +The example that follows configures the application to use ``text/html`` as +default content type:: + + from microdot import Response + + Response.default_content_type = 'text/html' + +Setting Cookies +^^^^^^^^^^^^^^^ + +Many web applications rely on cookies to maintain client state between +requests. Cookies can be set with the ``Set-Cookie`` header in the response, +but since this is such a common practice, Microdot provides the +:func:`set_cookie() ` method in the response +object to add a properly formatted cookie header to the response. + +Given that route functions do not normally work directly with the response +object, the recommended way to set a cookie is to do it in a +:ref:`request-specific after-request handler `. + +Example:: + + @app.get('/') + async def index(request): + @request.after_request + async def set_cookie(request, response): + response.set_cookie('name', 'value') + return response + + return 'Hello, World!' + +Another option is to create a response object directly in the route function:: + + @app.get('/') + async def index(request): + response = Response('Hello, World!') + response.set_cookie('name', 'value') + return response + +.. note:: + Standard cookies do not offer sufficient privacy and security controls, so + never store sensitive information in them unless you are adding additional + protection mechanisms such as encryption or cryptographic signing. The + :ref:`session ` extension implements signed + cookies that prevent tampering by malicious actors. diff --git a/pyproject.toml b/pyproject.toml index acbb3fd..e8f052c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,7 @@ dev = [ ] docs = [ "sphinx", + "furo", "pyjwt", ] diff --git a/tox.ini b/tox.ini index 0b76f35..4372440 100644 --- a/tox.ini +++ b/tox.ini @@ -64,6 +64,7 @@ commands= changedir=docs deps= sphinx + furo pyjwt allowlist_externals= make