From d2780f554193e9b5270124926d271f8092b28cdb Mon Sep 17 00:00:00 2001 From: jtc42 Date: Wed, 22 Jan 2020 11:10:52 +0000 Subject: [PATCH] Added marshaling section to docs --- docs/source/conf.py | 2 + docs/source/extensions/marshaling.rst | 206 ++++++++++++++++++++++++-- 2 files changed, 197 insertions(+), 11 deletions(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index 9939ad13..ca5cecdf 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -225,6 +225,8 @@ epub_exclude_files = ["search.html"] intersphinx_mapping = { "openflexure_stage": ("https://openflexure-stage.readthedocs.io/en/latest/", None), "picamera": ("https://picamera.readthedocs.io/en/release-1.13/", None), + "marshmallow": ("https://marshmallow.readthedocs.io/en/stable/", None), + "webargs": ("https://webargs.readthedocs.io/en/latest/", None) } # -- Options for todo extension ---------------------------------------------- diff --git a/docs/source/extensions/marshaling.rst b/docs/source/extensions/marshaling.rst index e096545a..c5f66ef3 100644 --- a/docs/source/extensions/marshaling.rst +++ b/docs/source/extensions/marshaling.rst @@ -4,20 +4,56 @@ Marshaling data Introduction ------------ -- Define marshaling -- Why marshal objects? +The OpenFlexure Microscope Server makes use of the `Marshmallow library `_ for both response and argument marshaling. From the Marshmallow documentation: -- Marshmallow library -- Marshmallow schema and fields - - Schema class - - Schema dictionaries + **marshmallow** is an ORM/ODM/framework-agnostic library for converting complex datatypes, such as objects, to and from native Python datatypes. -- Using schema in ``@marshal_with`` -- Using schema in ``@use_args`` + In short, marshmallow schemas can be used to: -In the previous section we saw how to use fields to document the expected request body for simple requests, in which a single argument is required. By making use of Marshmallow schemas, we can allow more complex requests containing many parameters of different types. The parsed request parameters are then passed to the view function as a positional argument (as before), in the form of a dictionary. + - **Validate** input data. + - **Deserialize** input data to app-level objects. + - **Serialize** app-level objects to primitive Python types. The serialized objects can then be rendered to standard formats such as JSON for use in an HTTP API. -For example, your ``@use_args`` decorator may look like: +When developing extensions, you are encouraged to make use of the ``@marshal_with`` and ``@use_args`` decorators, along with schemas, to handle serialisation of your API responses, and parsing of request parameters respectively. + +Schemas and fields +++++++++++++++++++ + +A **field** describes the data type of a single parameter, as well as any other properties of that parameter for use in parsing, and documentation. For example, a String-type field, with a default value in case no actual value is passed, and extra documentation, may look like: + +.. code-block:: python + + fields.String(required=False, missing="Default value", example="Example value") + +A **schema** is a collection of keys and fields describing how an object should be serialized/deserialized. Schemas can be created in several ways, either by creating a ``Schema`` class, or by passing a dictionary of key-field pairs. Both methods will be discussed in the following examples. + + +Argument parsing +++++++++++++++++ + +In the previous section we saw how to use fields and ``@use_body`` to get simple arguments from requests, in which a single parameter is required. By making use of Marshmallow schemas, and the `Webargs library `_, we can allow for more complex requests containing many parameters of different types. The parsed request parameters are then passed to the view function as a positional argument (as before), in the form of a dictionary. + +For example, if you are creating an API route, in which you expect parameters ``name``, ``age``, and optionally, ``job``, your schema class may look like: + +.. code-block:: python + + from labthings.server.schema import Schema + from labthings.server import fields + + class UserSchema(Schema): + name = fields.String(required=True) + age = fields.Integer(required=True) + job = fields.String(required=False, missing="Unknown") + +To inform your POST method to expect these arguments, use the ``@use_args`` decorator: + +.. code-block:: python + + @use_args(UserSchema()) + def post(self, args): + .. + +Alternatively, if your schema is only used in a single location, it may be simpler to create a dictionary schema only where it is used, for example: .. code-block:: python @@ -26,6 +62,8 @@ For example, your ``@use_args`` decorator may look like: "age": fields.Integer(required=True), "job": fields.String(required=False, missing="Unknown") }) + def post(self, args): + ... A compatible request body, in JSON format, may look like: @@ -37,6 +75,7 @@ A compatible request body, in JSON format, may look like: "job": "Python developer" } + This JSON data is the parsed, converted into a Python dictionary, and passed as an argument. Retreiving the data from within your view function may therefore look like: .. code-block:: python @@ -52,4 +91,149 @@ This JSON data is the parsed, converted into a Python dictionary, and passed as job = args.get("job") # Returns "Python developer", type str -- Example (from ``03_marshaling_data.py``) +Object serialization +++++++++++++++++++++ + +Schemas can also be used to format our data so that it is suitable for an API response. Our API expects JSON formatted data both in, and out. It is therefore important that your API views respond with valid JSON where possible. + +Continuing with our example in the previous pages, we will enhance our ``identify`` method to provide more, better formatted information about our current microscope. + +We start by creating a schema to describe how to serialise a :py:class:`openflexure_microscope.Microscope` object. + +.. code-block:: python + + # Define which properties of a Microscope object we care about, + # and what types they should be converted to + class MicroscopeIdentifySchema(Schema): + name = fields.String() # Microscopes name + id = fields.UUID() # Microscopes unique ID + status = fields.Dict() # Status dictionary + camera = fields.String() # Camera object (represented as a string) + stage = fields.String() # Stage object (represented as a string) + + +We use this new schema in our ``identify`` view like so: + +.. code-block:: python + + class ExampleIdentifyView(View): + # Format our returned object using MicroscopeIdentifySchema + @marshal_with(MicroscopeIdentifySchema()) + def get(self): + # Find our microscope component + microscope = find_component("org.openflexure.microscope") + + # Return our microscope object, + # let @marshal_with handle formatting the output + return microscope + +Note that our ``get`` method now returns the :py:class:`openflexure_microscope.Microscope` object itself. No formatting is done by the function, it is entirely handled by ``@marshal_with(MicroscopeIdentifySchema())``. Additionally, since we defined our schema as a class, it can be re-used elsewhere. + +For our ``rename`` view, we will use a simpler schema for our input arguments, defined by a dictionary (since we are only expecting a single parameter in, and it will likely not be re-used elsewhere). Our response, however, will use our ``MicroscopeIdentifySchema`` class. This means that the *response* of our ``identify`` and ``rename`` views will be identically formatted. + +Our ``rename`` view class may now look like: + +.. code-block:: python + + class ExampleRenameView(View): + # Format our returned object using MicroscopeIdentifySchema + @marshal_with(MicroscopeIdentifySchema()) + # Expect a request parameter called "name", which is a string. Pass to argument "args". + @use_args({"name": fields.String(required=True, example="My Example Microscope")}) + def post(self, args): + # Look for our "name" parameter in the request arguments + new_name = args.get("name") + + # Find our microscope component + microscope = find_component("org.openflexure.microscope") + + # Pass microscope and new name to our rename function + rename(microscope, new_name) + + # Return our microscope object, + # let @marshal_with handle formatting the output + return microscope + + +Complete example +++++++++++++++++ + +Combining both of these into our example extension, we now have: + +.. code-block:: python + + from labthings.server.extensions import BaseExtension + from labthings.server.find import find_component + from labthings.server.view import View + + from labthings.server.decorators import use_args, marshal_with + from labthings.server.schema import Schema + from labthings.server import fields + + + ## Extension methods + + # Define which properties of a Microscope object we care about, + # and what types they should be converted to + class MicroscopeIdentifySchema(Schema): + name = fields.String() # Microscopes name + id = fields.UUID() # Microscopes unique ID + status = fields.Dict() # Status dictionary + camera = fields.String() # Camera object (represented as a string) + stage = fields.String() # Stage object (represented as a string) + + + def rename(microscope, new_name): + """ + Rename the microscope + """ + + microscope.name = new_name + microscope.save_settings() + + + ## Extension views + + class ExampleIdentifyView(View): + # Format our returned object using MicroscopeIdentifySchema + @marshal_with(MicroscopeIdentifySchema()) + def get(self): + # Find our microscope component + microscope = find_component("org.openflexure.microscope") + + # Return our microscope object, + # let @marshal_with handle formatting the output + return microscope + + + class ExampleRenameView(View): + # Format our returned object using MicroscopeIdentifySchema + @marshal_with(MicroscopeIdentifySchema()) + # Expect a request parameter called "name", which is a string. Pass to argument "args". + @use_args({"name": fields.String(required=True, example="My Example Microscope")}) + def post(self, args): + # Look for our "name" parameter in the request arguments + new_name = args.get("name") + + # Find our microscope component + microscope = find_component("org.openflexure.microscope") + + # Pass microscope and new name to our rename function + rename(microscope, new_name) + + # Return our microscope object, + # let @marshal_with handle formatting the output + return microscope + + + ## Create extension + + # Create your extension object + my_extension = BaseExtension("com.myname.myextension", version="0.0.0") + + # Add methods to your extension + my_extension.add_method(rename, "rename") + + # Add API views to your extension + my_extension.add_view(ExampleIdentifyView, "/identify") + my_extension.add_view(ExampleRenameView, "/rename")