Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add json schema to submitted data of json dump plugin #5007

Merged
Changes from 1 commit
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
573492f
:sparkles: [#4980] Add json schema definition to static variables
viktorvanwijk Jan 7, 2025
e0a1760
:sparkles: [#4980] Add json schema definition to formio components
viktorvanwijk Jan 7, 2025
791b151
:sparkles: [#4980] Add function to generate JSON schema from a form a…
viktorvanwijk Jan 8, 2025
e6bb253
:white_check_mark: [#4980] Add tests
viktorvanwijk Jan 8, 2025
4867708
:sparkles: [#4980] Add schema definitions for user-defined variables
viktorvanwijk Jan 10, 2025
0c77904
:sparkles: [#4980] Process file schema to make it represent the data …
viktorvanwijk Jan 10, 2025
1f8ee58
:bug: [#4980] Add empty string to list of choices to account for an u…
viktorvanwijk Jan 10, 2025
92b5db5
:bug: [#4980] Set required properties in select boxes schema to empty…
viktorvanwijk Jan 10, 2025
6000596
:bug: [#4980] Revise as_json_schema for select, select boxes, and rad…
viktorvanwijk Jan 13, 2025
6e11e3b
:sparkles: [#4980] Revise processing of radio, select, and selectboxe…
viktorvanwijk Jan 15, 2025
e07c0a0
:white_check_mark: [#4980] Update tests
viktorvanwijk Jan 20, 2025
de22b21
:truck: [#4980] Make few functions public API
viktorvanwijk Jan 24, 2025
19c8ffc
:art: [#4980] Clean up post-processing
viktorvanwijk Jan 24, 2025
5e4c4e6
:truck: [#4980] Revise and move implementation of converting user-def…
viktorvanwijk Jan 24, 2025
5de478d
:truck: [#4980] Move implementation of JSON schema generation
viktorvanwijk Jan 24, 2025
b157657
:white_check_mark: [#4980] Update tests
viktorvanwijk Jan 24, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
✨ [#4980] Add json schema definition to formio components
viktorvanwijk committed Jan 28, 2025
commit e0a1760c78d0c21e63bb33271ac327395d54a444
117 changes: 111 additions & 6 deletions src/openforms/formio/components/custom.py
Original file line number Diff line number Diff line change
@@ -16,7 +16,7 @@
from openforms.authentication.service import AuthAttribute
from openforms.config.models import GlobalConfiguration, MapTileLayer
from openforms.submissions.models import Submission
from openforms.typing import DataMapping
from openforms.typing import DataMapping, JSONObject
from openforms.utils.date import TIMEZONE_AMS, datetime_in_amsterdam, format_date_value
from openforms.utils.validators import BSNValidator, IBANValidator
from openforms.validations.service import PluginValidator
@@ -43,7 +43,7 @@
from .np_family_members.haal_centraal import get_np_family_members_haal_centraal
from .np_family_members.models import FamilyMembersTypeConfig
from .np_family_members.stuf_bg import get_np_family_members_stuf_bg
from .utils import _normalize_pattern, salt_location_message
from .utils import _normalize_pattern, salt_location_message, to_multiple

logger = logging.getLogger(__name__)

@@ -109,6 +109,14 @@ def build_serializer_field(
)
return serializers.ListField(child=base) if multiple else base

@staticmethod
def as_json_schema(component: DateComponent) -> JSONObject:
label = component.get("label", "Date")
multiple = component.get("multiple", False)

base = {"title": label, "format": "date", "type": "string"}
return to_multiple(base) if multiple else base


class FormioDateTimeField(serializers.DateTimeField):
def validate_empty_values(self, data):
@@ -190,6 +198,14 @@ def build_serializer_field(
)
return serializers.ListField(child=base) if multiple else base

@staticmethod
def as_json_schema(component: Component) -> JSONObject:
label = component.get("label", "Date time")
multiple = component.get("multiple", False)

base = {"title": label, "format": "date-time", "type": "string"}
return to_multiple(base) if multiple else base


@register("map")
class Map(BasePlugin[MapComponent]):
@@ -222,6 +238,24 @@ def build_serializer_field(self, component: MapComponent) -> serializers.ListFie
)
return serializers.ListField(child=base, min_length=2, max_length=2)

@staticmethod
def as_json_schema(component: MapComponent) -> JSONObject:
label = component.get("label", "Map coordinate")

base = {
"title": label,
"type": "array",
"prefixItems": [
{"title": "Latitude", "type": "number"},
{"title": "Longitude", "type": "number"},
],
"items": False,
"minItems": 2,
"maxItems": 2,
}

return base
Comment on lines +242 to +258
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This has changes substantially with Robin's map work 😬 it now takes GeoJSON.



@register("postcode")
class Postcode(BasePlugin[Component]):
@@ -253,8 +287,8 @@ def build_serializer_field(
# dynamically add in more kwargs based on the component configuration
extra = {}
validators = []
# adding in the validator is more explicit than changing to serialiers.RegexField,
# which essentially does the same.
# adding in the validator is more explicit than changing to
# serializers.RegexField, which essentially does the same.
if pattern := validate.get("pattern"):
validators.append(
RegexValidator(
@@ -274,6 +308,15 @@ def build_serializer_field(
)
return serializers.ListField(child=base) if multiple else base

@staticmethod
def as_json_schema(component: Component) -> JSONObject:
# TODO-4923: add a regex
label = component.get("label", "Postcode")
multiple = component.get("multiple", False)

base = {"title": label, "type": "string"}
viktorvanwijk marked this conversation as resolved.
Show resolved Hide resolved
return to_multiple(base) if multiple else base


class FamilyMembersHandler(Protocol):
def __call__(
@@ -362,6 +405,12 @@ def mutate_config_dynamically(
for value, label in child_choices
]

@staticmethod
def as_json_schema(component: Component) -> JSONObject:
# This component plugin is transformed into a SelectBoxes component, so a schema
# is not relevant here
raise NotImplementedError()


@register("bsn")
class BSN(BasePlugin[Component]):
@@ -393,6 +442,19 @@ def build_serializer_field(
)
return serializers.ListField(child=base) if multiple else base

@staticmethod
def as_json_schema(component: Component) -> JSONObject:
label = component.get("label", "BSN")
multiple = component.get("multiple", False)

base = {
"title": label,
"type": "string",
"pattern": "^\\d{9}",
"format": "nl-bsn",
}
return to_multiple(base) if multiple else base


class AddressValueSerializer(serializers.Serializer):
postcode = serializers.RegexField(
@@ -510,6 +572,25 @@ def build_serializer_field(
**extra,
)

@staticmethod
def as_json_schema(component: AddressNLComponent) -> JSONObject:
label = component.get("label", "Address NL")
base = {
"title": label,
"type": "object",
"properties": {
"city": {"type": "string"},
"houseLetter": {"type": "string"},
"houseNumber": {"type": "string"},
"houseNumberAddition": {"type": "string"},
"postcode": {"type": "string"},
"streetName": {"type": "string"},
},
"required": ["houseNumber", "postcode"],
}

return base


@register("cosign")
class Cosign(BasePlugin):
@@ -520,6 +601,14 @@ def build_serializer_field(self, component: Component) -> serializers.EmailField
required = validate.get("required", False)
return serializers.EmailField(required=required, allow_blank=not required)

@staticmethod
def as_json_schema(component: Component) -> JSONObject:
label = component.get("label", "Cosign email")

base = {"title": label, "type": "string", "format": "email"}

return base


@register("iban")
class Iban(BasePlugin):
@@ -542,6 +631,14 @@ def build_serializer_field(
)
return serializers.ListField(child=base) if multiple else base

@staticmethod
def as_json_schema(component: Component) -> JSONObject:
label = component.get("label", "IBAN")
multiple = component.get("multiple", False)

base = {"title": label, "type": "string"}
return to_multiple(base) if multiple else base


@register("licenseplate")
class LicensePlate(BasePlugin):
@@ -556,8 +653,8 @@ def build_serializer_field(

extra = {}
validators = []
# adding in the validator is more explicit than changing to serialiers.RegexField,
# which essentially does the same.
# adding in the validator is more explicit than changing to
# serializers.RegexField, which essentially does the same.
if pattern := validate.get("pattern"):
validators.append(
RegexValidator(
@@ -579,3 +676,11 @@ def build_serializer_field(
)

return serializers.ListField(child=base) if multiple else base

@staticmethod
def as_json_schema(component: Component) -> JSONObject:
label = component.get("label", "License plate")
multiple = component.get("multiple", False)

base = {"title": label, "type": "string"}
return to_multiple(base) if multiple else base
17 changes: 17 additions & 0 deletions src/openforms/formio/components/utils.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
from django.utils.crypto import salted_hmac

from openforms.typing import JSONObject

from ..typing import Component

def _normalize_pattern(pattern: str) -> str:
"""
@@ -19,3 +22,17 @@ def salt_location_message(message_bits: dict[str, str]) -> str:
computed_message = f"{message_bits['postcode']}/{message_bits['number']}/{message_bits['city']}/{message_bits['street_name']}"
computed_hmac = salted_hmac("location_check", value=computed_message).hexdigest()
return computed_hmac


def to_multiple(schema: JSONObject) -> JSONObject:
"""Convert a JSON schema of a component to a schema of multiple components.

:param schema: JSON schema of a component.
:returns: JSON schema of multiple components.
"""
title = schema.pop("title")
return {
"title": title,
"type": "array",
"items": schema,
}
177 changes: 175 additions & 2 deletions src/openforms/formio/components/vanilla.py
Original file line number Diff line number Diff line change
@@ -31,7 +31,7 @@
from openforms.config.models import GlobalConfiguration
from openforms.submissions.attachments import temporary_upload_from_url
from openforms.submissions.models import EmailVerification
from openforms.typing import DataMapping
from openforms.typing import DataMapping, JSONObject
from openforms.utils.urls import build_absolute_uri
from openforms.validations.service import PluginValidator

@@ -68,7 +68,7 @@
)
from ..typing.base import OpenFormsConfig
from .translations import translate_options
from .utils import _normalize_pattern
from .utils import _normalize_pattern, to_multiple

if TYPE_CHECKING:
from openforms.submissions.models import Submission
@@ -136,6 +136,14 @@ def build_serializer_field(
)
return serializers.ListField(child=base) if multiple else base

@staticmethod
def as_json_schema(component: TextFieldComponent) -> JSONObject:
label = component.get("label", "Text")
multiple = component.get("multiple", False)

base = {"title": label, "type": "string"}
return to_multiple(base) if multiple else base


class EmailVerificationValidator:
message = _("The email address {value} has not been verified yet.")
@@ -196,6 +204,14 @@ def build_serializer_field(
)
return serializers.ListField(child=base) if multiple else base

@staticmethod
def as_json_schema(component: Component) -> JSONObject:
label = component.get("label", "Email")
multiple = component.get("multiple", False)

base = {"title": label, "type": "string", "format": "email"}
return to_multiple(base) if multiple else base


class FormioTimeField(serializers.TimeField):
def validate_empty_values(self, data):
@@ -276,6 +292,14 @@ def build_serializer_field(
)
return serializers.ListField(child=base) if multiple else base

@staticmethod
def as_json_schema(component: Component) -> JSONObject:
label = component.get("label", "Time")
multiple = component.get("multiple", False)

base = {"title": label, "type": "string", "format": "time"}
return to_multiple(base) if multiple else base


@register("phoneNumber")
class PhoneNumber(BasePlugin):
@@ -323,6 +347,14 @@ def build_serializer_field(
)
return serializers.ListField(child=base) if multiple else base

@staticmethod
def as_json_schema(component: Component) -> JSONObject:
label = component.get("label", "Phone number")
multiple = component.get("multiple", False)

base = {"title": label, "type": "string"}
return to_multiple(base) if multiple else base


class FileDataSerializer(serializers.Serializer):
url = serializers.URLField()
@@ -434,6 +466,42 @@ def build_serializer_field(self, component: FileComponent) -> serializers.ListFi
required=required,
)

@staticmethod
def as_json_schema(component: FileComponent) -> JSONObject:
label = component.get("label", "File")

# fmt: off
base = {
"title": label,
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"originalName": {"type": "string"},
"size": {"type": "number", "description": "Size in bytes"},
"storage": {"type": "string"},
"type": {"type": "string"},
"url": {"type": "string", "format": "uri"},
"data": {
"type": "object",
"properties": {
"baseUrl": {"type": "string", "format": "uri"},
"form": {"type": "string"},
"name": {"type": "string"},
"project": {"type": "string"},
"size": {"type": "number", "description": "Size in bytes"},
"url": {"type": "string", "format": "uri"},
},
"required": ["baseUrl", "form", "name", "project", "size", "url"],
},
},
"required": ["name", "originalName", "size", "storage", "type", "url", "data"],
},
}
# fmt: on
return base


@register("textarea")
class TextArea(BasePlugin[Component]):
@@ -461,6 +529,14 @@ def build_serializer_field(
)
return serializers.ListField(child=base) if multiple else base

@staticmethod
def as_json_schema(component: Component) -> JSONObject:
label = component.get("label", "Text area")
multiple = component.get("multiple", False)

base = {"title": label, "type": "string"}
return to_multiple(base) if multiple else base


@register("number")
class Number(BasePlugin):
@@ -492,6 +568,14 @@ def build_serializer_field(
)
return serializers.ListField(child=base) if multiple else base

@staticmethod
def as_json_schema(component: Component) -> JSONObject:
label = component.get("label", "Number")
multiple = component.get("multiple", False)

base = {"title": label, "type": "number"}
return to_multiple(base) if multiple else base


def validate_required_checkbox(value: bool) -> None:
"""
@@ -525,6 +609,13 @@ def build_serializer_field(self, component: Component) -> serializers.BooleanFie

return serializers.BooleanField(**extra)

@staticmethod
def as_json_schema(component: Component) -> JSONObject:
label = component.get("label", "Checkbox")

base = {"title": label, "type": "boolean"}
return base


class SelectboxesField(serializers.Serializer):

@@ -601,6 +692,23 @@ def build_serializer_field(

return serializer

@staticmethod
def as_json_schema(component: SelectBoxesComponent) -> JSONObject:
label = component.get("label", "Select boxes")

properties = {
options["value"]: {"type": "boolean"} for options in component["values"]
}

base = {
"title": label,
"type": "object",
"properties": properties,
"additionalProperties": False,
"required": list(properties.keys()),
}
return base


@register("select")
class Select(BasePlugin[SelectComponent]):
@@ -650,6 +758,20 @@ def build_serializer_field(
**field_kwargs,
)

@staticmethod
def as_json_schema(component: SelectComponent) -> JSONObject:
multiple = component.get("multiple", False)
label = component.get("label", "Select")

choices = [options["value"] for options in component["data"]["values"]]
viktorvanwijk marked this conversation as resolved.
Show resolved Hide resolved

base = {"type": "string", "enum": choices}
if multiple:
base = {"type": "array", "items": base}
base["title"] = label

return base


@register("currency")
class Currency(BasePlugin[Component]):
@@ -676,6 +798,13 @@ def build_serializer_field(self, component: Component) -> serializers.FloatField
required=required, allow_null=not required, **extra
)

@staticmethod
def as_json_schema(component: Component) -> JSONObject:
label = component.get("label", "Currency")
base = {"title": label, "type": "number"}

return base


@register("radio")
class Radio(BasePlugin[RadioComponent]):
@@ -711,6 +840,15 @@ def build_serializer_field(
allow_null=not required,
)

@staticmethod
def as_json_schema(component: RadioComponent) -> JSONObject:
label = component.get("label", "Radio")

choices = [options["value"] for options in component["values"]]
base = {"title": label, "type": "string", "enum": choices}

return base


@register("signature")
class Signature(BasePlugin[Component]):
@@ -721,6 +859,13 @@ def build_serializer_field(self, component: Component) -> serializers.CharField:
required = validate.get("required", False)
return serializers.CharField(required=required, allow_blank=not required)

@staticmethod
def as_json_schema(component: Component) -> JSONObject:
label = component.get("label", "Signature")
base = {"title": label, "type": "string", "format": "base64"}

return base


@register("content")
class Content(BasePlugin):
@@ -745,6 +890,11 @@ def rewrite_for_request(component: ContentComponent, request: Request):
"""
component["html"] = post_process_html(component["html"], request)

@staticmethod
def as_json_schema(component: ContentComponent) -> JSONObject:
# Not relevant as content components don't have values
raise NotImplementedError()


class EditGridField(serializers.Field):
"""
@@ -859,3 +1009,26 @@ def build_serializer_field(self, component: EditGridComponent) -> EditGridField:
allow_empty=not required,
**kwargs,
)

@staticmethod
def as_json_schema(component: EditGridComponent) -> JSONObject:
label = component.get("label", "Edit grid")

# Build the edit grid object properties by iterating over the child components
properties = {
child["key"]: register[child["type"]].as_json_schema(child)
viktorvanwijk marked this conversation as resolved.
Show resolved Hide resolved
for child in component["components"]
}

base = {
"title": label,
"type": "array",
"items": {
"type": "object",
"properties": properties,
"required": list(properties.keys()),
"additionalProperties": False,
},
}

return base
9 changes: 8 additions & 1 deletion src/openforms/formio/registry.py
Original file line number Diff line number Diff line change
@@ -22,7 +22,7 @@

from openforms.plugins.plugin import AbstractBasePlugin
from openforms.plugins.registry import BaseRegistry
from openforms.typing import DataMapping
from openforms.typing import DataMapping, JSONObject

from .typing import Component
from .utils import is_layout_component
@@ -109,6 +109,13 @@ def build_serializer_field(self, component: ComponentT) -> serializers.Field:
# validation which is common for most components.
return serializers.JSONField(required=required, allow_null=True)

@staticmethod
def as_json_schema(component: ComponentT) -> JSONObject:
"""Return JSON schema for this formio component plugin. This routine should be
implemented in the child class
"""
raise NotImplementedError()


class ComponentRegistry(BaseRegistry[BasePlugin]):
module = "formio_components"