schema.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508
  1. import re
  2. import typing
  3. from collections import OrderedDict
  4. from django.utils.translation import gettext_lazy as _
  5. from drf_spectacular.contrib.django_filters import DjangoFilterExtension
  6. from drf_spectacular.extensions import OpenApiSerializerExtension, OpenApiSerializerFieldExtension, _SchemaType
  7. from drf_spectacular.openapi import AutoSchema
  8. from drf_spectacular.plumbing import (
  9. build_basic_type,
  10. build_choice_field,
  11. build_media_type_object,
  12. build_object_type,
  13. follow_field_source,
  14. get_doc,
  15. )
  16. from drf_spectacular.types import OpenApiTypes
  17. from drf_spectacular.utils import Direction, OpenApiParameter, OpenApiResponse
  18. from netbox.api.fields import ChoiceField
  19. from netbox.api.serializers import BulkOperationErrorSerializer, WritableNestedSerializer
  20. from netbox.api.viewsets import NetBoxModelViewSet
  21. # see netbox.api.routers.NetBoxRouter
  22. BULK_ACTIONS = ("bulk_destroy", "bulk_partial_update", "bulk_update")
  23. WRITABLE_ACTIONS = ("PATCH", "POST", "PUT")
  24. class NetBoxDjangoFilterExtension(DjangoFilterExtension):
  25. """
  26. Overrides drf-spectacular's DjangoFilterExtension to fix a regression in v0.29.0 where
  27. _get_model_field() incorrectly double-appends to_field_name when field_name already ends
  28. with that value (e.g. field_name='tags__slug', to_field_name='slug' produces the invalid
  29. path ['tags', 'slug', 'slug']). This caused hundreds of spurious warnings during schema
  30. generation for filters such as TagFilter, TenancyFilterSet.tenant, and OwnerFilterMixin.owner.
  31. See: https://github.com/netbox-community/netbox/issues/20787
  32. https://github.com/tfranzel/drf-spectacular/issues/1475
  33. """
  34. priority = 1
  35. def _get_model_field(self, filter_field, model):
  36. if not filter_field.field_name:
  37. return None
  38. path = filter_field.field_name.split('__')
  39. to_field_name = filter_field.extra.get('to_field_name')
  40. if to_field_name is not None and path[-1] != to_field_name:
  41. path.append(to_field_name)
  42. return follow_field_source(model, path, emit_warnings=False)
  43. class FixTimeZoneSerializerField(OpenApiSerializerFieldExtension):
  44. target_class = 'timezone_field.rest_framework.TimeZoneSerializerField'
  45. def map_serializer_field(self, auto_schema, direction):
  46. return build_basic_type(OpenApiTypes.STR)
  47. class ChoiceFieldFix(OpenApiSerializerFieldExtension):
  48. target_class = 'netbox.api.fields.ChoiceField'
  49. def map_serializer_field(self, auto_schema, direction):
  50. build_cf = build_choice_field(self.target)
  51. if direction == 'request':
  52. return build_cf
  53. if direction == "response":
  54. value = build_cf
  55. label = {
  56. **build_basic_type(OpenApiTypes.STR),
  57. "enum": list(OrderedDict.fromkeys(self.target.choices.values()))
  58. }
  59. return build_object_type(
  60. properties={
  61. "value": value,
  62. "label": label
  63. }
  64. )
  65. # TODO: This function should never implicitly/explicitly return `None`
  66. # The fallback should be well-defined (drf-spectacular expects request/response naming).
  67. return None
  68. def viewset_handles_bulk_create(view):
  69. """Check if view automatically provides list-based bulk create"""
  70. return isinstance(view, NetBoxModelViewSet)
  71. class NetBoxAutoSchema(AutoSchema):
  72. """
  73. Overrides to drf_spectacular.openapi.AutoSchema to fix following issues:
  74. 1. bulk serializers cause operation_id conflicts with non-bulk ones
  75. 2. bulk operations should specify a list
  76. 3. bulk operations don't have filter params
  77. 4. bulk operations don't have pagination
  78. 5. bulk delete should specify input
  79. """
  80. writable_serializers = {}
  81. @property
  82. def is_bulk_action(self):
  83. if hasattr(self.view, "action") and self.view.action in BULK_ACTIONS:
  84. return True
  85. return False
  86. def get_operation_id(self):
  87. """
  88. bulk serializers cause operation_id conflicts with non-bulk ones
  89. bulk operations cause id conflicts in spectacular resulting in numerous:
  90. Warning: operationId "xxx" has collisions [xxx]. "resolving with numeral suffixes"
  91. code is modified from drf_spectacular.openapi.AutoSchema.get_operation_id
  92. """
  93. if self.is_bulk_action:
  94. tokenized_path = self._tokenize_path()
  95. # replace dashes as they can be problematic later in code generation
  96. tokenized_path = [t.replace('-', '_') for t in tokenized_path]
  97. if self.method == 'GET' and self._is_list_view():
  98. # this shouldn't happen, but keeping it here to follow base code
  99. action = 'list'
  100. else:
  101. # action = self.method_mapping[self.method.lower()]
  102. # use bulk name so partial_update -> bulk_partial_update
  103. action = self.view.action.lower()
  104. if not tokenized_path:
  105. tokenized_path.append('root')
  106. if re.search(r'<drf_format_suffix\w*:\w+>', self.path_regex):
  107. tokenized_path.append('formatted')
  108. return '_'.join(tokenized_path + [action])
  109. # if not bulk - just return normal id
  110. return super().get_operation_id()
  111. def get_request_serializer(self) -> typing.Any:
  112. serializer = super().get_request_serializer()
  113. # Bulk update/partial-update has a special request shape: a list of
  114. # writable objects plus a required `id` field. The normal writable
  115. # serializer omits `id` because it is read-only, so don't use the generic
  116. # bulk handling for these actions.
  117. action = getattr(self.view, 'action', None)
  118. if action in ('bulk_update', 'bulk_partial_update'):
  119. get_bulk_update_request_serializer = getattr(
  120. self.view,
  121. 'get_bulk_update_request_serializer',
  122. None,
  123. )
  124. if get_bulk_update_request_serializer is not None:
  125. return get_bulk_update_request_serializer(
  126. partial=(action == 'bulk_partial_update' or self.method == 'PATCH')
  127. )
  128. # Bulk creates/deletes should specify a list.
  129. if self.is_bulk_action:
  130. return type(serializer)(many=True)
  131. # handle mapping for Writable serializers - adapted from dansheps original
  132. # code for drf-yasg.
  133. if serializer is not None and self.method in WRITABLE_ACTIONS:
  134. writable_class = self.get_writable_class(serializer)
  135. if writable_class is not None:
  136. if hasattr(serializer, "child"):
  137. child_serializer = self.get_writable_class(serializer.child)
  138. serializer = writable_class(context=serializer.context, child=child_serializer)
  139. else:
  140. serializer = writable_class(context=serializer.context)
  141. return serializer
  142. def get_response_serializers(self) -> typing.Any:
  143. # bulk operations should specify a list
  144. response_serializers = super().get_response_serializers()
  145. if self.is_bulk_action:
  146. return type(response_serializers)(many=True)
  147. return response_serializers
  148. def _get_bulk_error_responses(self, direction) -> typing.Any:
  149. """
  150. Return the error responses of the current bulk write action, keyed by status code, or an
  151. empty dict if this action is not a bulk write.
  152. A failed bulk write returns a structured body correlating each failure with the object (or,
  153. where no object could be identified, the request position) responsible for it. This is a
  154. documented part of the API contract, but drf-spectacular cannot infer it: responses are
  155. derived from the request/response serializer alone, which describes only the success case.
  156. """
  157. action = getattr(self.view, 'action', None)
  158. if action in ('bulk_update', 'bulk_partial_update'):
  159. return {
  160. '400': OpenApiResponse(
  161. response=BulkOperationErrorSerializer,
  162. description=_(
  163. "One or more of the objects specified could not be updated. No objects were "
  164. "modified: a bulk update is an all-or-none operation."
  165. ),
  166. ),
  167. }
  168. if action == 'bulk_destroy':
  169. return {
  170. '400': OpenApiResponse(
  171. response=BulkOperationErrorSerializer,
  172. description=_(
  173. "The request was malformed, or one or more of the objects specified could "
  174. "not be found. No objects were deleted."
  175. ),
  176. ),
  177. '409': OpenApiResponse(
  178. response=BulkOperationErrorSerializer,
  179. description=_(
  180. "One or more of the objects specified could not be deleted, because a "
  181. "dependent object or a protection rule prevents it. No objects were "
  182. "deleted: a bulk deletion is an all-or-none operation."
  183. ),
  184. ),
  185. }
  186. if action == 'create' and viewset_handles_bulk_create(self.view):
  187. # A POST to a list endpoint accepts either a single object or a list of them (see
  188. # _get_request_for_media_type()), so its error body takes one of two shapes
  189. # accordingly: field-keyed errors for a single object, or the bulk envelope for a list.
  190. component = self.resolve_serializer(BulkOperationErrorSerializer, direction)
  191. return {
  192. '400': OpenApiResponse(
  193. response={
  194. 'oneOf': [
  195. build_basic_type(OpenApiTypes.OBJECT),
  196. component.ref if component else build_basic_type(OpenApiTypes.OBJECT),
  197. ],
  198. },
  199. description=_(
  200. "The object could not be created. Where a list was submitted, no objects "
  201. "were created: a bulk creation is an all-or-none operation."
  202. ),
  203. ),
  204. }
  205. return {}
  206. def _get_response_bodies(self, direction='response') -> typing.Any:
  207. responses = super()._get_response_bodies(direction=direction)
  208. # Document the error responses of the bulk write actions, which cannot be inferred (see
  209. # _get_bulk_error_responses). A status code already present -- for instance one declared
  210. # via @extend_schema on a custom action -- is left as it is.
  211. for code, response in self._get_bulk_error_responses(direction).items():
  212. if code not in responses:
  213. responses[code] = self._get_response_for_code(response, code, direction=direction)
  214. return responses
  215. def _get_request_for_media_type(self, serializer, direction='request'):
  216. """
  217. Override to generate oneOf schema for serializers that support both
  218. single object and array input (NetBoxModelViewSet POST operations).
  219. Refs: #20638
  220. """
  221. # Get the standard schema first
  222. schema, required = super()._get_request_for_media_type(serializer, direction)
  223. # If this serializer supports arrays (marked in get_request_serializer),
  224. # wrap the schema in oneOf to allow single object OR array
  225. if (
  226. direction == 'request' and
  227. schema is not None and
  228. getattr(self.view, 'action', None) == 'create' and
  229. viewset_handles_bulk_create(self.view)
  230. ):
  231. return {
  232. 'oneOf': [
  233. schema, # Single object
  234. {
  235. 'type': 'array',
  236. 'items': schema, # Array of objects
  237. }
  238. ]
  239. }, required
  240. return schema, required
  241. def _get_serializer_name(self, serializer, direction, bypass_extensions=False) -> str:
  242. name = super()._get_serializer_name(serializer, direction, bypass_extensions)
  243. # If this serializer is nested, prepend its name with "Brief"
  244. if getattr(serializer, 'nested', False):
  245. name = f'Brief{name}'
  246. return name
  247. def get_serializer_ref_name(self, serializer):
  248. # from drf-yasg.utils
  249. """Get serializer's ref_name
  250. :param serializer: Serializer instance
  251. :return: Serializer's ``ref_name`` or ``None`` for inline serializer
  252. :rtype: str or None
  253. """
  254. serializer_meta = getattr(serializer, 'Meta', None)
  255. serializer_name = type(serializer).__name__
  256. if hasattr(serializer_meta, 'ref_name'):
  257. ref_name = serializer_meta.ref_name
  258. else:
  259. ref_name = serializer_name
  260. if ref_name.endswith('Serializer'):
  261. ref_name = ref_name[: -len('Serializer')]
  262. return ref_name
  263. def get_writable_class(self, serializer):
  264. properties = {}
  265. fields = {} if hasattr(serializer, 'child') else serializer.fields
  266. remove_fields = []
  267. # If you get a failure here for "AttributeError: 'cached_property' object has no attribute 'items'"
  268. # it is probably because you are using a viewsets.ViewSet for the API View and are defining a
  269. # serializer_class. You will also need to define a get_serializer() method like for GenericAPIView.
  270. for child_name, child in fields.items():
  271. # read_only fields don't need to be in writable (write only) serializers
  272. if 'read_only' in dir(child) and child.read_only:
  273. remove_fields.append(child_name)
  274. if isinstance(child, (ChoiceField, WritableNestedSerializer)):
  275. properties[child_name] = None
  276. if not properties:
  277. return None
  278. if type(serializer) not in self.writable_serializers:
  279. writable_name = 'Writable' + type(serializer).__name__
  280. meta_class = getattr(type(serializer), 'Meta', None)
  281. if meta_class:
  282. ref_name = 'Writable' + self.get_serializer_ref_name(serializer)
  283. # remove read_only fields from write-only serializers
  284. fields = list(meta_class.fields)
  285. for field in remove_fields:
  286. fields.remove(field)
  287. writable_meta = type('Meta', (meta_class,), {'ref_name': ref_name, 'fields': fields})
  288. properties['Meta'] = writable_meta
  289. self.writable_serializers[type(serializer)] = type(writable_name, (type(serializer),), properties)
  290. writable_class = self.writable_serializers[type(serializer)]
  291. return writable_class
  292. def get_override_parameters(self):
  293. params = super().get_override_parameters()
  294. # Expose the ?fields, ?omit, and ?brief query parameters supported by NetBoxModelViewSet
  295. # for all non-bulk GET operations (both list and detail).
  296. if not self.is_bulk_action and self.method == 'GET':
  297. params = list(params) + [
  298. OpenApiParameter(
  299. name='fields',
  300. location=OpenApiParameter.QUERY,
  301. required=False,
  302. type=OpenApiTypes.STR,
  303. description='Comma-separated list of fields to include in the response. Example: `fields=id,name`.',
  304. ),
  305. OpenApiParameter(
  306. name='omit',
  307. location=OpenApiParameter.QUERY,
  308. required=False,
  309. type=OpenApiTypes.STR,
  310. description='Comma-separated list of fields to exclude from the response. '
  311. 'Example: `omit=description,tags`.',
  312. ),
  313. OpenApiParameter(
  314. name='brief',
  315. location=OpenApiParameter.QUERY,
  316. required=False,
  317. type=OpenApiTypes.BOOL,
  318. description='Return only brief fields for each object.',
  319. ),
  320. ]
  321. return params
  322. def get_filter_backends(self):
  323. # bulk operations don't have filter params
  324. if self.is_bulk_action:
  325. return []
  326. return super().get_filter_backends()
  327. def _get_paginator(self):
  328. # bulk operations don't have pagination
  329. if self.is_bulk_action:
  330. return None
  331. return super()._get_paginator()
  332. def _get_request_body(self, direction='request'):
  333. # bulk delete should specify input
  334. if (not self.is_bulk_action) or (self.method != 'DELETE'):
  335. return super()._get_request_body(direction)
  336. # rest from drf_spectacular.openapi.AutoSchema._get_request_body
  337. # but remove the unsafe method check
  338. request_serializer = self.get_request_serializer()
  339. if isinstance(request_serializer, dict):
  340. content = []
  341. request_body_required = True
  342. for media_type, serializer in request_serializer.items():
  343. schema, partial_request_body_required = self._get_request_for_media_type(serializer, direction)
  344. examples = self._get_examples(serializer, direction, media_type)
  345. if schema is None:
  346. continue
  347. content.append((media_type, schema, examples))
  348. request_body_required &= partial_request_body_required
  349. else:
  350. schema, request_body_required = self._get_request_for_media_type(request_serializer, direction)
  351. if schema is None:
  352. return None
  353. content = [
  354. (media_type, schema, self._get_examples(request_serializer, direction, media_type))
  355. for media_type in self.map_parsers()
  356. ]
  357. request_body = {
  358. 'content': {
  359. media_type: build_media_type_object(schema, examples) for media_type, schema, examples in content
  360. }
  361. }
  362. if request_body_required:
  363. request_body['required'] = request_body_required
  364. return request_body
  365. def get_description(self):
  366. """
  367. Return a string description for the ViewSet.
  368. """
  369. # If a docstring is provided, use it.
  370. if self.view.__doc__:
  371. return get_doc(self.view.__class__)
  372. # When the action method is decorated with @action, use the docstring of the method.
  373. action_or_method = getattr(self.view, getattr(self.view, 'action', self.method.lower()), None)
  374. if action_or_method and action_or_method.__doc__:
  375. return get_doc(action_or_method)
  376. # Else, generate a description from the class name.
  377. return self._generate_description()
  378. def _generate_description(self):
  379. """
  380. Generate a docstring for the method. It also takes into account whether the method is for list or detail.
  381. """
  382. model_name = self.view.queryset.model._meta.verbose_name
  383. # Determine if the method is for list or detail.
  384. if '{id}' in self.path:
  385. return f"{self.method.capitalize()} a {model_name} object."
  386. return f"{self.method.capitalize()} a list of {model_name} objects."
  387. class FixSerializedPKRelatedField(OpenApiSerializerFieldExtension):
  388. target_class = 'netbox.api.fields.SerializedPKRelatedField'
  389. def map_serializer_field(self, auto_schema, direction):
  390. if direction == "response":
  391. component = auto_schema.resolve_serializer(self.target.serializer, direction)
  392. return component.ref if component else None
  393. return build_basic_type(OpenApiTypes.INT)
  394. class FixIntegerRangeSerializerSchema(OpenApiSerializerExtension):
  395. target_class = 'netbox.api.fields.IntegerRangeSerializer'
  396. match_subclasses = True
  397. def map_serializer(self, auto_schema: 'AutoSchema', direction: Direction) -> _SchemaType:
  398. # One range = two integers; many=True will wrap this in an outer array
  399. return {
  400. 'type': 'array',
  401. 'items': {
  402. 'type': 'integer',
  403. },
  404. 'minItems': 2,
  405. 'maxItems': 2,
  406. 'example': [10, 20],
  407. }
  408. # Nested models can be passed by ID in requests
  409. # The logic for this is handled in `BaseModelSerializer.to_internal_value`
  410. class FixWritableNestedSerializerAllowPK(OpenApiSerializerFieldExtension):
  411. target_class = 'netbox.api.serializers.BaseModelSerializer'
  412. match_subclasses = True
  413. def map_serializer_field(self, auto_schema, direction):
  414. schema = auto_schema._map_serializer_field(self.target, direction, bypass_extensions=True)
  415. if schema is None:
  416. return schema
  417. if direction == 'request' and self.target.nested:
  418. return {
  419. 'oneOf': [
  420. build_basic_type(OpenApiTypes.INT),
  421. schema,
  422. ]
  423. }
  424. return schema