Skip to content

Commit bca26d3

Browse files
committed
postprocessing
1 parent 013e601 commit bca26d3

6 files changed

Lines changed: 162 additions & 14 deletions

File tree

drf_spectacular/plumbing.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import inspect
2+
import json
23
import sys
34
from abc import ABCMeta
45
from collections import defaultdict
56
from collections.abc import Hashable
67
from typing import List, Type, Optional, TypeVar, Union, Generic
78

9+
import inflection
810
from django import __version__ as DJANGO_VERSION
911
from django.utils.module_loading import import_string
1012
from rest_framework import fields, serializers
@@ -285,6 +287,10 @@ def build(self, extra_components) -> dict:
285287
for extra_type, extra_component_dict in extra_components.items():
286288
for component_name, component_schema in extra_component_dict.items():
287289
output[extra_type][component_name] = component_schema
290+
291+
if 'schemas' in output:
292+
postprocess_schema_enums(output['schemas'])
293+
288294
# sort by component type then by name
289295
return {
290296
type: {name: output[type][name] for name in output[type].keys()}
@@ -329,3 +335,37 @@ def get_match(cls, target) -> Optional[T]:
329335
if extension._matches(target):
330336
return extension(target)
331337
return None
338+
339+
340+
def postprocess_schema_enums(schemas):
341+
"""
342+
simple replacement of Enum/Choices that globally share the same name and have
343+
the same choices. Aids client generation to not generate a separate enum for
344+
every occurrence. only takes effect when replacement is guaranteed to be correct.
345+
"""
346+
hash_mapping = defaultdict(set)
347+
# collect all enums, their names and contents
348+
for schema in schemas.values():
349+
for prop_name, prop_schema in schema.get('properties', {}).items():
350+
if 'enum' not in prop_schema:
351+
continue
352+
hash_mapping[prop_name].add(
353+
hash(json.dumps(prop_schema, sort_keys=True))
354+
)
355+
# safe replacement requires name to have only one set of enum values
356+
candidate_enums = {
357+
prop_name for prop_name, prop_hash_set in hash_mapping.items()
358+
if len(prop_hash_set) == 1
359+
}
360+
# replace all valid occurrences with enum schema component
361+
for schema_name in list(schemas):
362+
for prop_name, prop_schema in schemas[schema_name].get('properties', {}).items():
363+
if 'enum' not in prop_schema:
364+
continue
365+
if prop_name not in candidate_enums:
366+
continue
367+
enum_name = f'{inflection.camelize(prop_name)}Enum'
368+
schemas[enum_name] = prop_schema
369+
schemas[schema_name]['properties'][prop_name] = {
370+
'$ref': f'#/components/schemas/{enum_name}'
371+
}

requirements/base.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,5 @@ Django>=2.2
22
djangorestframework>=3.10
33
uritemplate>=3.0.0
44
PyYAML>=5.1
5-
jsonschema>=3.2.0
5+
jsonschema>=3.2.0
6+
inflection>=0.4.0

tests/test_basic.yml

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -210,10 +210,7 @@ components:
210210
type: string
211211
maxLength: 100
212212
genre:
213-
enum:
214-
- POP
215-
- ROCK
216-
type: string
213+
$ref: '#/components/schemas/GenreEnum'
217214
year:
218215
type: integer
219216
released:
@@ -262,11 +259,13 @@ components:
262259
type: string
263260
maxLength: 100
264261
genre:
265-
enum:
266-
- POP
267-
- ROCK
268-
type: string
262+
$ref: '#/components/schemas/GenreEnum'
269263
year:
270264
type: integer
271265
released:
272266
type: boolean
267+
GenreEnum:
268+
enum:
269+
- POP
270+
- ROCK
271+
type: string

tests/test_extend_schema.yml

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -286,11 +286,7 @@ components:
286286
$ref: '#/components/schemas/Inline'
287287
readOnly: true
288288
field_l:
289-
enum:
290-
- a
291-
- b
292-
type: string
293-
readOnly: true
289+
$ref: '#/components/schemas/FieldLEnum'
294290
Inline:
295291
type: object
296292
properties:
@@ -323,3 +319,9 @@ components:
323319
minLength: 3
324320
required:
325321
- stars
322+
FieldLEnum:
323+
enum:
324+
- a
325+
- b
326+
type: string
327+
readOnly: true

tests/test_postprocessing.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
from rest_framework import serializers, viewsets, mixins
2+
from rest_framework.decorators import action
3+
4+
from drf_spectacular.utils import extend_schema
5+
from tests import assert_schema, generate_schema
6+
7+
language_choices = (
8+
('en', 'en'),
9+
('es', 'es'),
10+
('ru', 'ru'),
11+
('cn', 'cn'),
12+
)
13+
14+
15+
class ASerializer(serializers.Serializer):
16+
language = serializers.ChoiceField(choices=language_choices)
17+
18+
19+
class BSerializer(serializers.Serializer):
20+
language = serializers.ChoiceField(choices=language_choices)
21+
22+
23+
class AViewset(mixins.ListModelMixin, viewsets.GenericViewSet):
24+
serializer_class = ASerializer
25+
26+
@extend_schema(responses=BSerializer)
27+
@action(detail=False, serializer_class=BSerializer)
28+
def selection(self, request):
29+
pass
30+
31+
32+
def test_postprocessing(no_warnings):
33+
schema = generate_schema('a', AViewset)
34+
assert_schema(schema, 'tests/test_postprocessing.yml')

tests/test_postprocessing.yml

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
openapi: 3.0.3
2+
info:
3+
title: ''
4+
version: 0.0.0
5+
paths:
6+
/a/:
7+
get:
8+
operationId: a_list
9+
description: ''
10+
tags:
11+
- a
12+
security:
13+
- cookieAuth: []
14+
- basicAuth: []
15+
- {}
16+
responses:
17+
'200':
18+
content:
19+
application/json:
20+
schema:
21+
type: array
22+
items:
23+
$ref: '#/components/schemas/A'
24+
description: ''
25+
/a/selection/:
26+
get:
27+
operationId: a_selection_retrieve
28+
description: ''
29+
tags:
30+
- a
31+
security:
32+
- cookieAuth: []
33+
- basicAuth: []
34+
- {}
35+
responses:
36+
'200':
37+
content:
38+
application/json:
39+
schema:
40+
$ref: '#/components/schemas/B'
41+
description: ''
42+
components:
43+
securitySchemes:
44+
cookieAuth:
45+
type: apiKey
46+
in: cookie
47+
name: Session
48+
basicAuth:
49+
type: http
50+
scheme: basic
51+
schemas:
52+
A:
53+
type: object
54+
properties:
55+
language:
56+
$ref: '#/components/schemas/LanguageEnum'
57+
required:
58+
- language
59+
B:
60+
type: object
61+
properties:
62+
language:
63+
$ref: '#/components/schemas/LanguageEnum'
64+
required:
65+
- language
66+
LanguageEnum:
67+
enum:
68+
- en
69+
- es
70+
- ru
71+
- cn
72+
type: string

0 commit comments

Comments
 (0)