authentik.api.v3.schema.enum

  1"""Error Response schema, from https://github.com/axnsan12/drf-yasg/issues/224"""
  2
  3import functools
  4import inspect
  5import re
  6from collections import defaultdict
  7from enum import Enum
  8
  9from django.db.models import Choices
 10from django.utils.translation import get_language
 11from drf_spectacular.drainage import error, warn
 12from drf_spectacular.hooks import postprocess_schema_enum_id_removal
 13from drf_spectacular.plumbing import (
 14    ResolvedComponent,
 15    deep_import_string,
 16    list_hash,
 17    safe_ref,
 18)
 19from drf_spectacular.settings import spectacular_settings
 20from inflection import camelize
 21from structlog.stdlib import get_logger
 22
 23LOGGER = get_logger()
 24
 25
 26# See https://github.com/tfranzel/drf-spectacular/blob/master/drf_spectacular/hooks.py
 27# and https://github.com/tfranzel/drf-spectacular/issues/520
 28def postprocess_schema_enums(result, generator, **kwargs):  # noqa: PLR0912, PLR0915
 29    """
 30    simple replacement of Enum/Choices that globally share the same name and have
 31    the same choices. Aids client generation to not generate a separate enum for
 32    every occurrence. only takes effect when replacement is guaranteed to be correct.
 33    """
 34
 35    def is_enum_prop(prop_schema):
 36        return (
 37            "enum" in prop_schema
 38            or prop_schema.get("type") == "array"
 39            and "enum" in prop_schema.get("items", {})
 40        )
 41
 42    def iter_field_schemas():
 43        def iter_prop_containers(schema, component_name=None):
 44            if not component_name:
 45                for _component_name, _schema in schema.items():
 46                    if spectacular_settings.COMPONENT_SPLIT_PATCH:
 47                        _component_name = re.sub("^Patched(.+)", r"\1", _component_name)
 48                    if spectacular_settings.COMPONENT_SPLIT_REQUEST:
 49                        _component_name = re.sub("(.+)Request$", r"\1", _component_name)
 50                    yield from iter_prop_containers(_schema, _component_name)
 51            elif isinstance(schema, list):
 52                for item in schema:
 53                    yield from iter_prop_containers(item, component_name)
 54            elif isinstance(schema, dict):
 55                if schema.get("properties"):
 56                    yield component_name, schema["properties"]
 57                yield from iter_prop_containers(schema.get("oneOf", []), component_name)
 58                yield from iter_prop_containers(schema.get("allOf", []), component_name)
 59                yield from iter_prop_containers(schema.get("anyOf", []), component_name)
 60
 61        def iter_path_parameters():
 62            for path in result.get("paths", {}).values():
 63                for operation in path.values():
 64                    for parameter in operation.get("parameters", []):
 65                        parameter_schema = parameter.get("schema", {})
 66                        if is_enum_prop(parameter_schema):
 67                            # Move description into enum schema
 68                            if "description" in parameter:
 69                                parameter_schema["description"] = parameter.pop("description")
 70                        if "name" not in parameter:
 71                            continue
 72                        yield "", {parameter["name"]: parameter_schema}
 73
 74        component_schemas = result.get("components", {}).get("schemas", {})
 75
 76        yield from iter_prop_containers(component_schemas)
 77        yield from iter_path_parameters()
 78
 79    def create_enum_component(name, schema):
 80        component = ResolvedComponent(
 81            name=name,
 82            type=ResolvedComponent.SCHEMA,
 83            schema=schema,
 84            object=name,
 85        )
 86        generator.registry.register_on_missing(component)
 87        return component
 88
 89    def extract_hash(schema):
 90        if "x-spec-enum-id" in schema:
 91            # try to use the injected enum hash first as it generated from (name, value) tuples,
 92            # which prevents collisions on choice sets only differing in labels not values.
 93            return schema["x-spec-enum-id"]
 94        else:
 95            # fall back to actual list hashing when we encounter enums not generated by us.
 96            # remove blank/null entry for hashing. will be reconstructed in the last step
 97            return list_hash([(i, i) for i in schema["enum"] if i not in ("", None)])
 98
 99    overrides = load_enum_name_overrides()
100
101    prop_hash_mapping = defaultdict(set)
102    hash_name_mapping = defaultdict(set)
103    # collect all enums, their names and choice sets
104    for component_name, props in iter_field_schemas():
105        for prop_name, prop_schema in props.items():
106            _prop_schema = prop_schema
107            if prop_schema.get("type") == "array":
108                _prop_schema = prop_schema.get("items", {})
109            if "enum" not in _prop_schema:
110                continue
111
112            prop_enum_cleaned_hash = extract_hash(_prop_schema)
113            prop_hash_mapping[prop_name].add(prop_enum_cleaned_hash)
114            hash_name_mapping[prop_enum_cleaned_hash].add((component_name, prop_name))
115
116    # get the suffix to be used for enums from settings
117    enum_suffix = spectacular_settings.ENUM_SUFFIX
118
119    # traverse all enum properties and generate a name for the choice set. naming collisions
120    # are resolved and a warning is emitted. giving a choice set multiple names is technically
121    # correct but potentially unwanted. also emit a warning there to make the user aware.
122    enum_name_mapping = {}
123    for prop_name, prop_hash_set in prop_hash_mapping.items():
124        for prop_hash in prop_hash_set:
125            if prop_hash in overrides:
126                enum_name = overrides[prop_hash]
127            elif len(prop_hash_set) == 1:
128                # prop_name has been used exclusively for one choice set (best case)
129                enum_name = f"{camelize(prop_name)}{enum_suffix}"
130            elif len(hash_name_mapping[prop_hash]) == 1:
131                # prop_name has multiple choice sets, but each one limited to one component only
132                component_name, _ = next(iter(hash_name_mapping[prop_hash]))
133                enum_name = f"{camelize(component_name)}{camelize(prop_name)}{enum_suffix}"
134            else:
135                enum_name = f"{camelize(prop_name)}{prop_hash[:3].capitalize()}{enum_suffix}"
136                warn(
137                    f"enum naming encountered a non-optimally resolvable collision for fields "
138                    f'named "{prop_name}". The same name has been used for multiple choice sets '
139                    f'in multiple components. The collision was resolved with "{enum_name}". '
140                    f"add an entry to ENUM_NAME_OVERRIDES to fix the naming."
141                )
142            if enum_name_mapping.get(prop_hash, enum_name) != enum_name:
143                warn(
144                    f"encountered multiple names for the same choice set ({enum_name}). This "
145                    f"may be unwanted even though the generated schema is technically correct. "
146                    f"Add an entry to ENUM_NAME_OVERRIDES to fix the naming."
147                )
148                del enum_name_mapping[prop_hash]
149            else:
150                enum_name_mapping[prop_hash] = enum_name
151            enum_name_mapping[(prop_hash, prop_name)] = enum_name
152
153    # replace all enum occurrences with a enum schema component. cut out the
154    # enum, replace it with a reference and add a corresponding component.
155    for _, props in iter_field_schemas():
156        for prop_name, _prop_schema in props.items():
157            prop_schema = _prop_schema
158            is_array = prop_schema.get("type") == "array"
159            if is_array:
160                prop_schema = prop_schema.get("items", {})
161
162            if "enum" not in prop_schema:
163                continue
164
165            prop_enum_original_list = prop_schema["enum"]
166            prop_schema["enum"] = [i for i in prop_schema["enum"] if i not in ["", None]]
167            prop_hash = extract_hash(prop_schema)
168            # when choice sets are reused under multiple names, the generated name cannot be
169            # resolved from the hash alone. fall back to prop_name and hash for resolution.
170            enum_name = enum_name_mapping.get(prop_hash) or enum_name_mapping[prop_hash, prop_name]
171
172            # split property into remaining property and enum component parts
173            enum_schema = {k: v for k, v in prop_schema.items() if k in ["type", "enum"]}
174            prop_schema = {
175                k: v for k, v in prop_schema.items() if k not in ["type", "enum", "x-spec-enum-id"]
176            }
177
178            # separate actual description from name-value tuples
179            if spectacular_settings.ENUM_GENERATE_CHOICE_DESCRIPTION:
180                if prop_schema.get("description", "").startswith("*"):
181                    enum_schema["description"] = prop_schema.pop("description")
182                elif "\n\n*" in prop_schema.get("description", ""):
183                    _, _, post = prop_schema["description"].partition("\n\n*")
184                    enum_schema["description"] = "*" + post
185
186            components = [create_enum_component(enum_name, schema=enum_schema)]
187            if spectacular_settings.ENUM_ADD_EXPLICIT_BLANK_NULL_CHOICE:
188                if "" in prop_enum_original_list:
189                    components.append(
190                        create_enum_component(f"Blank{enum_suffix}", schema={"enum": [""]})
191                    )
192                if None in prop_enum_original_list:
193                    if spectacular_settings.OAS_VERSION.startswith("3.1"):
194                        components.append(
195                            create_enum_component(f"Null{enum_suffix}", schema={"type": "null"})
196                        )
197                    else:
198                        components.append(
199                            create_enum_component(f"Null{enum_suffix}", schema={"enum": [None]})
200                        )
201
202            # undo OAS 3.1 type list NULL construction as we cover
203            # this in a separate component already
204            if spectacular_settings.OAS_VERSION.startswith("3.1") and isinstance(
205                enum_schema["type"], list
206            ):
207                enum_schema["type"] = [t for t in enum_schema["type"] if t != "null"][0]
208
209            if len(components) == 1:
210                prop_schema.update(components[0].ref)
211            else:
212                prop_schema.update({"oneOf": [c.ref for c in components]})
213
214            patch_target = props[prop_name]  # noqa: PLR1733
215            if is_array:
216                patch_target = patch_target["items"]
217
218            # Replace existing schema information with reference
219            patch_target.clear()
220            patch_target.update(safe_ref(prop_schema))
221
222    # sort again with additional components
223    result["components"] = generator.registry.build(spectacular_settings.APPEND_COMPONENTS)
224
225    # remove remaining ids that were not part of this hook (operation parameters mainly)
226    postprocess_schema_enum_id_removal(result, generator)
227
228    return result
229
230
231# Fixed version of `load_enum_name_overrides()` with a LRU cache based on language
232# *and* enum overrides.
233# Without this, API generation breaks if there is more than 1 API present (such as in split APIs)
234# Original source: drf-spectacular/drf_spectacular/plumbing.py
235def load_enum_name_overrides():
236    cache_key = get_language() or ""
237
238    for k, v in sorted(spectacular_settings.ENUM_NAME_OVERRIDES.items()):
239        cache_key += f";{k}:{v}"
240
241    return _load_enum_name_overrides(cache_key)
242
243
244# Original source: drf-spectacular/drf_spectacular/plumbing.py
245# Only change: cache_key argument instead of language.
246@functools.lru_cache
247def _load_enum_name_overrides(cache_key):
248    overrides = {}
249    for name, _choices in spectacular_settings.ENUM_NAME_OVERRIDES.items():
250        choices = _choices
251        if isinstance(choices, str):
252            choices = deep_import_string(choices)
253        if not choices:
254            warn(
255                f"unable to load choice override for {name} from ENUM_NAME_OVERRIDES. "
256                f"please check module path string."
257            )
258            continue
259        if inspect.isclass(choices) and issubclass(choices, Choices):
260            choices = choices.choices
261        if inspect.isclass(choices) and issubclass(choices, Enum):
262            choices = [(c.value, c.name) for c in choices]
263        normalized_choices = []
264        for choice in choices:
265            # Allow None values in the simple values list case
266            if isinstance(choice, str) or choice is None:
267                # TODO warning
268                normalized_choices.append((choice, choice))  # simple choice list
269            elif isinstance(choice[1], (list, tuple)):
270                normalized_choices.extend(choice[1])  # categorized nested choices
271            else:
272                normalized_choices.append(choice)  # normal 2-tuple form
273
274        # Get all of choice values that should be used in the hash, blank and
275        # None values get excluded in the post-processing hook for enum overrides,
276        # so we do the same here to ensure the hashes match
277        hashable_values = [
278            (value, label) for value, label in normalized_choices if value not in ["", None]
279        ]
280        overrides[list_hash(hashable_values)] = name
281
282    if len(spectacular_settings.ENUM_NAME_OVERRIDES) != len(overrides):
283        error(
284            "ENUM_NAME_OVERRIDES has duplication issues. Encountered multiple names "
285            "for the same choice set. Enum naming might be unexpected."
286        )
287    return overrides
LOGGER = <BoundLoggerLazyProxy(logger=None, wrapper_class=None, processors=None, context_class=None, initial_values={}, logger_factory_args=())>
def postprocess_schema_enums(result, generator, **kwargs):
 29def postprocess_schema_enums(result, generator, **kwargs):  # noqa: PLR0912, PLR0915
 30    """
 31    simple replacement of Enum/Choices that globally share the same name and have
 32    the same choices. Aids client generation to not generate a separate enum for
 33    every occurrence. only takes effect when replacement is guaranteed to be correct.
 34    """
 35
 36    def is_enum_prop(prop_schema):
 37        return (
 38            "enum" in prop_schema
 39            or prop_schema.get("type") == "array"
 40            and "enum" in prop_schema.get("items", {})
 41        )
 42
 43    def iter_field_schemas():
 44        def iter_prop_containers(schema, component_name=None):
 45            if not component_name:
 46                for _component_name, _schema in schema.items():
 47                    if spectacular_settings.COMPONENT_SPLIT_PATCH:
 48                        _component_name = re.sub("^Patched(.+)", r"\1", _component_name)
 49                    if spectacular_settings.COMPONENT_SPLIT_REQUEST:
 50                        _component_name = re.sub("(.+)Request$", r"\1", _component_name)
 51                    yield from iter_prop_containers(_schema, _component_name)
 52            elif isinstance(schema, list):
 53                for item in schema:
 54                    yield from iter_prop_containers(item, component_name)
 55            elif isinstance(schema, dict):
 56                if schema.get("properties"):
 57                    yield component_name, schema["properties"]
 58                yield from iter_prop_containers(schema.get("oneOf", []), component_name)
 59                yield from iter_prop_containers(schema.get("allOf", []), component_name)
 60                yield from iter_prop_containers(schema.get("anyOf", []), component_name)
 61
 62        def iter_path_parameters():
 63            for path in result.get("paths", {}).values():
 64                for operation in path.values():
 65                    for parameter in operation.get("parameters", []):
 66                        parameter_schema = parameter.get("schema", {})
 67                        if is_enum_prop(parameter_schema):
 68                            # Move description into enum schema
 69                            if "description" in parameter:
 70                                parameter_schema["description"] = parameter.pop("description")
 71                        if "name" not in parameter:
 72                            continue
 73                        yield "", {parameter["name"]: parameter_schema}
 74
 75        component_schemas = result.get("components", {}).get("schemas", {})
 76
 77        yield from iter_prop_containers(component_schemas)
 78        yield from iter_path_parameters()
 79
 80    def create_enum_component(name, schema):
 81        component = ResolvedComponent(
 82            name=name,
 83            type=ResolvedComponent.SCHEMA,
 84            schema=schema,
 85            object=name,
 86        )
 87        generator.registry.register_on_missing(component)
 88        return component
 89
 90    def extract_hash(schema):
 91        if "x-spec-enum-id" in schema:
 92            # try to use the injected enum hash first as it generated from (name, value) tuples,
 93            # which prevents collisions on choice sets only differing in labels not values.
 94            return schema["x-spec-enum-id"]
 95        else:
 96            # fall back to actual list hashing when we encounter enums not generated by us.
 97            # remove blank/null entry for hashing. will be reconstructed in the last step
 98            return list_hash([(i, i) for i in schema["enum"] if i not in ("", None)])
 99
100    overrides = load_enum_name_overrides()
101
102    prop_hash_mapping = defaultdict(set)
103    hash_name_mapping = defaultdict(set)
104    # collect all enums, their names and choice sets
105    for component_name, props in iter_field_schemas():
106        for prop_name, prop_schema in props.items():
107            _prop_schema = prop_schema
108            if prop_schema.get("type") == "array":
109                _prop_schema = prop_schema.get("items", {})
110            if "enum" not in _prop_schema:
111                continue
112
113            prop_enum_cleaned_hash = extract_hash(_prop_schema)
114            prop_hash_mapping[prop_name].add(prop_enum_cleaned_hash)
115            hash_name_mapping[prop_enum_cleaned_hash].add((component_name, prop_name))
116
117    # get the suffix to be used for enums from settings
118    enum_suffix = spectacular_settings.ENUM_SUFFIX
119
120    # traverse all enum properties and generate a name for the choice set. naming collisions
121    # are resolved and a warning is emitted. giving a choice set multiple names is technically
122    # correct but potentially unwanted. also emit a warning there to make the user aware.
123    enum_name_mapping = {}
124    for prop_name, prop_hash_set in prop_hash_mapping.items():
125        for prop_hash in prop_hash_set:
126            if prop_hash in overrides:
127                enum_name = overrides[prop_hash]
128            elif len(prop_hash_set) == 1:
129                # prop_name has been used exclusively for one choice set (best case)
130                enum_name = f"{camelize(prop_name)}{enum_suffix}"
131            elif len(hash_name_mapping[prop_hash]) == 1:
132                # prop_name has multiple choice sets, but each one limited to one component only
133                component_name, _ = next(iter(hash_name_mapping[prop_hash]))
134                enum_name = f"{camelize(component_name)}{camelize(prop_name)}{enum_suffix}"
135            else:
136                enum_name = f"{camelize(prop_name)}{prop_hash[:3].capitalize()}{enum_suffix}"
137                warn(
138                    f"enum naming encountered a non-optimally resolvable collision for fields "
139                    f'named "{prop_name}". The same name has been used for multiple choice sets '
140                    f'in multiple components. The collision was resolved with "{enum_name}". '
141                    f"add an entry to ENUM_NAME_OVERRIDES to fix the naming."
142                )
143            if enum_name_mapping.get(prop_hash, enum_name) != enum_name:
144                warn(
145                    f"encountered multiple names for the same choice set ({enum_name}). This "
146                    f"may be unwanted even though the generated schema is technically correct. "
147                    f"Add an entry to ENUM_NAME_OVERRIDES to fix the naming."
148                )
149                del enum_name_mapping[prop_hash]
150            else:
151                enum_name_mapping[prop_hash] = enum_name
152            enum_name_mapping[(prop_hash, prop_name)] = enum_name
153
154    # replace all enum occurrences with a enum schema component. cut out the
155    # enum, replace it with a reference and add a corresponding component.
156    for _, props in iter_field_schemas():
157        for prop_name, _prop_schema in props.items():
158            prop_schema = _prop_schema
159            is_array = prop_schema.get("type") == "array"
160            if is_array:
161                prop_schema = prop_schema.get("items", {})
162
163            if "enum" not in prop_schema:
164                continue
165
166            prop_enum_original_list = prop_schema["enum"]
167            prop_schema["enum"] = [i for i in prop_schema["enum"] if i not in ["", None]]
168            prop_hash = extract_hash(prop_schema)
169            # when choice sets are reused under multiple names, the generated name cannot be
170            # resolved from the hash alone. fall back to prop_name and hash for resolution.
171            enum_name = enum_name_mapping.get(prop_hash) or enum_name_mapping[prop_hash, prop_name]
172
173            # split property into remaining property and enum component parts
174            enum_schema = {k: v for k, v in prop_schema.items() if k in ["type", "enum"]}
175            prop_schema = {
176                k: v for k, v in prop_schema.items() if k not in ["type", "enum", "x-spec-enum-id"]
177            }
178
179            # separate actual description from name-value tuples
180            if spectacular_settings.ENUM_GENERATE_CHOICE_DESCRIPTION:
181                if prop_schema.get("description", "").startswith("*"):
182                    enum_schema["description"] = prop_schema.pop("description")
183                elif "\n\n*" in prop_schema.get("description", ""):
184                    _, _, post = prop_schema["description"].partition("\n\n*")
185                    enum_schema["description"] = "*" + post
186
187            components = [create_enum_component(enum_name, schema=enum_schema)]
188            if spectacular_settings.ENUM_ADD_EXPLICIT_BLANK_NULL_CHOICE:
189                if "" in prop_enum_original_list:
190                    components.append(
191                        create_enum_component(f"Blank{enum_suffix}", schema={"enum": [""]})
192                    )
193                if None in prop_enum_original_list:
194                    if spectacular_settings.OAS_VERSION.startswith("3.1"):
195                        components.append(
196                            create_enum_component(f"Null{enum_suffix}", schema={"type": "null"})
197                        )
198                    else:
199                        components.append(
200                            create_enum_component(f"Null{enum_suffix}", schema={"enum": [None]})
201                        )
202
203            # undo OAS 3.1 type list NULL construction as we cover
204            # this in a separate component already
205            if spectacular_settings.OAS_VERSION.startswith("3.1") and isinstance(
206                enum_schema["type"], list
207            ):
208                enum_schema["type"] = [t for t in enum_schema["type"] if t != "null"][0]
209
210            if len(components) == 1:
211                prop_schema.update(components[0].ref)
212            else:
213                prop_schema.update({"oneOf": [c.ref for c in components]})
214
215            patch_target = props[prop_name]  # noqa: PLR1733
216            if is_array:
217                patch_target = patch_target["items"]
218
219            # Replace existing schema information with reference
220            patch_target.clear()
221            patch_target.update(safe_ref(prop_schema))
222
223    # sort again with additional components
224    result["components"] = generator.registry.build(spectacular_settings.APPEND_COMPONENTS)
225
226    # remove remaining ids that were not part of this hook (operation parameters mainly)
227    postprocess_schema_enum_id_removal(result, generator)
228
229    return result

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 load_enum_name_overrides():
236def load_enum_name_overrides():
237    cache_key = get_language() or ""
238
239    for k, v in sorted(spectacular_settings.ENUM_NAME_OVERRIDES.items()):
240        cache_key += f";{k}:{v}"
241
242    return _load_enum_name_overrides(cache_key)