diff --git a/CHANGELOG.md b/CHANGELOG.md index 175c7af..666e893 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index e15f03f..6ea0ae2 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/django_api_forms/__init__.py b/django_api_forms/__init__.py index 81a8e62..3ac2069 100644 --- a/django_api_forms/__init__.py +++ b/django_api_forms/__init__.py @@ -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__ = [ @@ -29,5 +30,6 @@ 'GeoJSONField', 'Form', 'ModelForm', + 'generate_form_schema', '__version__' ] diff --git a/django_api_forms/fields.py b/django_api_forms/fields.py index b4d308b..c796ec5 100644 --- a/django_api_forms/fields.py +++ b/django_api_forms/fields.py @@ -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 [] @@ -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 [] @@ -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)) diff --git a/django_api_forms/openapi.py b/django_api_forms/openapi.py new file mode 100644 index 0000000..9c61457 --- /dev/null +++ b/django_api_forms/openapi.py @@ -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: {}, +} diff --git a/docs/openapi.md b/docs/openapi.md new file mode 100644 index 0000000..05334f3 --- /dev/null +++ b/docs/openapi.md @@ -0,0 +1,134 @@ +# OpenAPI + +Django API Forms can generate an [OpenAPI 3.0 Schema Object](https://spec.openapis.org/oas/v3.0.3#schema-object) +describing the JSON request body accepted by any `Form`. The output is a plain `dict`, so you can serve it, dump it +to a file, or mount it into a larger OpenAPI document generated by the tooling of your choice +(Swagger UI, ReDoc, `drf-spectacular`, ...). + +Schema generation is dependency-free — no extra packages are required. + +## Usage + +```python +from django.forms import fields + +from django_api_forms import Form, FormField, FieldList, generate_form_schema + + +class ArtistForm(Form): + name = fields.CharField(required=True, max_length=100) + genres = FieldList(field=fields.CharField(max_length=30)) + members = fields.IntegerField() + + +class AlbumForm(Form): + title = fields.CharField(max_length=100) + year = fields.IntegerField(required=True) + artist = FormField(form=ArtistForm, required=True) + + +schema = generate_form_schema(AlbumForm) +``` + +The `schema` variable now contains: + +```json +{ + "type": "object", + "properties": { + "title": {"type": "string", "maxLength": 100}, + "year": {"type": "integer"}, + "artist": { + "type": "object", + "properties": { + "name": {"type": "string", "maxLength": 100}, + "genres": {"type": "array", "items": {"type": "string", "maxLength": 30}}, + "members": {"type": "integer"} + }, + "required": ["name"] + } + }, + "required": ["year", "artist"] +} +``` + +## Field mapping + +| Field | Schema | +|-------|--------| +| `CharField` | `string` (+ `minLength`/`maxLength`) | +| `IntegerField` | `integer` (+ `minimum`/`maximum`) | +| `FloatField`, `DecimalField` | `number` (+ `minimum`/`maximum`) | +| `BooleanField` | `boolean` | +| `DateTimeField` | `string` (`format: date-time`) | +| `DateField` | `string` (`format: date`) | +| `TimeField` | `string` (`format: time`) | +| `DurationField` | `string` (`format: duration`) | +| `UUIDField` | `string` (`format: uuid`) | +| `EmailField` | `string` (`format: email`) | +| `URLField` | `string` (`format: uri`) | +| `ChoiceField` | `enum` of the choice values | +| `EnumField` | `enum` of the `Enum` member values | +| `FieldList` | `array` of the inner field schema (+ `minItems`/`maxItems`) | +| `FormField` | nested `object` (recursive) | +| `FormFieldList` | `array` of the nested form `object` (+ `minItems`/`maxItems`) | +| `DictionaryField` | `object` with `additionalProperties` from `value_field` | +| `FileField`, `ImageField` | `string` (`format: byte` — BASE64/Data URI payload) | +| `RRuleField` | `string` | +| `GeoJSONField` | `object` | +| `AnyField` | `{}` (any type) | + +Fields with `required=True` are listed in the enclosing object's `required` array. A field's `label` and `help_text` +are exported as `title` and `description`. Unknown field types fall back to `{}` (any type) instead of raising an +error, so forms containing custom fields still produce a usable schema. + +If a form defines key mapping in `Meta.mapping`, the schema uses the JSON keys clients actually send: + +```python +class ArtistForm(Form): + class Meta: + mapping = { + '_name': 'name' # '_name' in JSON is populated into the 'name' form field + } + + name = fields.CharField(required=True, max_length=100) + + +generate_form_schema(ArtistForm)['properties'].keys() # dict_keys(['_name']) +``` + +## Serving a full OpenAPI document + +`generate_form_schema` returns just the request-body schema, which you can embed into a complete document: + +```python +from django.http import JsonResponse + +from myapp.forms import AlbumForm + + +def openapi_document(request): + document = { + 'openapi': '3.0.3', + 'info': {'title': 'My API', 'version': '1.0.0'}, + 'paths': { + '/api/albums': { + 'post': { + 'requestBody': { + 'required': True, + 'content': { + 'application/json': { + 'schema': generate_form_schema(AlbumForm) + } + } + }, + 'responses': {'201': {'description': 'Created'}} + } + } + } + } + return JsonResponse(document) +``` + +Any OpenAPI viewer (Swagger UI, ReDoc, ...) pointed at this endpoint will render interactive documentation of your +forms. diff --git a/mkdocs.yml b/mkdocs.yml index 12f7684..4d7b16a 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -11,6 +11,7 @@ nav: - Tutorial: tutorial.md - Fields: fields.md - Example: example.md + - OpenAPI: openapi.md - API Reference: api_reference.md - Contributing: contributing.md diff --git a/tests/test_openapi.py b/tests/test_openapi.py new file mode 100644 index 0000000..262732c --- /dev/null +++ b/tests/test_openapi.py @@ -0,0 +1,353 @@ +from enum import Enum + +from django.forms import fields +from django.test import SimpleTestCase + +from django_api_forms import ( + AnyField, + BooleanField, + DictionaryField, + EnumField, + FieldList, + FileField, + Form, + FormField, + FormFieldList, + GeoJSONField, + ImageField, + RRuleField, +) +from django_api_forms.openapi import generate_form_schema + + +class AlbumType(Enum): + CD = 'cd' + VINYL = 'vinyl' + + +class ScalarFieldsTests(SimpleTestCase): + def test_char_field(self): + class MyForm(Form): + title = fields.CharField(required=True, min_length=2, max_length=100) + + schema = generate_form_schema(MyForm) + + self.assertEqual(schema['type'], 'object') + self.assertEqual( + schema['properties']['title'], + {'type': 'string', 'minLength': 2, 'maxLength': 100} + ) + self.assertEqual(schema['required'], ['title']) + + def test_numeric_fields(self): + class MyForm(Form): + year = fields.IntegerField(required=False, min_value=1900, max_value=2100) + rating = fields.FloatField(required=False) + price = fields.DecimalField(required=False) + + schema = generate_form_schema(MyForm) + + self.assertEqual( + schema['properties']['year'], + {'type': 'integer', 'minimum': 1900, 'maximum': 2100} + ) + self.assertEqual(schema['properties']['rating'], {'type': 'number'}) + self.assertEqual(schema['properties']['price'], {'type': 'number'}) + self.assertNotIn('required', schema) + + def test_boolean_fields(self): + class MyForm(Form): + django_flag = fields.BooleanField(required=False) + api_flag = BooleanField(required=False) + + schema = generate_form_schema(MyForm) + + self.assertEqual(schema['properties']['django_flag'], {'type': 'boolean'}) + self.assertEqual(schema['properties']['api_flag'], {'type': 'boolean'}) + + def test_string_format_fields(self): + class MyForm(Form): + created_at = fields.DateTimeField(required=False) + birthday = fields.DateField(required=False) + alarm = fields.TimeField(required=False) + duration = fields.DurationField(required=False) + uuid = fields.UUIDField(required=False) + email = fields.EmailField(required=False) + website = fields.URLField(required=False) + + schema = generate_form_schema(MyForm) + + self.assertEqual(schema['properties']['created_at'], {'type': 'string', 'format': 'date-time'}) + self.assertEqual(schema['properties']['birthday'], {'type': 'string', 'format': 'date'}) + self.assertEqual(schema['properties']['alarm'], {'type': 'string', 'format': 'time'}) + self.assertEqual(schema['properties']['duration'], {'type': 'string', 'format': 'duration'}) + self.assertEqual(schema['properties']['uuid'], {'type': 'string', 'format': 'uuid'}) + self.assertEqual( + schema['properties']['email'], + {'type': 'string', 'format': 'email', 'maxLength': fields.EmailField().max_length} + ) + self.assertEqual(schema['properties']['website'], {'type': 'string', 'format': 'uri'}) + + def test_choice_field(self): + class MyForm(Form): + genre = fields.ChoiceField(required=False, choices=(('rock', 'Rock'), ('punk', 'Punk'))) + + schema = generate_form_schema(MyForm) + + self.assertEqual( + schema['properties']['genre'], + {'type': 'string', 'enum': ['rock', 'punk']} + ) + + def test_grouped_choice_field(self): + class MyForm(Form): + genre = fields.ChoiceField( + required=False, + choices=( + ('Rock', (('rock', 'Rock'), ('punk', 'Punk'))), + ('Electronic', (('techno', 'Techno'),)) + ) + ) + + schema = generate_form_schema(MyForm) + + self.assertEqual( + schema['properties']['genre'], + {'type': 'string', 'enum': ['rock', 'punk', 'techno']} + ) + + def test_boolean_choice_field(self): + class MyForm(Form): + flag = fields.ChoiceField(required=False, choices=((True, 'Yes'), (False, 'No'))) + + schema = generate_form_schema(MyForm) + + self.assertEqual( + schema['properties']['flag'], + {'type': 'boolean', 'enum': [True, False]} + ) + + def test_title_and_description(self): + class MyForm(Form): + name = fields.CharField(required=False, label='Name', help_text='Artist name') + + schema = generate_form_schema(MyForm) + + self.assertEqual(schema['properties']['name']['title'], 'Name') + self.assertEqual(schema['properties']['name']['description'], 'Artist name') + + +class LibraryFieldsTests(SimpleTestCase): + def test_enum_field(self): + class MyForm(Form): + type = EnumField(enum=AlbumType, required=True) + + schema = generate_form_schema(MyForm) + + self.assertEqual( + schema['properties']['type'], + {'type': 'string', 'enum': ['cd', 'vinyl']} + ) + + def test_integer_enum_field(self): + class Rating(Enum): + GOOD = 1 + BAD = 2 + + class MyForm(Form): + rating = EnumField(enum=Rating, required=False) + + schema = generate_form_schema(MyForm) + + self.assertEqual( + schema['properties']['rating'], + {'type': 'integer', 'enum': [1, 2]} + ) + + def test_float_enum_field(self): + class Threshold(Enum): + LOW = 0.5 + HIGH = 1.5 + + class MyForm(Form): + threshold = EnumField(enum=Threshold, required=False) + + schema = generate_form_schema(MyForm) + + self.assertEqual( + schema['properties']['threshold'], + {'type': 'number', 'enum': [0.5, 1.5]} + ) + + def test_mixed_enum_field(self): + class Weird(Enum): + NUMBER = 1 + TEXT = 'one' + + class MyForm(Form): + weird = EnumField(enum=Weird, required=False) + + schema = generate_form_schema(MyForm) + + self.assertEqual( + schema['properties']['weird'], + {'enum': [1, 'one']} + ) + + def test_field_list(self): + class MyForm(Form): + genres = FieldList(field=fields.CharField(max_length=30), min_length=1, max_length=10, required=False) + + schema = generate_form_schema(MyForm) + + self.assertEqual( + schema['properties']['genres'], + { + 'type': 'array', + 'items': {'type': 'string', 'maxLength': 30}, + 'minItems': 1, + 'maxItems': 10 + } + ) + + def test_form_field(self): + class ArtistForm(Form): + name = fields.CharField(required=True, max_length=100) + members = fields.IntegerField(required=False) + + class MyForm(Form): + artist = FormField(form=ArtistForm, required=True) + + schema = generate_form_schema(MyForm) + + self.assertEqual( + schema['properties']['artist'], + { + 'type': 'object', + 'properties': { + 'name': {'type': 'string', 'maxLength': 100}, + 'members': {'type': 'integer'} + }, + 'required': ['name'] + } + ) + self.assertEqual(schema['required'], ['artist']) + + def test_form_field_list(self): + class SongForm(Form): + title = fields.CharField(required=True, max_length=100) + + class MyForm(Form): + songs = FormFieldList(form=SongForm, min_length=1, max_length=20, required=False) + + schema = generate_form_schema(MyForm) + + self.assertEqual( + schema['properties']['songs'], + { + 'type': 'array', + 'items': { + 'type': 'object', + 'properties': { + 'title': {'type': 'string', 'maxLength': 100} + }, + 'required': ['title'] + }, + 'minItems': 1, + 'maxItems': 20 + } + ) + + def test_dictionary_field(self): + class MyForm(Form): + metadata = DictionaryField(value_field=fields.DateTimeField(), required=False) + + schema = generate_form_schema(MyForm) + + self.assertEqual( + schema['properties']['metadata'], + { + 'type': 'object', + 'additionalProperties': {'type': 'string', 'format': 'date-time'} + } + ) + + def test_file_fields(self): + class MyForm(Form): + attachment = FileField(required=False) + avatar = ImageField(required=False) + + schema = generate_form_schema(MyForm) + + self.assertEqual(schema['properties']['attachment'], {'type': 'string', 'format': 'byte'}) + self.assertEqual(schema['properties']['avatar'], {'type': 'string', 'format': 'byte'}) + + def test_rrule_and_geojson_fields(self): + class MyForm(Form): + recurrence = RRuleField(required=False) + location = GeoJSONField(required=False) + + schema = generate_form_schema(MyForm) + + self.assertEqual(schema['properties']['recurrence'], {'type': 'string'}) + self.assertEqual(schema['properties']['location'], {'type': 'object'}) + + def test_any_field(self): + class MyForm(Form): + payload = AnyField(required=False) + + schema = generate_form_schema(MyForm) + + self.assertEqual(schema['properties']['payload'], {}) + + def test_unknown_field_fallback(self): + class WeirdField(fields.Field): + pass + + class MyForm(Form): + weird = WeirdField(required=False) + + schema = generate_form_schema(MyForm) + + self.assertEqual(schema['properties']['weird'], {}) + + +class MetaMappingTests(SimpleTestCase): + def test_mapping_reverses_property_names(self): + class ArtistForm(Form): + class Meta: + mapping = { + '_name': 'name' + } + + name = fields.CharField(required=True, max_length=100) + members = fields.IntegerField(required=False) + + schema = generate_form_schema(ArtistForm) + + self.assertIn('_name', schema['properties']) + self.assertNotIn('name', schema['properties']) + self.assertEqual(schema['required'], ['_name']) + self.assertIn('members', schema['properties']) + + +class NestedFormTests(SimpleTestCase): + def test_complete_nested_form(self): + class ArtistForm(Form): + name = fields.CharField(required=True, max_length=100) + genres = FieldList(field=fields.CharField(max_length=30), required=False) + members = fields.IntegerField(required=False) + + class AlbumForm(Form): + title = fields.CharField(required=True, max_length=100) + year = fields.IntegerField(required=True) + artist = FormField(form=ArtistForm, required=True) + type = EnumField(enum=AlbumType, required=True) + metadata = DictionaryField(value_field=fields.DateTimeField(), required=False) + + schema = generate_form_schema(AlbumForm) + + self.assertEqual(schema['type'], 'object') + self.assertEqual(schema['required'], ['title', 'year', 'artist', 'type']) + self.assertEqual(schema['properties']['artist']['type'], 'object') + self.assertEqual(schema['properties']['artist']['required'], ['name'])