jsonschema.py 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183
  1. from dataclasses import dataclass, field
  2. from enum import Enum
  3. from typing import Any
  4. from django import forms
  5. from django.contrib.postgres.forms import SimpleArrayField
  6. from django.core.exceptions import ValidationError
  7. from django.core.validators import RegexValidator
  8. from django.utils.translation import gettext_lazy as _
  9. from jsonschema.exceptions import SchemaError
  10. from jsonschema.validators import validator_for
  11. from utilities.string import title
  12. from utilities.templatetags.builtins.filters import render_markdown
  13. from utilities.validators import MultipleOfValidator
  14. __all__ = (
  15. 'JSONSchemaProperty',
  16. 'PropertyTypeEnum',
  17. 'StringFormatEnum',
  18. 'validate_schema',
  19. )
  20. class PropertyTypeEnum(Enum):
  21. STRING = 'string'
  22. INTEGER = 'integer'
  23. NUMBER = 'number'
  24. BOOLEAN = 'boolean'
  25. ARRAY = 'array'
  26. OBJECT = 'object'
  27. class StringFormatEnum(Enum):
  28. EMAIL = 'email'
  29. URI = 'uri'
  30. IRI = 'iri'
  31. UUID = 'uuid'
  32. DATE = 'date'
  33. TIME = 'time'
  34. DATETIME = 'datetime'
  35. FORM_FIELDS = {
  36. PropertyTypeEnum.STRING.value: forms.CharField,
  37. PropertyTypeEnum.INTEGER.value: forms.IntegerField,
  38. PropertyTypeEnum.NUMBER.value: forms.FloatField,
  39. PropertyTypeEnum.BOOLEAN.value: forms.BooleanField,
  40. PropertyTypeEnum.ARRAY.value: SimpleArrayField,
  41. PropertyTypeEnum.OBJECT.value: forms.JSONField,
  42. }
  43. STRING_FORM_FIELDS = {
  44. StringFormatEnum.EMAIL.value: forms.EmailField,
  45. StringFormatEnum.URI.value: forms.URLField,
  46. StringFormatEnum.IRI.value: forms.URLField,
  47. StringFormatEnum.UUID.value: forms.UUIDField,
  48. StringFormatEnum.DATE.value: forms.DateField,
  49. StringFormatEnum.TIME.value: forms.TimeField,
  50. StringFormatEnum.DATETIME.value: forms.DateTimeField,
  51. }
  52. @dataclass
  53. class JSONSchemaProperty:
  54. type: PropertyTypeEnum = PropertyTypeEnum.STRING.value
  55. title: str | None = None
  56. description: str | None = None
  57. default: Any = None
  58. enum: list | None = None
  59. # Strings
  60. minLength: int | None = None
  61. maxLength: int | None = None
  62. pattern: str | None = None # Regex
  63. format: StringFormatEnum | None = None
  64. # Numbers
  65. minimum: int | float | None = None
  66. maximum: int | float | None = None
  67. multipleOf: int | float | None = None
  68. # Arrays
  69. items: dict | None = field(default_factory=dict)
  70. def to_form_field(self, name, required=False):
  71. """
  72. Instantiate and return a Django form field suitable for editing the property's value.
  73. """
  74. field_class = self.field_class
  75. field_kwargs = {
  76. 'label': self.title or title(name),
  77. 'help_text': render_markdown(self.description),
  78. 'required': required,
  79. 'initial': self.default,
  80. }
  81. # Choices
  82. if self.type == PropertyTypeEnum.ARRAY.value and (item_enums := self.items.get('enum')):
  83. field_kwargs['choices'] = [(v, v) for v in item_enums]
  84. elif self.enum:
  85. choices = [(v, v) for v in self.enum]
  86. if not required:
  87. choices = [(None, ''), *choices]
  88. field_kwargs['choices'] = choices
  89. if issubclass(field_class, forms.TypedChoiceField):
  90. # The widget submits each choice as a string; map it back to the enum member so a
  91. # non-string enum (e.g. integer) keeps the type the schema validates it against.
  92. field_kwargs['coerce'] = {str(v): v for v in self.enum}.get
  93. # Arrays
  94. if self.type == PropertyTypeEnum.ARRAY.value:
  95. if not self.items.get('enum'):
  96. items_type = self.items.get('type', PropertyTypeEnum.STRING.value)
  97. field_kwargs['base_field'] = FORM_FIELDS[items_type]()
  98. # String validation
  99. if self.type == PropertyTypeEnum.STRING.value:
  100. # Checking against CharField is safe because the other CharField-derived fields are
  101. # ruled out by the "is a string" check above. UUIDField is the exception: it cleans to
  102. # a uuid.UUID, which the length validators can't call len() on.
  103. if issubclass(field_class, forms.CharField) and not issubclass(field_class, forms.UUIDField):
  104. if self.minLength is not None:
  105. field_kwargs['min_length'] = self.minLength
  106. if self.maxLength is not None:
  107. field_kwargs['max_length'] = self.maxLength
  108. if self.pattern is not None:
  109. field_kwargs['validators'] = [
  110. RegexValidator(regex=self.pattern)
  111. ]
  112. # Integer/number validation
  113. elif self.type in (PropertyTypeEnum.INTEGER.value, PropertyTypeEnum.NUMBER.value):
  114. if issubclass(field_class, forms.IntegerField):
  115. field_kwargs['widget'] = forms.NumberInput(attrs={'step': 'any'})
  116. if self.minimum is not None:
  117. field_kwargs['min_value'] = self.minimum
  118. if self.maximum is not None:
  119. field_kwargs['max_value'] = self.maximum
  120. if self.multipleOf:
  121. field_kwargs['validators'] = [
  122. MultipleOfValidator(multiple=self.multipleOf)
  123. ]
  124. return self.field_class(**field_kwargs)
  125. @property
  126. def field_class(self):
  127. """
  128. Resolve the property's type (and string format, if specified) to the appropriate field class.
  129. """
  130. if self.type == PropertyTypeEnum.ARRAY.value and self.items.get('enum'):
  131. return forms.MultipleChoiceField
  132. if self.enum:
  133. if self.type == PropertyTypeEnum.ARRAY.value:
  134. return forms.MultipleChoiceField
  135. return forms.TypedChoiceField
  136. if self.type == PropertyTypeEnum.STRING.value and self.format is not None:
  137. try:
  138. return STRING_FORM_FIELDS[self.format]
  139. except KeyError:
  140. raise ValueError(f"Unsupported string format type: {self.format}")
  141. try:
  142. return FORM_FIELDS[self.type]
  143. except KeyError:
  144. raise ValueError(f"Unknown property type: {self.type}")
  145. def validate_schema(schema):
  146. """
  147. Check that a minimum JSON schema definition is defined.
  148. """
  149. # Pass on empty values
  150. if schema in (None, ''):
  151. return
  152. # Provide some basic sanity checking (not provided by jsonschema)
  153. if type(schema) is not dict:
  154. raise ValidationError(_("Invalid JSON schema definition"))
  155. try:
  156. ValidatorClass = validator_for(schema)
  157. ValidatorClass.check_schema(schema)
  158. except SchemaError as e:
  159. raise ValidationError(_("Invalid JSON schema definition: {error}").format(error=e))