diff --git a/docs/api.rst b/docs/api.rst index 2c88bf7..242e144 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -4,92 +4,71 @@ API Reference ``microdot`` module ------------------- -The ``microdot`` module defines a few classes that help implement HTTP-based -servers for MicroPython and standard Python, with multithreading support for -Python interpreters that support it. - -``Microdot`` class -~~~~~~~~~~~~~~~~~~ - .. autoclass:: microdot.Microdot :members: -``Request`` class -~~~~~~~~~~~~~~~~~ - .. autoclass:: microdot.Request :members: -``Response`` class -~~~~~~~~~~~~~~~~~~ - .. autoclass:: microdot.Response :members: -``MultiDict`` class -~~~~~~~~~~~~~~~~~~~ - .. autoclass:: microdot.MultiDict :members: ``microdot_asyncio`` module --------------------------- -The ``microdot_asyncio`` module defines a few classes that help implement -HTTP-based servers for MicroPython and standard Python that use ``asyncio`` -and coroutines. - -``Microdot`` class -~~~~~~~~~~~~~~~~~~ - .. autoclass:: microdot_asyncio.Microdot :inherited-members: :members: -``Request`` class -~~~~~~~~~~~~~~~~~ - .. autoclass:: microdot_asyncio.Request :inherited-members: :members: -``Response`` class -~~~~~~~~~~~~~~~~~~ - .. autoclass:: microdot_asyncio.Response :inherited-members: :members: -``microdot_test_client`` module -------------------------------- +``microdot_utemplate`` module +----------------------------- -The ``microdot_test_client`` module defines a test client that can be used to -create automated tests for the Microdot server. +.. automodule:: microdot_utemplate + :members: -``TestClient`` class -~~~~~~~~~~~~~~~~~~~~ +``microdot_jinja`` module +------------------------- -.. autoclass:: microdot_test_client.TestClient +.. automodule:: microdot_jinja :members: -``TestResponse`` class -~~~~~~~~~~~~~~~~~~~~~~ +``microdot_session`` module +--------------------------- + +.. automodule:: microdot_session + :members: + +``microdot_test_client`` module +------------------------------- + +.. autoclass:: microdot_test_client.TestClient + :members: .. autoclass:: microdot_test_client.TestResponse :members: -``microdot_wsgi`` module ------------------------- +``microdot_asyncio_test_client`` module +--------------------------------------- -The ``microdot_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 `_. Since there are -no WSGI web servers available for MicroPython, this support is currently -limited to standard Python. +.. autoclass:: microdot_asyncio_test_client.TestClient + :members: -``Microdot`` class -~~~~~~~~~~~~~~~~~~ +.. autoclass:: microdot_asyncio_test_client.TestResponse + :members: + +``microdot_wsgi`` module +------------------------ .. autoclass:: microdot_wsgi.Microdot :members: @@ -98,15 +77,6 @@ limited to standard Python. ``microdot_asgi`` module ------------------------ -The ``microdot_asgi`` module provides an extended ``Microdot`` class that -implements the ASGI protocol and can be used with a compliant ASGI server such -as `Uvicorn `_. Since there are no ASGI web servers -available for MicroPython, this support is currently limited to standard -Python. - -``Microdot`` class -~~~~~~~~~~~~~~~~~~ - .. autoclass:: microdot_asgi.Microdot :members: :exclude-members: shutdown, run diff --git a/docs/conf.py b/docs/conf.py index 61204be..986fe2d 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -13,7 +13,7 @@ import os import sys sys.path.insert(0, os.path.abspath('../src')) - +sys.path.insert(1, os.path.abspath('../libs/common')) # -- Project information ----------------------------------------------------- diff --git a/docs/extensions.rst b/docs/extensions.rst index 481f4a4..b3e425b 100644 --- a/docs/extensions.rst +++ b/docs/extensions.rst @@ -22,10 +22,13 @@ Asynchronous Support with ``asyncio`` - | CPython: None | MicroPython: `uasyncio `_ + * - Examples + - | `hello_async.py `_ + Microdot can be extended to use an asynchronous programming model based on the ``asyncio`` package. When the :class:`Microdot ` class is imported from the ``microdot_asyncio`` package, an asynchronous server -is used. +is used, and handlers can be defined as coroutines. The example that follows uses ``asyncio`` coroutines for concurrency:: @@ -42,8 +45,14 @@ The example that follows uses ``asyncio`` coroutines for concurrency:: Rendering HTML 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 @@ -58,6 +67,32 @@ Using the uTemplate Engine * - Required external dependencies - | `utemplate `_ + * - Examples + - | `hello_utemplate.py `_ + | `hello_utemplate_async.py `_ + +The :func:`render_template ` function is +used to render HTML templates with the uTemplate engine. The first argument is +the template filename, relative to the templates directory, which is +*templates* by default. Any additional arguments are passed to the template +engine to be used as arguments. + +Example:: + + from microdot_utemplate import render_template + + @app.get('/') + def index(req): + return render_template('index.html') + +The default location from where templates are loaded is the *templates* +subdirectory. This location can be changed with the +:func:`init_templates ` function:: + + from microdot_utemplate import init_templates + + init_templates('my_templates') + Using the Jinja Engine ^^^^^^^^^^^^^^^^^^^^^^ @@ -74,6 +109,34 @@ Using the Jinja Engine * - Required external dependencies - | `Jinja2 `_ + * - Examples + - | `hello_jinja.py `_ + +The :func:`render_template ` function is used +to render HTML templates with the Jinja engine. The first argument is the +template filename, relative to the templates directory, which is *templates* by +default. Any additional arguments are passed to the template engine to be used +as arguments. + +Example:: + + from microdot_jinja import render_template + + @app.get('/') + def index(req): + return render_template('index.html') + +The default location from where templates are loaded is the *templates* +subdirectory. This location can be changed with the +:func:`init_templates ` function:: + + from microdot_jinja import init_templates + + init_templates('my_templates') + +.. note:: + The Jinja extension is not compatible with MicroPython. + Maintaing Secure User Sessions ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -89,11 +152,64 @@ Maintaing Secure User Sessions * - Required external dependencies - | CPython: `PyJWT `_ - | MicroPython: `ujwt.py `_, + | MicroPython: `jwt.py `_, `hmac `_, `hashlib `_, `warnings `_ + * - Examples + - | `login.py `_ + +The session extension provides a secure way for the application to maintain +user sessions. The session 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 the secret key +that will be used to sign the session cookies. It is very important that this +key is kept secret. An attacker who is in possession of this key can generate +valid user session cookies with any contents. + +To set the secret key, use the :func:`set_session_secret_key ` function:: + + from microdot_session import set_session_secret_key + + set_session_secret_key('top-secret!') + +To :func:`get_session `, +:func:`update_session ` and +:func:`delete_session ` functions are used +inside route handlers to retrieve, store and delete session data respectively. +The :func:`with_session ` decorator is provided +as a convenient way to retrieve the session at the start of a route handler. + +Example:: + + from microdot import Microdot + from microdot_session import set_session_secret_key, with_session, \ + update_session, delete_session + + app = Microdot() + set_session_secret_key('top-secret') + + @app.route('/', methods=['GET', 'POST']) + @with_session + def index(req, session): + username = session.get('username') + if req.method == 'POST': + username = req.form.get('username') + update_session(req, {'username': username}) + return redirect('/') + if username is None: + return 'Not logged in' + else: + return 'Logged in as ' + username + + @app.post('/logout') + def logout(req): + delete_session(req) + return redirect('/') + Test Client ~~~~~~~~~~~ @@ -110,9 +226,69 @@ Test Client * - Required external dependencies - | None +The Microdot Test Client is a utility class that can be used during testing to +send requests into the application. + +Example:: + + from microdot import Microdot + from microdot_test_client import TestClient + + app = Microdot() + + @app.route('/') + def index(req): + return 'Hello, World!' + + def test_app(): + client = TestClient(app) + response = client.get('/') + assert response.text == 'Hello, World!' + +See the documentation for the :class:`TestClient ` +class for more details. + +Asynchronous Test Client +~~~~~~~~~~~~~~~~~~~~~~~~ + +.. list-table:: + :align: left + + * - Compatibility + - | CPython & MicroPython + + * - Required Microdot source files + - | `microdot.py `_ + | `microdot_asyncio.py `_ + | `microdot_test_client.py `_ + | `microdot_asyncio_test_client.py `_ + + * - Required external dependencies + - | None + +Similar to the :class:`TestClient ` class +above, but for asynchronous applications. + +Example usage:: + + from microdot_asyncio_test_client import TestClient + + async def test_app(): + client = TestClient(app) + response = await client.get('/') + assert response.text == 'Hello, World!' + +See the :class:`reference documentation ` +for details. + Deploying on a Production Web Server ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +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 WSGI and ASGI protocols. + Using a WSGI Web Server ^^^^^^^^^^^^^^^^^^^^^^^ @@ -129,6 +305,34 @@ Using a WSGI Web Server * - Required external dependencies - | A WSGI web server, such as `Gunicorn `_. + * - Examples + - | `hello_wsgi.py `_ + + +The ``microdot_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 ``microdot_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 is a WSGI application +that can be used with any complaint WSGI web server. If the above application +is stored in a file called *test.py*, then the following command runs the +web application using the Gunicorn web server:: + + gunicorn test:app + Using an ASGI Web Server ^^^^^^^^^^^^^^^^^^^^^^^^ @@ -146,3 +350,29 @@ Using an ASGI Web Server * - Required external dependencies - | An ASGI web server, such as `Uvicorn `_. + * - Examples + - | `hello_asgi.py `_ + +The ``microdot_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 ``microdot_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 is an ASGI application +that can be used with any complaint ASGI web server. If the above application +is stored in a file called *test.py*, then the following command runs the +web application using the Uvicorn web server:: + + uvicorn test:app + diff --git a/docs/intro.rst b/docs/intro.rst index dc6d634..312ec7c 100644 --- a/docs/intro.rst +++ b/docs/intro.rst @@ -67,6 +67,9 @@ Running with CPython * - Required external dependencies - | None + * - Examples + - | `hello.py `_ + When using CPython, you can start the web server by running the script that defines and runs the application instance:: @@ -89,6 +92,10 @@ Running with MicroPython * - 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 *microdot.py*. MicroPython will automatically run *main.py* when the device is powered on, so the web server @@ -618,8 +625,8 @@ Example:: return {'hello': 'world'} .. note:: - JSON responses are sent with the ``Content-Type`` header set to - ``application/json``. + A ``Content-Type`` header set to ``application/json`` is automatically added + to the response. Redirects ^^^^^^^^^ @@ -694,9 +701,9 @@ default content type:: Setting Cookies ^^^^^^^^^^^^^^^ -Many web application 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 +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. @@ -719,7 +726,7 @@ Another option is to create a response object directly in the route function:: @app.get('/') def index(request): - response = Response('Hello, World!')) + response = Response('Hello, World!') response.set_cookie('name', 'value') return response diff --git a/src/microdot_jinja.py b/src/microdot_jinja.py index 6656fd9..533ef1d 100644 --- a/src/microdot_jinja.py +++ b/src/microdot_jinja.py @@ -18,6 +18,15 @@ def init_templates(template_dir='templates'): def render_template(template, *args, **kwargs): + """Render a template. + + :param template: The filename of the template to render, relative to the + configured template directory. + :param args: Positional arguments to be passed to the render engine. + :param kwargs: Keyword arguments to be passed to the render engine. + + The return value is a string with the rendered template. + """ if _jinja_env is None: # pragma: no cover init_templates() template = _jinja_env.get_template(template) diff --git a/src/microdot_session.py b/src/microdot_session.py index 6f565f3..430577c 100644 --- a/src/microdot_session.py +++ b/src/microdot_session.py @@ -4,11 +4,22 @@ def set_session_secret_key(key): + """Set the secret key for signing user sessions. + + :param key: The secret key, as a string or bytes object. + """ global secret_key secret_key = key def get_session(request): + """Retrieve the user session. + + :param request: The client request. + + The return value is a dictionary with the data stored in the user's + session, or ``{}`` if the session data is not available or invalid. + """ global secret_key if not secret_key: raise ValueError('The session secret key is not configured') @@ -24,6 +35,14 @@ def get_session(request): def update_session(request, session): + """Update the user session. + + :param request: The client request. + :param session: A dictionary with the update session data for the user. + + Calling this function adds a cookie with the updated session to the request + currently being processed. + """ if not secret_key: raise ValueError('The session secret key is not configured') @@ -36,6 +55,13 @@ def _update_session(request, response): def delete_session(request): + """Remove the user session. + + :param request: The client request. + + Calling this function adds a cookie removal header to the request currently + being processed. + """ @request.after_request def _delete_session(request, response): response.set_cookie('session', '', http_only=True, @@ -44,6 +70,19 @@ def _delete_session(request, response): def with_session(f): + """Decorator that passes the user session to the route handler. + + The session dictionary is passed to the decorated function as an argument + after the request object. Example:: + + @app.route('/') + @with_session + def index(request, session): + return 'Hello, World!' + + Note that the decorator does not save the session. To update the session, + call the :func:`update_session ` function. + """ def wrapper(request, *args, **kwargs): return f(request, get_session(request), *args, **kwargs) diff --git a/src/microdot_utemplate.py b/src/microdot_utemplate.py index cd767e2..ccef608 100644 --- a/src/microdot_utemplate.py +++ b/src/microdot_utemplate.py @@ -19,6 +19,15 @@ def init_templates(template_dir='templates', loader_class=recompile.Loader): def render_template(template, *args, **kwargs): + """Render a template. + + :param template: The filename of the template to render, relative to the + configured template directory. + :param args: Positional arguments to be passed to the render engine. + :param kwargs: Keyword arguments to be passed to the render engine. + + The return value is an iterator that returns sections of rendered template. + """ if _loader is None: # pragma: no cover init_templates() render = _loader.load(template)