Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## Unreleased

- **Added**: `generate_form_schema` helper for generating OpenAPI 3.0 request-body schemas from form definitions
([#72](https://github.com/Sibyx/django_api_forms/issues/72))
- **Added**: Read-only properties `FieldList.field`, `min_length`/`max_length` on `FieldList` and `FormFieldList`,
and `DictionaryField.value_field`

## 1.0.0-rc.11 : 16.08.2024

- **Fixed**: Proper manipulation with `BaseStrategy` instances during population
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ Django API Forms provides a declarative way to:
- **Object Population**: Easily populate Django models or other objects with validated data
- **Customizable Validation**: Define custom validation rules at the field or form level
- **Multiple Content Types**: Support for JSON, MessagePack, and extensible to other formats
- **OpenAPI Schema Generation**: Generate OpenAPI 3.0 request-body schemas straight from your form definitions

## Motivation

Expand Down
2 changes: 2 additions & 0 deletions django_api_forms/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from .fields import GeoJSONField
from .forms import Form
from .forms import ModelForm
from .openapi import generate_form_schema
from .version import __version__

__all__ = [
Expand All @@ -29,5 +30,6 @@
'GeoJSONField',
'Form',
'ModelForm',
'generate_form_schema',
'__version__'
]
24 changes: 24 additions & 0 deletions django_api_forms/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,18 @@ def __init__(self, field, min_length=None, max_length=None, **kwargs):
self._max_length = max_length
self._field = field

@property
def field(self):
return self._field

@property
def min_length(self):
return self._min_length

@property
def max_length(self):
return self._max_length

def to_python(self, value) -> typing.List:
if not value:
return []
Expand Down Expand Up @@ -118,6 +130,14 @@ def __init__(self, form: typing.Type, min_length=None, max_length=None, **kwargs
'not_list': _('This field needs to be a list of objects!')
}

@property
def min_length(self):
return self._min_length

@property
def max_length(self):
return self._max_length

def to_python(self, value):
if not value:
return []
Expand Down Expand Up @@ -194,6 +214,10 @@ def __init__(self, *, value_field, key_field=None, **kwargs):
self._value_field = value_field
self._key_field = key_field

@property
def value_field(self):
return self._value_field

def to_python(self, value) -> dict:
if not isinstance(value, dict):
msg = self.error_messages['not_dict'].format(type(value))
Expand Down
194 changes: 194 additions & 0 deletions django_api_forms/openapi.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import typing

from django.forms import fields

from .fields import (
AnyField,
BooleanField,
DictionaryField,
EnumField,
FieldList,
FileField,
FormField,
FormFieldList,
GeoJSONField,
RRuleField,
)


def generate_form_schema(form_class: typing.Type) -> dict:
"""Generate an OpenAPI 3.0 Schema Object (plain dict) describing the JSON request body of the given Form."""
mapping = {}
meta = getattr(form_class, 'Meta', None)
if isinstance(meta, type) and hasattr(meta, 'mapping'):
mapping = {field_name: json_key for json_key, field_name in meta.mapping.items()}

properties = {}
required = []

for name, field in form_class.base_fields.items():
key = mapping.get(name, name)
properties[key] = _field_to_schema(field)

if field.required:
required.append(key)

schema = {
'type': 'object',
'properties': properties
}

if required:
schema['required'] = required

return schema


def _field_to_schema(field: fields.Field) -> dict:
handler = _resolve_handler(type(field))
schema = handler(field) if handler else {}

if getattr(field, 'label', None):
schema['title'] = str(field.label)
if getattr(field, 'help_text', None):
schema['description'] = str(field.help_text)

return schema


def _resolve_handler(field_type: typing.Type) -> typing.Optional[typing.Callable]:
for cls in field_type.__mro__:
if cls in FIELD_SCHEMA_HANDLERS:
return FIELD_SCHEMA_HANDLERS[cls]
return None


def _enum_value_type(values: list) -> typing.Optional[str]:
if all(isinstance(value, str) for value in values):
return 'string'
if all(isinstance(value, bool) for value in values):
return 'boolean'
if all(isinstance(value, int) for value in values):
return 'integer'
if all(isinstance(value, (int, float)) for value in values):
return 'number'
return None


def _string_schema(field: fields.Field, output_format: str = None) -> dict:
schema = {'type': 'string'}

if output_format:
schema['format'] = output_format
if getattr(field, 'min_length', None) is not None:
schema['minLength'] = field.min_length
if getattr(field, 'max_length', None) is not None:
schema['maxLength'] = field.max_length

return schema


def _numeric_schema(field: fields.Field, numeric_type: str) -> dict:
schema = {'type': numeric_type}

if getattr(field, 'min_value', None) is not None:
schema['minimum'] = field.min_value
if getattr(field, 'max_value', None) is not None:
schema['maximum'] = field.max_value

return schema


def _choice_schema(field: fields.ChoiceField) -> dict:
values = []
for value, label in field.choices:
if isinstance(label, (list, tuple)):
values.extend(item for item, _ in label)
else:
values.append(value)

schema = {}
value_type = _enum_value_type(values)
if value_type:
schema['type'] = value_type
schema['enum'] = values

return schema


def _enum_schema(field: EnumField) -> dict:
values = [item.value for item in field.enum]

schema = {}
value_type = _enum_value_type(values)
if value_type:
schema['type'] = value_type
schema['enum'] = values

return schema


def _list_schema(field: FieldList) -> dict:
schema = {
'type': 'array',
'items': _field_to_schema(field.field)
}

if field.min_length is not None:
schema['minItems'] = field.min_length
if field.max_length is not None:
schema['maxItems'] = field.max_length

return schema


def _form_schema(field: FormField) -> dict:
return generate_form_schema(field.form)


def _form_list_schema(field: FormFieldList) -> dict:
schema = {
'type': 'array',
'items': generate_form_schema(field.form)
}

if field.min_length is not None:
schema['minItems'] = field.min_length
if field.max_length is not None:
schema['maxItems'] = field.max_length

return schema


def _dictionary_schema(field: DictionaryField) -> dict:
return {
'type': 'object',
'additionalProperties': _field_to_schema(field.value_field)
}


FIELD_SCHEMA_HANDLERS = {
fields.CharField: _string_schema,
fields.EmailField: lambda field: _string_schema(field, 'email'),
fields.URLField: lambda field: _string_schema(field, 'uri'),
fields.UUIDField: lambda field: _string_schema(field, 'uuid'),
fields.DateTimeField: lambda field: _string_schema(field, 'date-time'),
fields.DateField: lambda field: _string_schema(field, 'date'),
fields.TimeField: lambda field: _string_schema(field, 'time'),
fields.DurationField: lambda field: _string_schema(field, 'duration'),
fields.IntegerField: lambda field: _numeric_schema(field, 'integer'),
fields.FloatField: lambda field: _numeric_schema(field, 'number'),
fields.DecimalField: lambda field: _numeric_schema(field, 'number'),
fields.BooleanField: lambda field: {'type': 'boolean'},
fields.ChoiceField: _choice_schema,
BooleanField: lambda field: {'type': 'boolean'},
EnumField: _enum_schema,
FieldList: _list_schema,
FormField: _form_schema,
FormFieldList: _form_list_schema,
DictionaryField: _dictionary_schema,
FileField: lambda field: _string_schema(field, 'byte'),
RRuleField: _string_schema,
GeoJSONField: lambda field: {'type': 'object'},
AnyField: lambda field: {},
}
Loading
Loading