Skip to content

bcdev/schema2ui

Repository files navigation

CI Pixi Ruff License

schema2ui

A small Python framework to convert OpenAPI Schemas into UI forms

Getting started

Install pixi and checkout schema2ui from GitHub. Change into the project folder and install dependencies:

pixi install

An easy way to understand how schema2ui works is to use the accompanied schema2ui tool. It opens a browser tab where you can select an OpenAPI schema from a list and inspect the UI generated for it. This way you can see how the framework renders the supported OpenAPI schemas, which are provided in the tests/schemas folder.

The tool can be run from the schema2ui's project root folder with

pixi run schema2ui

to bring up a simple app that looks like this:

schema2ui

UI-Generation

Any UI generation begins from a root schema of type object whose properties represent the UI's form elements. We use OpenAPI Schema v3.0.

The pipeline in short:

Process Input Descriptions
   ↓
Schema
   ↓
Field Metadata
   ↓
Field = View Model + View (Panel viewables)

The UI generation at its core is not aware of the target UI library that is used to render the UI and let users interact with it.

The default target UI library we use for schema2ui is Panel. Therefore, most of the customization configuration is directly mapped to configuration of the underlying Panel widgets and viewables.

Schema Mapping Overview

A given schema is converted into UI fields given using the available metadata that is part of the schema itself and metadata that can be specified using special schema properties prefixed by "x-ui".

Mapped OpenAPI/JSON schema metadata:

  • title - used as widget or group label
  • description - used as tool-tip text, where possible
  • default - serves as default value for the widget
  • enum - provides the options for select-like widgets
  • format - mostly for type string, controls the actual target data type

Supported "x-ui" metadata extensions:

  • x-ui-widget - hint to generate a widget of the given type
  • x-ui-placeholder - a placeholder value used in text or numeric input fields
  • x-ui-minimum - minimum value for sliders
  • x-ui-maximum - maximum value for sliders
  • x-ui-step - step size for sliders
  • x-ui-order - number of string used for sorting the fields of a group
  • x-ui-layout - grouping fields and group layout

Note that you can also use the prefix "x-ui:" instead of "x-ui-". The latter is more convenient when encoding schemas is YAML. If multiple extensions are used, in a schema or process input description, they can also be grouped in an object property named x-ui:

    x-ui: 
      widget: slider,
      minimum: 0,        
      maximum: 100,
      step: 5,
    }

The extensions can occur in bot schemas and process input/output descriptions.

Schema widget mapping:

The following list provides an overview about the currently implemented mapping of schema elements to Panel widgets and panels.

  • type: boolean: creates checkbox and switch widgets.
  • type: integer and type: number: creates numeric input or slider widgets.
    • enum: [...]: creates numeric select, radio group, or toggle button group widgets.
  • type: string: creates text input, textarea, date/time picker widgets.
    • enum: [...]: creates textual select, radio group, or toggle button group widgets.
    • format: password: creates a password input widget.
    • format: date-time: creates a datetime picker widget.
    • format: date: creates a date picker widget.
    • format: time: creates a time picker widget.
  • type: array: creates array input widgets for numeric and textual item types or array editors for any item schema type. A few special tuple types such as geographic bounding boxes and date(-time) ranges are supported too.
  • type: object: creates a sub-form with optionally ordered and outlaid fields for the object properties.
  • oneOf: [s1, s2, s3, ...]: creates a tabs panel with a tab generated for each subschemas s{i}. An optional schema discriminator is fully supported. Typically, the item schemas are objects.
  • anyOf: [s1, s2, s3, ...]: same as oneOf.
  • allOf: [s1, s2, s3, ...]: creates a field for the schema resulting from merging the schemas s{i}. Typically, the item schemas are objects.
  • nullable: true: creates a labeled switch widget that if selected, shows the generated field for the same schema, but using nullable: false. If unselected, the value is null (JSON) or Null (Python).

If none of the above is given, a JSON editor widget will be generated for a given schema.

Partly supported schema keywords:

  • $ref: only schema-relative references are currently supported. For example, $ref: #/$defs/Complex expects a schema definition named Complex in a top-level JSON object $defs in the same schema. Schema definitions can also be referenced in nested objects, e.g., $ref: #/components/schemas/Complex.
  • prefixItems: JSON schema introduced this keyword to represent typed tuples. Since there is no unambiguous OpenAPI schema representation, multiple prefix-item schemas are converted into an items value which comprises a oneOf element of the converted prefixItems schemas.
  • items: if the value is a list of schemas (= tuple), see prefixItems above.

Currently unsupported schema keywords:

  • additionalProperties: currently not implemented, ignored for time being. The plan is to support it by a special editor that allows adding and removing named properties. Will work only if and only if properties is not given.
  • minProperties, maxProperties: ignored.
  • additionalItems: ignored.
  • not - ignored, hence falls back to an untyped schema.

The following subsections describe the default mapping of schema types to Panel widgets in more detail including the available customization options.

Type boolean

Schemas of type boolean generate a checkbox by default.

Customisation options:

  • x-ui-widget: switch: generates a switch instead.

Type integer and number

Schemas of type integer and number generate an int input or float input by default if enum is not specified. With enum, a select is generated.

Customisation options:

  • x-ui-widget: slider generates a slider. Requires minimum and maximum to be given too, optionally also step to control the step size.

If enum is given too:

Type string

Schemas of Type string generates a text-input by default if format is not provided (or currently unsupported) and enum is not specified.

If enum is specified, a select widget is generated by default.

If format is given:

  • format: byte: generates a file input and stores the file data as base64-encoded string.
  • format: password: generates a password input and stores the password as plain text (take care!).
  • format: date-time: generates a datetime picker.
  • format: date: generates a date picker.
  • format: time: generates a time picker.

Customisation options:

  • x-ui-widget: dropper: generates a file dropper if format is bytes and stores the file data as base64-encoded string.
  • x-ui-widget: input: generates a datetime input if format is date-time.
  • x-ui-widget: textarea: generates a text area input if format is not provided or currently unsupported.

If enum is specified:

Type array

With a few exceptions, the array type will generate an array editor field. The editor is used to interactively add, edit, and remove array items. The array item fields are generated from the schema's items property which specifies the items' schema.

A few tuple types are supported that generate special widgets instead of the default array editor:

  • geographic bounding boxes: item type number with minItems: 4, maxItems: 4, and x-ui-widget: map (or format: bbox) creates a special editor to enter the bounding box using an ipyleaflet map. It lets users draw a geometry whose bounding box will become the effective field value.

  • date(-time) ranges: item type string with minItems: 2, maxItems: 2 creates a date-time range picker for item format date-time or a date range picker for item format date.

Customisation options:

  • x-ui-widget: input generates array input widgets where users enter array items into a text-input separated by a comma or a character specified by the x-ui-separator property.
  • x-ui-widget: textarea similar as above, but uses a multi-line text area.

Type object

Schemas of Type object generate a sub-form with optionally ordered and outlaid fields for the object's properties.

Customisation options:

If no layout or order is specified, the property forms are generated in the order that corresponds to order of the properties in the object schema.

  • x-ui-layout: column: arranges the sub-fields in a column
  • x-ui-layout: row: arranges the sub-fields in a row
  • x-ui-layout: { layout }: specifies a layout. A layout is an object with two properties type and items. type is either column (default) or row and items is an optional list of property names or of other layout objects. If items is not not given, it defaults to all properties that have not yet been part of the layout.
  • x-ui-order: <order>: an integer value that can be used in the property schemas to specify the fields order. The default order value is the index of a property in its given order.

nullable Schemas

If nullable: true a labeled switch widget is created that if selected, shows the generated field for the same schema, but using nullable: false. If the switch is unselected, the effective value of the field will be null (Null in Python).

oneOf and anyOf Schemas

The values of both oneOf and anyOf are lists that define alternative schemas. They create create a tabs panel with a tab generated for each subschemas s{i}.

Given that all subschemas are of type object an optional schema discriminator specifies a common object property whose value uniquely identifies the type of the subschema. The subschemas must be specified by schema references using the $ref keyword. The discriminator property is omitted from the generated sub-forms as its field value is determined by $ref schema name or the discriminator's optional mapping keys.

allOf Schemas

The allOf schema combination creates a field for the schema resulting from merging all its subschemas. Typically, the item schemas are objects and allOf is used to represent a type derived form two or more subtypes.

Customizing the UI Generation

Follow the steps below to customize the UI generation using your own schema2ui.FieldFactory.

1. Define the required customization

Let's say we want to generate a custom UI for a 2-tuple representing a numeric value range, whose values can be validated by the following schema:

    type: array
    items: 
      type: number
      minimum: -1.0
      maximum: 1.0
    minItems: 2
    maxItems: 2
    default: [-1.0, 1.0]

The UI field to be generated for this schema should be a numeric range slider. Furthermore, we say that the field is only used if x-ui-widget: range-input.

2. Create a custom field factory

In order to generate the desired UI field, we'll develop a custom schema2ui.panel.PanelFieldFactory class and register an instance of it in the framework.

The PanelFieldFactory is a typed abstract base class that implements the generic schema2ui.FieldFactory interface. A FieldFactory is responsible for calculating a "suitability score" for a given field metadata schema2ui.FieldMeta which includes the OpenAPI schema and derived metadata.

The framework selects the factory with the highest score for a given schema. The default score returned by the inbuilt factories is 5. If a factory was selected, it is asked to create a schema2ui.Field given the current schema2ui.FieldContext. A PanelFieldFactory is supposed to only create schema2ui.panel.PanelField instances.

The above is best explained by example. First we create a new class NumberRangeFactory that derives from PanelFieldFactoryBase:

from schema2ui import FieldContext, FieldMeta
from schema2ui.models import DataType
from schema2ui.panel import PanelField, PanelFieldFactoryBase


class NumberRangeFactory(PanelFieldFactoryBase):
    def get_array_score(self, meta: FieldMeta) -> int:
        """Compute the score of this factory for an array schema."""
        ...

    def create_array_field(self, ctx: FieldContext) -> PanelField:
        """Create the Panel field for the selected array schema."""
        ...

3. Implement the custom field factory

Lets implement get_array_score() first:

    def get_array_score(self, meta: FieldMeta) -> int:
        schema = meta.schema_
        assert schema.type == DataType.array  # will never fail 
        is_range_input = (
            meta.widget == 'range-input' 
            and schema.items is not None 
            and schema.items.type == DataType.number
            and schema.minItems == 2 
            and schema.maxItems == 2
        )
        return 100 if is_range_input else 0

Now create_array_field():

    def create_array_field(self, ctx: FieldContext) -> int:
        # A view model is a reactive holder for some value, 
        # here an array value:
        view_model=ctx.vm.array()
        # The view must derive from type pn.widgets.WidgetBase
        view = pn.widgets.EditableRangeSlider(
            name=ctx.label,  # always use ctx.label for adding labels to widgets 
            value=view_model.value, # the view model's initial value is used here
        )
        return PanelField(view_model=view_model, view=view)

4. Register the custom field factory

Finally, we register an instance of the new class in the Cuiman client's configuration:

from cuiman.api import ClientConfig

config = ClientConfig.default_config
config.get_field_factory_registry().register(NumberRangeFactory())

The next time you run the Cuiman GUI client, it will consider that factory for generating its GUIs for a given OGC process description, provided that the above code is executed once before the GUI is used.

schema2ui API Description

The schema2ui package contains the code to generate widgets and panels from plain OpenAPI Schema instances of type schema2ui.models.Schema.

The design of the framework's core is neutral with respect to the target UI library. An implementation that generates UIs for the Panel library is located in the schema2ui.panel package.

Programming Model

The framework entry point to generate UI from an OpenAPI schema is the schema2ui.FieldGenerator class that is configured with schema2ui.FieldFactoryRegistry populated with one or more schema2ui.FieldFactory implementations.

The schema2ui.FieldFactoryBase class eases implementing factories for custom field types.

The following snipped explains the programming model:

from schema2ui import FieldGenerator, FieldMeta, FieldFactoryRegistry
from schema2ui.models import Schema

# what is needed:
# --- factories that generate fields from metadata (required)
from mylib import MyFieldFactory1, MyFieldFactory2
# --- observer for value changes in the generated UI tree (optional)
from mylib import MyViewModelObserver
# --- top-level field metadata, e.g. from OpenAPI Schema (required)
my_schema = Schema(**{...})
my_field_meta = FieldMeta.from_schema(my_schema)
# --- an initial value for the UI (optional)
my_value = {}

# Generator setup:
registry = FieldFactoryRegistry()
registry.register(MyFieldFactory1())
registry.register(MyFieldFactory2())
generator = FieldGenerator(registry)
# Generator usage:
field = generator.generate_field(my_field_meta)

# Then:
# --- populate UI fields
field.view_model.value = my_value
# --- observe value changes in UI fields
my_observer = MyViewModelObserver()
field.view_model.watch(my_observer)
# --- and do something to render field.view

Framework Overview

The following class diagram provides an overview of the classes and interfaces in schema2ui:

---
config:
    class:
        hideEmptyMembersBox: true
    theme: default
---
classDiagram
    direction LR

    Field ..> FieldMeta
    Field --> View
    Field --> schema2ui.vm.ViewModel
    FieldBase --|> Field
    
    schema2ui.vm.ViewModel --> FieldMeta
    
    class Field {    
        meta: FieldMeta
        view_model: ViewModel*
        view: Any*
    }

    
    class FieldBase {
        meta: FieldMeta
        view_model: ViewModel
        view: Any
        _bind()*
    }

    class FieldMeta {
        name: str
        schema: Schema
        widget: str
        layout: FieldLayout
        order: int
        advanced: bool
        title: str
        description: str
        
        from_schema(schema)$ FieldMeta
        from_input_description(input)$ FieldMeta
        from_input_descriptions(inputs)$ FieldMeta
    }
Loading
---
config:
    class:
        hideEmptyMembersBox: true
    theme: default
---
classDiagram
    direction TB
    
    FieldContext --> FieldMeta
    FieldFactory ..> Field: create
    FieldFactory ..> FieldContext: use
    FieldFactory ..> FieldContext: use
    FieldFactoryBase --|> FieldFactory
    FieldGenerator ..> FieldContext : create
    FieldGenerator --> FieldFactoryRegistry
    FieldFactoryRegistry *--> FieldFactory 

    class FieldFactory {
        get_score(meta: FieldMeta)* int
        create_field(ctx: FieldContext)* Field 
    }

    class FieldFactoryBase {
        get_score(meta: FieldMeta) int
        get_boolean_score(meta: FieldMeta)* int
        get_integer_score(meta: FieldMeta)* int
        get_..._score(meta: FieldMeta)* int

        create_field(ctx: FieldContext) Field 
        create_boolean_field(ctx: FieldContext)* Field 
        create_integer_field(ctx: FieldContext)* Field 
        create_..._field(ctx: FieldContext)* Field 
    }

    class FieldContext {
        meta: FieldMeta
        vm: ViewModelFactory
        
        create_property_fields(obj_meta) dict
        create_item_field(array_meta) Field
        create_child_field(child_meta) Field
    }

    class FieldFactoryRegistry {
        register(factory) Callable
        lookup(meta: FieldMeta) FieldFactory|None
    }

    class FieldGenerator {
        generate_field(meta: FieldMeta, initial_value) Field
    }
Loading

A schema2ui.vm.ViewModel is used by a schema2ui.Field to hold the value currently being edited by the field's view - a widget or any viewable that is rendered and managed by the actual UI-library used. View model changes are propagated into the view and vice-versa. Since fields may form a field tree, changes observed in child view model are propagated to their parents.

---
config:
    class:
        hideEmptyMembersBox: true
    theme: default
---
classDiagram
    direction BT

    ViewModel *--> ViewModelObserver 
    ViewModel --> schema2ui.FieldMeta 
    ViewModelObserver ..> ViewModelChangeEvent : consume
    ViewModel ..> ViewModelChangeEvent : emit
    ViewModelChangeEvent --|> ViewModelChangeEvent 
    
    class ViewModel {
        meta: FieldMeta
        value: Any
        watch(observer: Callable, ...) Callable 
        dispose()        
        _get_value()* Any
        _set_value(value: Any)*
    }
    
    class ViewModelChangeEvent {
        source: ViewModel
        causes: ViewModelChangeEvent[]
    }
    
    PrimitiveViewModel --|> ViewModel
    CompositeViewModel --|> ViewModel
    ArrayViewModel --|> CompositeViewModel
    ObjectViewModel --|> CompositeViewModel
Loading

Panel Implementation

The framework's view model API is defined in schema2ui.vm.

Basic Usage

The package schema2ui.panel defines the PanelField and PanelFieldFactory classes for generating UIs from OpenAPI Schema targeting the Panel UI-library. For example, you can generate a UI from a given OpenAPI schema using

from schema2ui.panel import PanelField

my_field = PanelField.from_schema(my_schema)

The my_field.view object will then contain the Panel UI and the my_field.view_model can be used to get, set, and observe the edited value.

The view is a widget-like component (it is likely a panel.widgets.WidgetBase) that can be used as part of a larger UI developed with Panel.

The PanelFieldFactoryBase class eases supporting custom field types targeting the Panel library.

Developers of and contributors to schema2ui should use the provided schema2ui tool edit related schemas in tests/schemas:

pixi run schema2ui

The schemas are also used to perform unit-level smoke tests with PanelField.from_schema().

About

A Python library to convert OpenAPI Schemas into UI forms

Resources

License

Code of conduct

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages