Skip to content
Snippets Groups Projects
Code owners
Assign users and groups as approvers for specific file changes. Learn more.
json_schema_exporter.py 6.49 KiB
#!/usr/bin/env python
# encoding: utf-8
#
# This file is a part of the CaosDB Project.
#
# Copyright (C) 2023 Indiscale GmbH <info@indiscale.com>
# Copyright (C) 2023 Florian Spreckelsen <f.spreckelsen@indiscale.com>
#
# This program is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option) any
# later version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <https://www.gnu.org/licenses/>.
#

import re

import linkahead as db


def _make_required_list(rt: db.RecordType):
    """Return the list of names of properties with importance db.OBLIGATORY."""
    required = []
    for prop in rt.properties:
        importance = rt.get_importance(prop.name)
        if importance == db.OBLIGATORY:
            required.append(prop.name)
    return required


def _extract_elemet_dtyp_from_list_dtype(list_dtype):

    if not list_dtype.lower().startswith("list"):

        raise ValueError(f"Not a list dtype: {list_dtype}")

    pattern = r"^[Ll][Ii][Ss][Tt](<(?P<dtype1>.*)>|\((?P<dtype2>.*)\))$"
    comp = re.compile(pattern)
    mtch = comp.match(list_dtype)
    if mtch is None:

        raise ValueError(f"Not a list dtype: {list_dtype}")

    elif "dtype1" in mtch.groupdict() and mtch.groupdict()["dtype1"] is not None:
        return mtch.groupdict()["dtype1"]

    elif "dtype2" in mtch.groupdict() and mtch.groupdict()["dtype2"] is not None:
        return mtch.groupdict()["dtype2"]

    else:
        raise ValueError(f"Not a list dtype: {list_dtype}")


def _make_prop_from_prop(prop, additional_options_for_text_props):

    if prop.is_reference():

        raise NotImplementedError(
            "Reference properties are not supported in this version of the json schema exporter."
        )

    if prop.datatype == db.TEXT or prop.datatype == db.DATETIME:
        text_format = None
        text_pattern = None
        if prop.name in additional_options_for_text_props:
            if "pattern" in additional_options_for_text_props[prop.name]:
                text_pattern = additional_options_for_text_props[prop.name]["pattern"]
            if "format" in additional_options_for_text_props[prop.name]:
                text_format = additional_options_for_text_props[prop.name]["format"]
            elif prop.datatype == db.DATETIME:
                # Set the date or datetime format if only a pattern is given ...
                text_format = ["date", "date-time"]
        elif prop.datatype == db.DATETIME:
            # ... again, for those props that don't appear in the additional
            # options list.
            text_format = ["date", "date-time"]

        return _make_text_property(prop.description, text_format, text_pattern)

    json_prop = {}
    if prop.description:
        json_prop["description"] = prop.description

    if prop.datatype == db.BOOLEAN:
        json_prop["type"] = "boolean"
    elif prop.datatype == db.INTEGER:
        json_prop["type"] = "integer"
    elif prop.datatype == db.DOUBLE:
        json_prop["type"] = "number"
    elif isinstance(prop.datatype, str) and prop.datatype.startswith("LIST"):
        json_prop["type"] = "array"
        list_element_prop = db.Property(
            name=prop.name, datatype=_extract_elemet_dtyp_from_list_dtype(prop.datatype))
        json_prop["items"] = _make_prop_from_prop(
            list_element_prop, additional_options_for_text_props)
    else:
        raise ValueError(
            f"Unknown or no property datatype. Property {prop.name} with type {prop.datatype}")

    return json_prop


def _make_text_property(description="", text_format=None, text_pattern=None):

    prop = {
        "type": "string"
    }
    if description:
        prop["description"] = description
    if text_format is not None:
        if isinstance(text_format, list):
            prop["anyOf"] = [{"format": tf} for tf in text_format]
        else:
            prop["format"] = text_format
    if text_pattern is not None:
        prop["pattern"] = text_pattern

    return prop


def recordtype_to_json_schema(rt: db.RecordType, additional_properties: bool = True,
                              name_and_description_in_properties: bool = False,
                              additional_options_for_text_props: dict = {}):
    """Create a jsonschema from a given RecordType that can be used, e.g., to
    validate a json specifying a record of the given type.

    Parameters
    ----------
    rt : RecordType
        The RecordType from which a json schema will be created.
    additional_properties : bool, optional
        Whether additional propeties will be admitted in the resulting
        schema. Optional, default is True.
    name_and_description_in_properties : bool, optional
        Whether to include name and description in the `properties` section of
        the schema to be exported. Optional, default is False.
    additional_options_for_text_props : dict, optional
        Dictionary containing additional "pattern" or "format" options for
        string-typed properties. Optional, default is empty.

    Returns
    -------
    schema : dict
        A dict containing the json schema created from the given RecordType's properties.

    """

    schema = {
        "$schema": "https://json-schema.org/draft/2019-09/schema",
        "type": "object"
    }
    if rt.name:
        schema["title"] = rt.name
    if rt.description:
        schema["description"] = rt.description

    schema["required"] = _make_required_list(rt)
    schema["additionalProperties"] = additional_properties

    props = {}
    if name_and_description_in_properties:
        props["name"] = _make_text_property("The name of the Record to be created")
        props["description"] = _make_text_property("The description of the Record to be created")

    for prop in rt.properties:
        if prop.name in props:
            # Multi property
            raise NotImplementedError(
                "Creating a schema for multi-properties is not specified. "
                f"Property {prop.name} occurs more than once."
            )
        props[prop.name] = _make_prop_from_prop(prop, additional_options_for_text_props)

    schema["properties"] = props
    return schema