File: hooks.py

package info (click to toggle)
python-drf-spectacular 0.28.0-2
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid
  • size: 1,748 kB
  • sloc: python: 14,174; javascript: 114; sh: 61; makefile: 30
file content (210 lines) | stat: -rw-r--r-- 10,126 bytes parent folder | download | duplicates (2)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
import re
from collections import defaultdict

from inflection import camelize
from rest_framework.settings import api_settings

from drf_spectacular.drainage import warn
from drf_spectacular.plumbing import (
    ResolvedComponent, list_hash, load_enum_name_overrides, safe_ref,
)
from drf_spectacular.settings import spectacular_settings


def postprocess_schema_enums(result, generator, **kwargs):
    """
    simple replacement of Enum/Choices that globally share the same name and have
    the same choices. Aids client generation to not generate a separate enum for
    every occurrence. only takes effect when replacement is guaranteed to be correct.
    """

    def iter_prop_containers(schema, component_name=None):
        if not component_name:
            for component_name, schema in schema.items():
                if spectacular_settings.COMPONENT_SPLIT_PATCH:
                    component_name = re.sub('^Patched(.+)', r'\1', component_name)
                if spectacular_settings.COMPONENT_SPLIT_REQUEST:
                    component_name = re.sub('(.+)Request$', r'\1', component_name)
                yield from iter_prop_containers(schema, component_name)
        elif isinstance(schema, list):
            for item in schema:
                yield from iter_prop_containers(item, component_name)
        elif isinstance(schema, dict):
            if schema.get('properties'):
                yield component_name, schema['properties']
            yield from iter_prop_containers(schema.get('oneOf', []), component_name)
            yield from iter_prop_containers(schema.get('allOf', []), component_name)
            yield from iter_prop_containers(schema.get('anyOf', []), component_name)

    def create_enum_component(name, schema):
        component = ResolvedComponent(
            name=name,
            type=ResolvedComponent.SCHEMA,
            schema=schema,
            object=name,
        )
        generator.registry.register_on_missing(component)
        return component

    def extract_hash(schema):
        if 'x-spec-enum-id' in schema:
            # try to use the injected enum hash first as it generated from (name, value) tuples,
            # which prevents collisions on choice sets only differing in labels not values.
            return schema['x-spec-enum-id']
        else:
            # fall back to actual list hashing when we encounter enums not generated by us.
            # remove blank/null entry for hashing. will be reconstructed in the last step
            return list_hash([(i, i) for i in schema['enum'] if i not in ('', None)])

    schemas = result.get('components', {}).get('schemas', {})

    overrides = load_enum_name_overrides()

    prop_hash_mapping = defaultdict(set)
    hash_name_mapping = defaultdict(set)
    # collect all enums, their names and choice sets
    for component_name, props in iter_prop_containers(schemas):
        for prop_name, prop_schema in props.items():
            if prop_schema.get('type') == 'array':
                prop_schema = prop_schema.get('items', {})
            if 'enum' not in prop_schema:
                continue

            prop_enum_cleaned_hash = extract_hash(prop_schema)
            prop_hash_mapping[prop_name].add(prop_enum_cleaned_hash)
            hash_name_mapping[prop_enum_cleaned_hash].add((component_name, prop_name))

    # get the suffix to be used for enums from settings
    enum_suffix = spectacular_settings.ENUM_SUFFIX

    # traverse all enum properties and generate a name for the choice set. naming collisions
    # are resolved and a warning is emitted. giving a choice set multiple names is technically
    # correct but potentially unwanted. also emit a warning there to make the user aware.
    enum_name_mapping = {}
    for prop_name, prop_hash_set in prop_hash_mapping.items():
        for prop_hash in prop_hash_set:
            if prop_hash in overrides:
                enum_name = overrides[prop_hash]
            elif len(prop_hash_set) == 1:
                # prop_name has been used exclusively for one choice set (best case)
                enum_name = f'{camelize(prop_name)}{enum_suffix}'
            elif len(hash_name_mapping[prop_hash]) == 1:
                # prop_name has multiple choice sets, but each one limited to one component only
                component_name, _ = next(iter(hash_name_mapping[prop_hash]))
                enum_name = f'{camelize(component_name)}{camelize(prop_name)}{enum_suffix}'
            else:
                enum_name = f'{camelize(prop_name)}{prop_hash[:3].capitalize()}{enum_suffix}'
                warn(
                    f'enum naming encountered a non-optimally resolvable collision for fields '
                    f'named "{prop_name}". The same name has been used for multiple choice sets '
                    f'in multiple components. The collision was resolved with "{enum_name}". '
                    f'add an entry to ENUM_NAME_OVERRIDES to fix the naming.'
                )
            if enum_name_mapping.get(prop_hash, enum_name) != enum_name:
                warn(
                    f'encountered multiple names for the same choice set ({enum_name}). This '
                    f'may be unwanted even though the generated schema is technically correct. '
                    f'Add an entry to ENUM_NAME_OVERRIDES to fix the naming.'
                )
                del enum_name_mapping[prop_hash]
            else:
                enum_name_mapping[prop_hash] = enum_name
            enum_name_mapping[(prop_hash, prop_name)] = enum_name

    # replace all enum occurrences with a enum schema component. cut out the
    # enum, replace it with a reference and add a corresponding component.
    for _, props in iter_prop_containers(schemas):
        for prop_name, prop_schema in props.items():
            is_array = prop_schema.get('type') == 'array'
            if is_array:
                prop_schema = prop_schema.get('items', {})

            if 'enum' not in prop_schema:
                continue

            prop_enum_original_list = prop_schema['enum']
            prop_schema['enum'] = [i for i in prop_schema['enum'] if i not in ['', None]]
            prop_hash = extract_hash(prop_schema)
            # when choice sets are reused under multiple names, the generated name cannot be
            # resolved from the hash alone. fall back to prop_name and hash for resolution.
            enum_name = enum_name_mapping.get(prop_hash) or enum_name_mapping[prop_hash, prop_name]

            # split property into remaining property and enum component parts
            enum_schema = {k: v for k, v in prop_schema.items() if k in ['type', 'enum']}
            prop_schema = {k: v for k, v in prop_schema.items() if k not in ['type', 'enum', 'x-spec-enum-id']}

            # separate actual description from name-value tuples
            if spectacular_settings.ENUM_GENERATE_CHOICE_DESCRIPTION:
                if prop_schema.get('description', '').startswith('*'):
                    enum_schema['description'] = prop_schema.pop('description')
                elif '\n\n*' in prop_schema.get('description', ''):
                    _, _, post = prop_schema['description'].partition('\n\n*')
                    enum_schema['description'] = '*' + post

            components = [
                create_enum_component(enum_name, schema=enum_schema)
            ]
            if spectacular_settings.ENUM_ADD_EXPLICIT_BLANK_NULL_CHOICE:
                if '' in prop_enum_original_list:
                    components.append(create_enum_component(f'Blank{enum_suffix}', schema={'enum': ['']}))
                if None in prop_enum_original_list:
                    if spectacular_settings.OAS_VERSION.startswith('3.1'):
                        components.append(create_enum_component(f'Null{enum_suffix}', schema={'type': 'null'}))
                    else:
                        components.append(create_enum_component(f'Null{enum_suffix}', schema={'enum': [None]}))

            # undo OAS 3.1 type list NULL construction as we cover this in a separate component already
            if spectacular_settings.OAS_VERSION.startswith('3.1') and isinstance(enum_schema['type'], list):
                enum_schema['type'] = [t for t in enum_schema['type'] if t != 'null'][0]

            if len(components) == 1:
                prop_schema.update(components[0].ref)
            else:
                prop_schema.update({'oneOf': [c.ref for c in components]})

            if is_array:
                props[prop_name]['items'] = safe_ref(prop_schema)
            else:
                props[prop_name] = safe_ref(prop_schema)

    # sort again with additional components
    result['components'] = generator.registry.build(spectacular_settings.APPEND_COMPONENTS)

    # remove remaining ids that were not part of this hook (operation parameters mainly)
    postprocess_schema_enum_id_removal(result, generator)

    return result


def postprocess_schema_enum_id_removal(result, generator, **kwargs):
    """
    Iterative modifying approach to scanning the whole schema and removing the
    temporary helper ids that allowed us to distinguish similar enums.
    """
    def clean(sub_result):
        if isinstance(sub_result, dict):
            for key in list(sub_result):
                if key == 'x-spec-enum-id':
                    del sub_result['x-spec-enum-id']
                else:
                    clean(sub_result[key])
        elif isinstance(sub_result, (list, tuple)):
            for item in sub_result:
                clean(item)

    clean(result)

    return result


def preprocess_exclude_path_format(endpoints, **kwargs):
    """
        preprocessing hook that filters out {format} suffixed paths, in case
        format_suffix_patterns is used and {format} path params are unwanted.
    """
    format_path = f'{{{api_settings.FORMAT_SUFFIX_KWARG}}}'
    return [
        (path, path_regex, method, callback)
        for path, path_regex, method, callback in endpoints
        if not (path.endswith(format_path) or path.endswith(format_path + '/'))
    ]