def load_enum_name_overrides(): overrides = {} for name, choices in spectacular_settings.ENUM_NAME_OVERRIDES.items(): if isinstance(choices, str): choices = deep_import_string(choices) if not choices: warn( f'unable to load choice override for {name} from ENUM_NAME_OVERRIDES. ' f'please check module path string.' ) continue if inspect.isclass(choices) and issubclass(choices, Choices): choices = choices.choices if inspect.isclass(choices) and issubclass(choices, Enum): choices = [c.value for c in choices] if isinstance(choices, Iterable) and isinstance(choices[0], str): choices = [(c, c) for c in choices] overrides[list_hash(list(dict(choices).keys()))] = name if len(spectacular_settings.ENUM_NAME_OVERRIDES) != len(overrides): error( 'ENUM_NAME_OVERRIDES has duplication issues. encountered multiple names ' 'for the same choice set. enum naming might be unexpected.' ) return overrides
def get_security_definition(self, auto_schema): from rest_framework_simplejwt.settings import api_settings if len(api_settings.AUTH_HEADER_TYPES) > 1: warn( f'OpenAPI3 can only have one "bearerFormat". JWT Settings specify ' f'{api_settings.AUTH_HEADER_TYPES}. Using the first one.' ) header_name = getattr(api_settings, 'AUTH_HEADER_NAME', 'HTTP_AUTHORIZATION') if ( api_settings.AUTH_HEADER_TYPES[0] == 'Bearer' and header_name == 'HTTP_AUTHORIZATION' ): return { 'type': 'http', 'scheme': 'bearer', 'bearerFormat': "JWT", } else: if header_name.startswith('HTTP_'): header_name = header_name[5:] header_name = header_name.capitalize() return { 'type': 'apiKey', 'in': 'header', 'name': header_name, 'description': _( 'Token-based authentication with required prefix "%s"' ) % api_settings.AUTH_HEADER_TYPES[0] }
def load_enum_name_overrides(): overrides = {} for name, choices in spectacular_settings.ENUM_NAME_OVERRIDES.items(): if isinstance(choices, str): choices = deep_import_string(choices) if not choices: warn( f'unable to load choice override for {name} from ENUM_NAME_OVERRIDES. ' f'please check module path string.') continue if inspect.isclass(choices) and issubclass(choices, Choices): choices = choices.choices if inspect.isclass(choices) and issubclass(choices, Enum): choices = [c.value for c in choices] normalized_choices = [] for choice in choices: if isinstance(choice, str): normalized_choices.append( (choice, choice)) # simple choice list elif isinstance(choice[1], (list, tuple)): normalized_choices.extend( choice[1]) # categorized nested choices else: normalized_choices.append(choice) # normal 2-tuple form overrides[list_hash(list(dict(normalized_choices).keys()))] = name if len(spectacular_settings.ENUM_NAME_OVERRIDES) != len(overrides): error( 'ENUM_NAME_OVERRIDES has duplication issues. Encountered multiple names ' 'for the same choice set. Enum naming might be unexpected.') return overrides
def register(self, component: ResolvedComponent): if component in self: warn( f'trying to re-register a {component.type} component with name ' f'{self._components[component.key].name}. this might lead to ' f'a incorrect schema. Look out for reused names') self._components[component.key] = component
def _load_class(cls): try: cls.target_class = import_string(cls.target_class) except ImportError: installed_apps = apps.app_configs.keys() if any(cls.target_class.startswith(app + '.') for app in installed_apps): warn( f'registered extensions {cls.__name__} for "{cls.target_class}" ' f'has an installed app but target class was not found.' ) cls.target_class = None
def get_security_definition(self, auto_schema): from rest_framework_simplejwt.settings import api_settings if len(api_settings.AUTH_HEADER_TYPES) > 1: warn( f'OpenAPI3 can only have one "bearerFormat". JWT Settings specify ' f'{api_settings.AUTH_HEADER_TYPES}. Using the first one.') return { 'type': 'http', 'scheme': 'bearer', 'bearerFormat': api_settings.AUTH_HEADER_TYPES[0], }
def get_security_definition(self, auto_schema): from rest_framework_simplejwt.settings import api_settings if len(api_settings.AUTH_HEADER_TYPES) > 1: warn( f'OpenAPI3 can only have one "bearerFormat". JWT Settings specify ' f'{api_settings.AUTH_HEADER_TYPES}. Using the first one.') return build_bearer_security_scheme_object( header_name=getattr(api_settings, 'AUTH_HEADER_NAME', 'HTTP_AUTHORIZATION'), token_prefix=api_settings.AUTH_HEADER_TYPES[0], bearer_format='JWT')
def build_basic_type(obj): """ resolve either enum or actual type and yield schema template for modification """ if obj in OPENAPI_TYPE_MAPPING: return dict(OPENAPI_TYPE_MAPPING[obj]) elif obj in PYTHON_TYPE_MAPPING: return dict(OPENAPI_TYPE_MAPPING[PYTHON_TYPE_MAPPING[obj]]) elif obj is None or type(obj) is None: return dict(OPENAPI_TYPE_MAPPING[OpenApiTypes.NONE]) else: warn(f'could not resolve type for "{obj}". defaulting to "string"') return dict(OPENAPI_TYPE_MAPPING[OpenApiTypes.STR])
def sanitize_result_object(result): # warn about and resolve operationId collisions with suffixes operations = defaultdict(list) for path, methods in result['paths'].items(): for method, operation in methods.items(): operations[operation['operationId']].append((path, method)) for operation_id, paths in operations.items(): if len(paths) == 1: continue warn(f'operationId "{operation_id}" has collisions {paths}. resolving with numeral suffixes.') for idx, (path, method) in enumerate(sorted(paths)[1:], start=2): suffix = str(idx) if spectacular_settings.CAMELIZE_NAMES else f'_{idx}' result['paths'][path][method]['operationId'] += suffix return result
def __contains__(self, component): if component.key not in self._components: return False query_obj = component.object registry_obj = self._components[component.key].object query_class = query_obj if inspect.isclass(query_obj) else query_obj.__class__ registry_class = query_obj if inspect.isclass(registry_obj) else registry_obj.__class__ if query_class != registry_class: warn( f'Encountered 2 components with identical names "{component.name}" and ' f'different classes {query_class} and {registry_class}. This will very ' f'likely result in an incorrect schema. Try renaming one.' ) return True
def _follow_return_type(a_callable): target_type = get_type_hints(a_callable).get('return') if target_type is None: return target_type origin, args = _get_type_hint_origin(target_type) if origin is typing.Union: type_args = [arg for arg in args if arg is not type(None)] # noqa: E721 if len(type_args) > 1: warn( f'could not traverse Union type, because we don\'t know which type to choose ' f'from {type_args}. Consider terminating "source" on a custom property ' f'that indicates the expected Optional/Union type. Defaulting to "string"' ) return target_type # Optional: return type_args[0] return target_type
def get_view_model(view): """ obtain model from view via view's queryset. try safer view attribute first before going through get_queryset(), which may perform arbitrary operations. """ model = getattr(getattr(view, 'queryset', None), 'model', None) if model is not None: return model try: return view.get_queryset().model except Exception as exc: warn( f'failed to obtain model through view\'s queryset due to raised exception. ' f'prevent this either by setting "queryset = Model.objects.none()" on the view, ' f'having an empty fallback in get_queryset() or by using @extend_schema. ' f'(Exception: {exc})')
def custom_preprocessing_hook(endpoints: Any) -> Any: # TODO: organize method, rename from sentry.apidocs.public_exclusion_list import ( EXCLUDED_FROM_PUBLIC_ENDPOINTS, PUBLIC_ENDPOINTS_FROM_JSON, ) registered_endpoints = PUBLIC_ENDPOINTS_FROM_JSON | EXCLUDED_FROM_PUBLIC_ENDPOINTS filtered = [] for (path, path_regex, method, callback) in endpoints: view = f"{callback.__module__}.{callback.__name__}" if callback.view_class.public and callback.view_class.private: warn( "both `public` and `private` cannot be defined at the same time, " "please remove one of the attributes." ) if callback.view_class.public: # endpoints that are documented via tooling if method in callback.view_class.public: # only pass declared public methods of the endpoint # to the rest of the OpenAPI build pipeline filtered.append((path, path_regex, method, callback)) elif view in registered_endpoints: # don't error if endpoint is added to exclusion list pass elif callback.view_class.private: # if the endpoint is explicitly private, that's okay. pass else: # any new endpoint that isn't accounted for should recieve this error when building api docs warn( f"{view} {method} is unaccounted for. " "Either document the endpoint and define the `public` attribute on the endpoint " "with the public HTTP methods, " "or set the `private` attribute on the endpoint to `True`. " "See https://develop.sentry.dev/api/public/ for more info on " "making APIs public." ) return filtered
def detype_pattern(pattern): """ return an equivalent pattern that accepts arbitrary values for path parameters. de-typing the path will ease determining a matching route without having properly formatted dummy values for all path parameters. """ if isinstance(pattern, URLResolver): return URLResolver( pattern=detype_pattern(pattern.pattern), urlconf_name=[detype_pattern(p) for p in pattern.url_patterns], default_kwargs=pattern.default_kwargs, app_name=pattern.app_name, namespace=pattern.namespace, ) elif isinstance(pattern, URLPattern): return URLPattern( pattern=detype_pattern(pattern.pattern), callback=pattern.callback, default_args=pattern.default_args, name=pattern.name, ) elif isinstance(pattern, RoutePattern): return RoutePattern( route=re.sub(r'<\w+:(\w+)>', r'<\1>', pattern._route), name=pattern.name, is_endpoint=pattern._is_endpoint ) elif isinstance(pattern, RegexPattern): detyped_regex = pattern._regex for name, regex in analyze_named_regex_pattern(pattern._regex).items(): detyped_regex = detyped_regex.replace( f'(?P<{name}>{regex})', f'(?P<{name}>[^/]+)', ) return RegexPattern( regex=detyped_regex, name=pattern.name, is_endpoint=pattern._is_endpoint ) else: warn(f'unexpected pattern "{pattern}" encountered while simplifying urlpatterns.') return pattern
def get_view_model(view, emit_warnings=True): """ obtain model from view via view's queryset. try safer view attribute first before going through get_queryset(), which may perform arbitrary operations. """ model = getattr(getattr(view, 'queryset', None), 'model', None) if model is not None: return model try: return view.get_queryset().model except Exception as exc: if emit_warnings: warn( f'Failed to obtain model through view\'s queryset due to raised exception. ' f'Prevent this either by setting "queryset = Model.objects.none()" on the ' f'view, checking for "getattr(self, "swagger_fake_view", False)" in ' f'get_queryset() or by simply using @extend_schema. (Exception: {exc})' )
def decorator(view): view_methods = {m.__name__: m for m in get_view_methods(view)} for method_name, method_decorator in kwargs.items(): if method_name not in view_methods: warn( f'@extend_schema_view argument "{method_name}" was not found on view ' f'{view.__name__}. method override for "{method_name}" will be ignored.' ) continue method = view_methods[method_name] # the context of derived methods must not be altered, as it belongs to the other # class. create a new context via the wrapping_decorator so the schema can be safely # stored in the wrapped_method. methods belonging to the view can be safely altered. if method_name in view.__dict__: method_decorator(method) else: setattr(view, method_name, wrapping_decorator(method_decorator, method)) return view
def follow_field_source(model, path): """ a model traversal chain "foreignkey.foreignkey.value" can either end with an actual model field instance "value" or a model property function named "value". differentiate the cases. :return: models.Field or function object """ try: return _follow_field_source(model, path) except UnableToProceedError as e: warn(e) except Exception as exc: warn( f'could not resolve field on model {model} with path "{".".join(path)}". ' f'this is likely a custom field that does some unknown magic. maybe ' f'consider annotating the field/property? defaulting to "string". (Exception: {exc})' ) def dummy_property(obj) -> str: pass return dummy_property
def resolve_filter_field(self, auto_schema, model, filterset_class, field_name, filter_field): from django_filters.rest_framework import filters unambiguous_mapping = { filters.CharFilter: OpenApiTypes.STR, filters.BooleanFilter: OpenApiTypes.BOOL, filters.DateFilter: OpenApiTypes.DATE, filters.DateTimeFilter: OpenApiTypes.DATETIME, filters.IsoDateTimeFilter: OpenApiTypes.DATETIME, filters.TimeFilter: OpenApiTypes.TIME, filters.UUIDFilter: OpenApiTypes.UUID, filters.DurationFilter: OpenApiTypes.DURATION, filters.OrderingFilter: OpenApiTypes.STR, filters.TimeRangeFilter: OpenApiTypes.TIME, filters.DateFromToRangeFilter: OpenApiTypes.DATE, filters.IsoDateTimeFromToRangeFilter: OpenApiTypes.DATETIME, filters.DateTimeFromToRangeFilter: OpenApiTypes.DATETIME, } if isinstance(filter_field, tuple(unambiguous_mapping)): for cls in filter_field.__class__.__mro__: if cls in unambiguous_mapping: schema = build_basic_type(unambiguous_mapping[cls]) break elif isinstance(filter_field, (filters.NumberFilter, filters.NumericRangeFilter)): # NumberField is underspecified by itself. try to find the # type that makes the most sense or default to generic NUMBER if filter_field.method: schema = self._build_filter_method_type( filterset_class, filter_field) if schema['type'] not in ['integer', 'number']: schema = build_basic_type(OpenApiTypes.NUMBER) else: model_field = self._get_model_field(filter_field, model) if isinstance(model_field, (models.IntegerField, models.AutoField)): schema = build_basic_type(OpenApiTypes.INT) elif isinstance(model_field, models.FloatField): schema = build_basic_type(OpenApiTypes.FLOAT) elif isinstance(model_field, models.DecimalField): schema = build_basic_type( OpenApiTypes.NUMBER) # TODO may be improved else: schema = build_basic_type(OpenApiTypes.NUMBER) elif filter_field.method: # try to make best effort on the given method schema = self._build_filter_method_type(filterset_class, filter_field) else: # last resort is to lookup the type via the model field. model_field = self._get_model_field(filter_field, model) if isinstance(model_field, models.Field): try: schema = auto_schema._map_model_field(model_field, direction=None) except Exception as exc: warn( f'Exception raised while trying resolve model field for django-filter ' f'field "{field_name}". Defaulting to string (Exception: {exc})' ) schema = build_basic_type(OpenApiTypes.STR) else: # default to string if nothing else works schema = build_basic_type(OpenApiTypes.STR) # primary keys are usually non-editable (readOnly=True) and map_model_field correctly # signals that attribute. however this does not apply in this context. schema.pop('readOnly', None) # enrich schema with additional info from filter_field enum = schema.pop('enum', None) if 'choices' in filter_field.extra: enum = [c for c, _ in filter_field.extra['choices']] if enum: schema['enum'] = sorted(enum, key=str) description = schema.pop('description', None) if filter_field.extra.get('help_text', None): description = filter_field.extra['help_text'] elif filter_field.label is not None: description = filter_field.label # parameter style variations based on filter base class if isinstance(filter_field, filters.BaseCSVFilter): schema = build_array_type(schema) field_names = [field_name] explode = False style = 'form' elif isinstance(filter_field, filters.MultipleChoiceFilter): schema = build_array_type(schema) field_names = [field_name] explode = True style = 'form' elif isinstance(filter_field, (filters.RangeFilter, filters.NumericRangeFilter)): try: suffixes = filter_field.field_class.widget.suffixes except AttributeError: suffixes = ['min', 'max'] field_names = [ f'{field_name}_{suffix}' if suffix else field_name for suffix in suffixes ] explode = None style = None else: field_names = [field_name] explode = None style = None return [ build_parameter_type(name=field_name, required=filter_field.extra['required'], location=OpenApiParameter.QUERY, description=description, schema=schema, explode=explode, style=style) for field_name in field_names ]