Added JSON form docs

This commit is contained in:
Joel Collins 2019-07-08 13:28:17 +01:00
parent b7ffab6868
commit cb0e0886ac
8 changed files with 561 additions and 133 deletions

View file

@ -64,7 +64,7 @@ extensions = [
'sphinx.ext.ifconfig',
'sphinxcontrib.httpdomain',
'sphinxcontrib.autohttp.flask',
'sphinxcontrib.autohttp.flaskqref',
'sphinxcontrib.autohttp.flaskqref'
]
# Override ordering

View file

@ -1,135 +1,20 @@
Example plugin
--------------
.. code-block:: python
plugin.py
+++++++++
from openflexure_microscope.plugins import MicroscopePlugin
from openflexure_microscope.api.v1.views import MicroscopeViewPlugin
from openflexure_microscope.api.utilities import JsonPayload
.. literalinclude:: example/plugin.py
:language: python
from flask import request, Response, escape
schema.json
+++++++++++
.. literalinclude:: example/schema.json
:language: JSON
### MICROSCOPE PLUGIN ###
class MyPluginClass(MicroscopePlugin):
"""
A set of default plugins
"""
api_views = {
'/identify': IdentifyAPI,
'/hello': HelloWorldAPI,
'/timelapse': TimelapseAPI,
}
def identify(self):
"""
Demonstrate access to Microscope.camera, and Microscope.stage
"""
response = "My parent camera is {}, and my parent stage is {}.".format(self.microscope.camera,
self.microscope.stage)
return response
def hello_world(self):
"""
Demonstrate passive method
"""
return "Hello world!"
def timelapse(self, n_images):
"""
Demonstrate a long-running method that requires microscope hardware
"""
print("Starting timelapse...")
capture_array = [] # Empty list to store captures in
# Acquire locks. Exception is raised if lock is in use by another thread.
with self.microscope.camera.lock, self.microscope.stage.lock:
for _ in range(n_images):
# Create a data stream to capture to
capture_data = self.microscope.camera.new_image(
write_to_file=True,
temporary=False)
# Capture a still image from the Pi camera, into the data stream
self.microscope.camera.capture(
capture_data,
use_video_port=True)
# Append the capture data to our list
capture_array.append(capture_data)
# Wait for 1 minute
time.sleep(60)
### API VIEWS ###
class IdentifyAPI(MicroscopeViewPlugin):
"""
A simple example API plugin, attached through the main microscope plugin.
"""
def get(self):
"""
Method to call when an HTTP GET request is made.
"""
# Call a method from our plugin, using the MicroscopeViewPlugin.plugin shortcut
data = self.plugin.identify()
return Response(escape(data))
class HelloWorldAPI(MicroscopeViewPlugin):
"""
A method to create, set, and return a new microscope parameter.
"""
def get(self):
"""
Method to call when an HTTP GET request is made.
"""
# If the microscope does not already contain our plugin_string attribute
if not hasattr(self.microscope, 'plugin_string'):
# Make a string, using the MicroscopeViewPlugin.plugin shortcut
self.microscope.plugin_string = self.plugin.hello_world()
return Response(self.microscope.plugin_string)
def post(self):
"""
Method to call when an HTTP POST request is made.
Assumes request will include a JSON payload.
"""
# Get payload JSON
payload = JsonPayload(request)
# Extract a value from the JSON key 'plugin_string', and convert to a string. If no value is given, default to empty.
new_plugin_string = payload.param('plugin_string', default='', convert=str)
if new_plugin_string: # If not None or empty
# Set microscope attribute to the specified string
self.microscope.plugin_string = new_plugin_string
return Response(self.microscope.plugin_string)
class TimelapseAPI(MicroscopeViewPlugin):
def post(self):
# Get any JSON data in the body of the POST request
payload = JsonPayload(request)
# Extract the "n_images" parameter if it was passed. Otherwise, default to 10.
n_images = payload.param('n_images', default=10, convert=int)
# Attach the long-running method as a microscope task
self.timelapse_task = self.microscope.task.start(self.plugin.timelapse, n_images)
# Return the state of the task (will show ID, start time, and status before the task has finished)
return jsonify(self.timelapse_task.state), 202
Notes
+++++
In this example, if the package or file were named ``my_plugin``, the three microscope plugin methods would be accessible from ``<microscope_object>.plugin.my_plugin.identify()``, ``<microscope_object>.plugin.my_plugin.timelapse()``, and ``<microscope_object>.plugin.my_plugin.hello_world()``.

View file

@ -0,0 +1,146 @@
from openflexure_microscope.plugins import MicroscopePlugin
from openflexure_microscope.api.v1.views import MicroscopeViewPlugin
from openflexure_microscope.api.utilities import JsonPayload
import os
import time
import json
from flask import request, Response, escape, jsonify
HERE = os.path.dirname(os.path.realpath(__file__))
SCHEMA_PATH = os.path.join(HERE, "schema.json")
### MICROSCOPE PLUGIN ###
class MyPluginClass(MicroscopePlugin):
"""
A set of default plugins
"""
global SCHEMA_PATH
with open(SCHEMA_PATH, 'r') as sc:
api_schema = json.load(sc)
api_views = {
'/identify': IdentifyAPI,
'/hello': HelloWorldAPI,
'/timelapse': TimelapseAPI,
}
def identify(self):
"""
Demonstrate access to Microscope.camera, and Microscope.stage
"""
response = "My parent camera is {}, and my parent stage is {}.".format(self.microscope.camera,
self.microscope.stage)
return response
def hello_world(self):
"""
Demonstrate passive method
"""
return "Hello world!"
def timelapse(self, n_images):
"""
Demonstrate a long-running method that requires microscope hardware
"""
print("Starting timelapse...")
capture_array = [] # Empty list to store captures in
# Acquire locks. Exception is raised if lock is in use by another thread.
with self.microscope.camera.lock, self.microscope.stage.lock:
for _ in range(n_images):
# Create a data stream to capture to
capture_data = self.microscope.camera.new_image(
write_to_file=True,
temporary=False)
# Capture a still image from the Pi camera, into the data stream
self.microscope.camera.capture(
capture_data,
use_video_port=True)
# Append the capture data to our list
capture_array.append(capture_data)
# Wait for 1 minute
time.sleep(60)
### API VIEWS ###
class IdentifyAPI(MicroscopeViewPlugin):
"""
A simple example API plugin, attached through the main microscope plugin.
"""
def get(self):
"""
Method to call when an HTTP GET request is made.
"""
# Call a method from our plugin, using the MicroscopeViewPlugin.plugin shortcut
data = self.plugin.identify()
return Response(escape(data))
class HelloWorldAPI(MicroscopeViewPlugin):
"""
A method to create, set, and return a new microscope parameter.
"""
def get(self):
"""
Method to call when an HTTP GET request is made.
"""
# If the microscope does not already contain our plugin_string attribute
if not hasattr(self.microscope, 'plugin_string'):
# Make a string, using the MicroscopeViewPlugin.plugin shortcut
self.microscope.plugin_string = self.plugin.hello_world()
json_response = jsonify({
'plugin_string': self.microscope.plugin_string
})
return Response(json_response)
def post(self):
"""
Method to call when an HTTP POST request is made.
Assumes request will include a JSON payload.
"""
# Get payload JSON
payload = JsonPayload(request)
# Extract a value from the JSON key 'plugin_string', and convert to a string. If no value is given, default to empty.
new_plugin_string = payload.param('plugin_string', default='', convert=str)
if new_plugin_string: # If not None or empty
# Set microscope attribute to the specified string
self.microscope.plugin_string = new_plugin_string
json_response = jsonify({
'plugin_string': self.microscope.plugin_string
})
return Response(json_response)
class TimelapseAPI(MicroscopeViewPlugin):
def post(self):
# Get any JSON data in the body of the POST request
payload = JsonPayload(request)
# Extract the "n_images" parameter if it was passed. Otherwise, default to 10.
n_images = payload.param('n_images', default=10, convert=int)
# Attach the long-running method as a microscope task
self.timelapse_task = self.microscope.task.start(self.plugin.timelapse, n_images)
# Return the state of the task (will show ID, start time, and status before the task has finished)
return jsonify(self.timelapse_task.state), 202

View file

@ -0,0 +1,33 @@
{
"id": "example-plugin",
"icon": "pets",
"forms": [
{
"name": "Hello world",
"isTask": false,
"selfUpdate": true,
"route": "/hello",
"schema": [
{
"fieldType": "textInput",
"placeholder": "Enter a string",
"label": "Plugin string",
"name": "plugin_string"
}
]
},
{
"name": "Timelapse",
"isTask": true,
"route": "/timelapse",
"submitLabel": "Start timelapse",
"schema": [
{
"fieldType": "numberInput",
"name": "n_images",
"label": "Number of images"
}
]
}
]
}

View file

@ -2,18 +2,97 @@ Adding a GUI
=====================
.. toctree::
:maxdepth: 2
:caption: Contents:
:maxdepth: 1
Components
----------
./schema/json_schema.rst
.. list-table:: Summary of form components
Introduction
------------
In order to bind user-interface elements to your plugin, an ``api_schema`` variable must be included in your microscope plugin, similar to your ``api_views``.
This variable must be in the form of a dictionary, with all data able to be parsed into JSON. Because of this requirement, it is suggested that you create your API schema as a JSON file, and load that file as a dictionary into your plugin. For example:
.. code-block:: python
import json
HERE = os.path.dirname(os.path.realpath(__file__)) # Find the full path of your plugin Python file
SCHEMA_PATH = os.path.join(HERE, "schema.json") # Find the full path of the adjascent JSON file
class MyPluginClass(MicroscopePlugin):
with open(SCHEMA_PATH, 'r') as sc: # Open the JSON file
api_schema = json.load(sc) # Load the JSON file into the api_schema dictionary
Throughout this documentation, all example of ``api_schema`` sections will be in JSON format. Keep in mind however that it is possible to directly define your ``api_schema`` as a dictionary, without loading an external file.
Forms from ``api_schema``
-------------------------
The ``api_schema`` object essentially describes HTML forms, which it is up to the client to render. The form is constructed by specifying a set of components, and their values. A form can update it's values by sending a GET request to the API route bound to that form, and can send it's current values via a POST request to *this same API route*.
Each component in the form has a ``name`` property, which must match up to a property your API route expects in JSON POST requests, and returns in JSON GET requests.
Structure of ``api_schema``
---------------------------
Root level
++++++++++
The root of your ``api_schema`` expects 3 properties:
``id`` - A unique ID to give your client-side plugin
``icon`` - The name of a Material Design icon to use for your plugin
``forms`` - An array of forms as described below
Form level
++++++++++
Your plugin can contain multiple forms. For example, if your plugin creates several API routes, you will need a separate form for each route.
Each form is described by a JSON object, with the following properties:
``name`` - A human-readable name for the form
``route`` - String of the corresponding API route. *Must* match a route defined in your ``api_views`` dictionary
``selfUpdate`` *(optional)* - Whether the client should automatically update form component values with a GET request to your API route
``isTask`` *(optional)* - Whether the client should treat your API route as a long-running task
``submitLabel`` *(optional)* - String to place inside of the form's submit button
``schema`` - An array of form components as described below
Component level
+++++++++++++++
Each form can (and probably should) contain multiple components. For example, if your API route expects several properties in POST requests, each property can be bound to a form component.
Upon form submission, the form data will be converted into a JSON object of key-value pairs, where the key is the components ``name``, and the value is it's current value.
On load, the form will make a GET request to it's API route, an expects a JSON object of key-value pairs in the same format (keys will be matched to component names).
An overview of available components, and their properties, can be found below.
Arranging components
^^^^^^^^^^^^^^^^^^^^
You can request that the client render several components in a horizontal grid by placing them in an array. You cannot nest arrays however. Each component in the array will be rendered with equal width as far as possible.
Overview of components
----------------------
.. list-table::
:widths: 10 10 40 20
:header-rows: 1
:stub-columns: 1
* - Name
* - fieldType
- Data type
- Properties
- Example
@ -104,3 +183,9 @@ Components
**placeholder** (str) Placeholder value
- .. figure:: https://openflexure.gitlab.io/assets/plugin-form-components/textInput.png
JSON form example
-----------------
.. literalinclude:: schema_example.json
:language: JSON

View file

@ -0,0 +1,5 @@
Full JSON Schema
================
.. literalinclude:: schema.json
:language: JSON

View file

@ -0,0 +1,187 @@
{
"definitions": {},
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "http://example.com/root.json",
"type": "object",
"title": "The Root Schema",
"required": [
"id",
"icon",
"forms"
],
"properties": {
"id": {
"$id": "#/properties/id",
"type": "string",
"title": "Unique ID",
"description": "A unique name for the plugin UI",
"default": "",
"examples": [
"test-plugin"
],
"pattern": "^(.*)$"
},
"icon": {
"$id": "#/properties/icon",
"type": "string",
"title": "Icon name",
"description": "Name of the Material Design icon to use for the plugin tab",
"default": "",
"examples": [
"pets"
],
"pattern": "^(.*)$"
},
"forms": {
"$id": "#/properties/forms",
"type": "array",
"title": "Forms array",
"description": "Array of form descriptions to be displayed",
"default": null,
"items": {
"$id": "#/properties/forms/items",
"type": "object",
"title": "The Items Schema",
"required": [
"name",
"route",
"schema"
],
"properties": {
"name": {
"$id": "#/properties/forms/items/properties/name",
"type": "string",
"title": "Form name",
"description": "Human-readable form name",
"default": "",
"examples": [
"Simple request"
],
"pattern": "^(.*)$"
},
"isCollapsible": {
"$id": "#/properties/forms/items/properties/isCollapsible",
"type": "boolean",
"title": "The isCollapsible Schema",
"description": "Should the form be rendered as a collapsible item",
"default": false,
"examples": [
true
]
},
"isTask": {
"$id": "#/properties/forms/items/properties/isTask",
"type": "boolean",
"title": "The isTask Schema",
"description": "Should the form's submit function be treated as a long-running task",
"default": false,
"examples": [
false
]
},
"selfUpdate": {
"$id": "#/properties/forms/items/properties/selfUpdate",
"type": "boolean",
"title": "The selfUpdate Schema",
"description": "Should the form's component values be automatically updated with a GET request",
"default": false,
"examples": [
true
]
},
"route": {
"$id": "#/properties/forms/items/properties/route",
"type": "string",
"title": "The Route Schema",
"description": "Form submit POST request's corresponding api_views route",
"default": "",
"examples": [
"/do"
],
"pattern": "^(.*)$"
},
"submitLabel": {
"$id": "#/properties/forms/items/properties/submitLabel",
"type": "string",
"title": "The submitLabel Schema",
"description": "String to place in submit button",
"default": "Submit",
"examples": [
"Do my function"
],
"pattern": "^(.*)$"
},
"schema": {
"$id": "#/properties/forms/items/properties/schema",
"type": "array",
"title": "Form component array",
"items": {
"$id": "#/properties/forms/items/properties/schema/items",
"type": "object",
"title": "A form component",
"required": [
"fieldType",
"name"
],
"properties": {
"fieldType": {
"$id": "#/properties/forms/items/properties/schema/items/properties/fieldType",
"type": "string",
"title": "Component type",
"default": "",
"examples": [
"numberInput",
"textInput",
"radioList",
"checkList",
"htmlBlock",
"selectList",
"textInput",
"keyvalList",
"taglList"
],
"pattern": "^(.*)$"
},
"placeholder": {
"$id": "#/properties/forms/items/properties/schema/items/properties/placeholder",
"type": "string",
"title": "Component placeholder value",
"default": "",
"examples": [
"Enter your name"
],
"pattern": "^(.*)$"
},
"name": {
"$id": "#/properties/forms/items/properties/schema/items/properties/name",
"type": "string",
"title": "Unique component name",
"examples": [
"my_form_component_1"
],
"pattern": "^(.*)$"
},
"label": {
"$id": "#/properties/forms/items/properties/schema/items/properties/label",
"type": "string",
"title": "Component label (if applicable)",
"default": "",
"examples": [
"My form component"
],
"pattern": "^(.*)$"
},
"value": {
"$id": "#/properties/forms/items/properties/schema/items/properties/value",
"type": ["array", "boolean", "integer", "number", "object", "string"],
"title": "Component value",
"pattern": "^(.*)$"
}
}
}
}
}
}
}
}
}

View file

@ -0,0 +1,87 @@
{
"id": "test-plugin",
"icon": "pets",
"forms": [
{
"name": "Simple request",
"isCollapsible": false,
"isTask": false,
"selfUpdate": true,
"route": "/do",
"submitLabel": "Do things",
"schema": [
{
"fieldType": "numberInput",
"placeholder": "Some integer",
"name": "val_int",
"label": "Number value"
},
{
"fieldType": "textInput",
"placeholder": "Some string",
"label": "String value",
"name": "val_str"
},
{
"fieldType": "radioList",
"name": "val_radio",
"label": "Radio value",
"options": ["First", "Second", "Third"]
},
{
"fieldType": "checkList",
"name": "val_check",
"label": "Checklist values",
"options": ["Foo", "Bar", "Baz"]
},
{
"fieldType": "htmlBlock",
"name": "html_block",
"content": "<i>This is a block of HTML in a plugin!</i>"
},
{
"fieldType": "selectList",
"name": "val_select",
"multi": false,
"label": "Some selection",
"options": ["Most", "Average", "Least"]
},
{
"fieldType": "textInput",
"placeholder": "Some string",
"label": "Non-persistent string",
"name": "val_disposable"
},
{
"fieldType": "keyvalList",
"label": "A key-val object",
"name": "val_keyval",
"value": {
"key1": "foo",
"key2": "bar"
}
},
{
"fieldType": "tagList",
"label": "A tag list",
"name": "val_tags",
"value": ["tag2", "tag2", "squidward"]
}
]
},
{
"name": "Task form",
"isTask": true,
"selfUpdate": true,
"route": "/task",
"submitLabel": "Start task",
"schema": [
{
"fieldType": "numberInput",
"name": "run_time",
"label": "Run time (seconds)"
}
]
}
]
}