field_converter.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643
  1. """Utilities for generating OpenAPI Specification (fka Swagger) entities from
  2. :class:`Fields <marshmallow.fields.Field>`.
  3. .. warning::
  4. This module is treated as private API.
  5. Users should not need to use this module directly.
  6. """
  7. from __future__ import annotations
  8. import functools
  9. import operator
  10. import re
  11. import typing
  12. import warnings
  13. import marshmallow
  14. from marshmallow.orderedset import OrderedSet
  15. from packaging.version import Version
  16. # marshmallow field => (JSON Schema type, format)
  17. DEFAULT_FIELD_MAPPING: dict[type, tuple[str | None, str | None]] = {
  18. marshmallow.fields.Integer: ("integer", None),
  19. marshmallow.fields.Number: ("number", None),
  20. marshmallow.fields.Float: ("number", None),
  21. marshmallow.fields.Decimal: ("number", None),
  22. marshmallow.fields.String: ("string", None),
  23. marshmallow.fields.Boolean: ("boolean", None),
  24. marshmallow.fields.UUID: ("string", "uuid"),
  25. marshmallow.fields.DateTime: ("string", "date-time"),
  26. marshmallow.fields.Date: ("string", "date"),
  27. marshmallow.fields.Time: ("string", None),
  28. marshmallow.fields.TimeDelta: ("integer", None),
  29. marshmallow.fields.Email: ("string", "email"),
  30. marshmallow.fields.URL: ("string", "url"),
  31. marshmallow.fields.Dict: ("object", None),
  32. marshmallow.fields.Field: (None, None),
  33. marshmallow.fields.Raw: (None, None),
  34. marshmallow.fields.List: ("array", None),
  35. marshmallow.fields.IP: ("string", "ip"),
  36. marshmallow.fields.IPv4: ("string", "ipv4"),
  37. marshmallow.fields.IPv6: ("string", "ipv6"),
  38. }
  39. # Properties that may be defined in a field's metadata that will be added to the output
  40. # of field2property
  41. # https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#schemaObject
  42. _VALID_PROPERTIES = {
  43. "format",
  44. "title",
  45. "description",
  46. "default",
  47. "multipleOf",
  48. "maximum",
  49. "exclusiveMaximum",
  50. "minimum",
  51. "exclusiveMinimum",
  52. "maxLength",
  53. "minLength",
  54. "pattern",
  55. "maxItems",
  56. "minItems",
  57. "uniqueItems",
  58. "maxProperties",
  59. "minProperties",
  60. "required",
  61. "enum",
  62. "type",
  63. "items",
  64. "allOf",
  65. "oneOf",
  66. "anyOf",
  67. "not",
  68. "properties",
  69. "additionalProperties",
  70. "readOnly",
  71. "writeOnly",
  72. "xml",
  73. "externalDocs",
  74. "example",
  75. "nullable",
  76. "deprecated",
  77. }
  78. _VALID_PREFIX = "x-"
  79. class FieldConverterMixin:
  80. """Adds methods for converting marshmallow fields to an OpenAPI properties."""
  81. field_mapping: dict[type, tuple[str | None, str | None]] = DEFAULT_FIELD_MAPPING
  82. openapi_version: Version
  83. def init_attribute_functions(self):
  84. self.attribute_functions = [
  85. # self.field2type_and_format should run first
  86. # as other functions may rely on its output
  87. self.field2type_and_format,
  88. self.field2default,
  89. self.field2choices,
  90. self.field2read_only,
  91. self.field2write_only,
  92. self.field2range,
  93. self.field2length,
  94. self.field2pattern,
  95. self.metadata2properties,
  96. self.enum2properties,
  97. self.nested2properties,
  98. self.pluck2properties,
  99. self.list2properties,
  100. self.dict2properties,
  101. self.timedelta2properties,
  102. self.datetime2properties,
  103. self.field2nullable,
  104. ]
  105. def map_to_openapi_type(self, field_cls, *args):
  106. """Set mapping for custom field class.
  107. :param type field_cls: Field class to set mapping for.
  108. ``*args`` can be:
  109. - a pair of the form ``(type, format)``
  110. - a core marshmallow field type (in which case we reuse that type's mapping)
  111. """
  112. if len(args) == 1 and args[0] in self.field_mapping:
  113. openapi_type_field = self.field_mapping[args[0]]
  114. elif len(args) == 2:
  115. openapi_type_field = args
  116. else:
  117. raise TypeError("Pass core marshmallow field type or (type, fmt) pair.")
  118. self.field_mapping[field_cls] = openapi_type_field
  119. def add_attribute_function(self, func):
  120. """Method to add an attribute function to the list of attribute functions
  121. that will be called on a field to convert it from a field to an OpenAPI
  122. property.
  123. :param func func: the attribute function to add
  124. The attribute function will be bound to the
  125. `OpenAPIConverter <apispec.ext.marshmallow.openapi.OpenAPIConverter>`
  126. instance.
  127. It will be called for each field in a schema with
  128. `self <apispec.ext.marshmallow.openapi.OpenAPIConverter>` and a
  129. `field <marshmallow.fields.Field>` instance
  130. positional arguments and `ret <dict>` keyword argument.
  131. Must return a dictionary of OpenAPI properties that will be shallow
  132. merged with the return values of all other attribute functions called on the field.
  133. User added attribute functions will be called after all built-in attribute
  134. functions in the order they were added. The merged results of all
  135. previously called attribute functions are accessible via the `ret`
  136. argument.
  137. """
  138. bound_func = func.__get__(self)
  139. setattr(self, func.__name__, bound_func)
  140. self.attribute_functions.append(bound_func)
  141. def field2property(self, field: marshmallow.fields.Field) -> dict:
  142. """Return the JSON Schema property definition given a marshmallow
  143. :class:`Field <marshmallow.fields.Field>`.
  144. Will include field metadata that are valid properties of OpenAPI schema objects
  145. (e.g. "description", "enum", "example").
  146. https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#schemaObject
  147. :param Field field: A marshmallow field.
  148. :rtype: dict, a Property Object
  149. """
  150. ret: dict = {}
  151. for attr_func in self.attribute_functions:
  152. ret.update(attr_func(field, ret=ret))
  153. return ret
  154. def field2type_and_format(
  155. self, field: marshmallow.fields.Field, **kwargs: typing.Any
  156. ) -> dict:
  157. """Return the dictionary of OpenAPI type and format based on the field type.
  158. :param Field field: A marshmallow field.
  159. :rtype: dict
  160. """
  161. # If this type isn't directly in the field mapping then check the
  162. # hierarchy until we find something that does.
  163. for field_class in type(field).__mro__:
  164. if field_class in self.field_mapping:
  165. type_, fmt = self.field_mapping[field_class]
  166. break
  167. else:
  168. warnings.warn(
  169. f"Field of type {type(field)} does not inherit from marshmallow.Field.",
  170. UserWarning,
  171. stacklevel=2,
  172. )
  173. type_, fmt = "string", None
  174. ret = {}
  175. if type_:
  176. ret["type"] = type_
  177. if fmt:
  178. ret["format"] = fmt
  179. return ret
  180. def field2default(
  181. self, field: marshmallow.fields.Field, **kwargs: typing.Any
  182. ) -> dict:
  183. """Return the dictionary containing the field's default value.
  184. Will first look for a `default` key in the field's metadata and then
  185. fall back on the field's `missing` parameter. A callable passed to the
  186. field's missing parameter will be ignored.
  187. :param Field field: A marshmallow field.
  188. :rtype: dict
  189. """
  190. ret = {}
  191. if "default" in field.metadata:
  192. ret["default"] = field.metadata["default"]
  193. else:
  194. default = field.load_default
  195. if default is not marshmallow.missing and not callable(default):
  196. default = field._serialize(default, None, None)
  197. ret["default"] = default
  198. return ret
  199. def field2choices(
  200. self, field: marshmallow.fields.Field, **kwargs: typing.Any
  201. ) -> dict:
  202. """Return the dictionary of OpenAPI field attributes for valid choices definition.
  203. :param Field field: A marshmallow field.
  204. :rtype: dict
  205. """
  206. attributes = {}
  207. comparable = [
  208. validator.comparable
  209. for validator in field.validators
  210. if hasattr(validator, "comparable")
  211. ]
  212. if comparable:
  213. attributes["enum"] = comparable
  214. else:
  215. choices = [
  216. OrderedSet(validator.choices)
  217. for validator in field.validators
  218. if hasattr(validator, "choices")
  219. ]
  220. if choices:
  221. attributes["enum"] = list(functools.reduce(operator.and_, choices))
  222. if field.allow_none:
  223. enum = attributes.get("enum")
  224. if enum is not None and None not in enum:
  225. attributes["enum"].append(None)
  226. return attributes
  227. def field2read_only(
  228. self, field: marshmallow.fields.Field, **kwargs: typing.Any
  229. ) -> dict:
  230. """Return the dictionary of OpenAPI field attributes for a dump_only field.
  231. :param Field field: A marshmallow field.
  232. :rtype: dict
  233. """
  234. attributes = {}
  235. if field.dump_only:
  236. attributes["readOnly"] = True
  237. return attributes
  238. def field2write_only(
  239. self, field: marshmallow.fields.Field, **kwargs: typing.Any
  240. ) -> dict:
  241. """Return the dictionary of OpenAPI field attributes for a load_only field.
  242. :param Field field: A marshmallow field.
  243. :rtype: dict
  244. """
  245. attributes = {}
  246. if field.load_only and self.openapi_version.major >= 3:
  247. attributes["writeOnly"] = True
  248. return attributes
  249. def field2nullable(self, field: marshmallow.fields.Field, ret) -> dict:
  250. """Return the dictionary of OpenAPI field attributes for a nullable field.
  251. :param Field field: A marshmallow field.
  252. :rtype: dict
  253. """
  254. attributes: dict = {}
  255. if field.allow_none:
  256. if self.openapi_version.major < 3:
  257. attributes["x-nullable"] = True
  258. elif self.openapi_version.minor < 1:
  259. if "$ref" in ret:
  260. attributes["anyOf"] = [
  261. {"type": "object", "nullable": True},
  262. {"$ref": ret.pop("$ref")},
  263. ]
  264. elif "allOf" in ret:
  265. attributes["anyOf"] = [
  266. *ret.pop("allOf"),
  267. {"type": "object", "nullable": True},
  268. ]
  269. else:
  270. attributes["nullable"] = True
  271. else:
  272. if "$ref" in ret:
  273. attributes["anyOf"] = [{"$ref": ret.pop("$ref")}, {"type": "null"}]
  274. elif "allOf" in ret:
  275. attributes["anyOf"] = [*ret.pop("allOf"), {"type": "null"}]
  276. elif "type" in ret:
  277. attributes["type"] = [*make_type_list(ret.get("type")), "null"]
  278. return attributes
  279. def field2range(self, field: marshmallow.fields.Field, ret) -> dict:
  280. """Return the dictionary of OpenAPI field attributes for a set of
  281. :class:`Range <marshmallow.validators.Range>` validators.
  282. :param Field field: A marshmallow field.
  283. :rtype: dict
  284. """
  285. validators = [
  286. validator
  287. for validator in field.validators
  288. if (
  289. hasattr(validator, "min")
  290. and hasattr(validator, "max")
  291. and not hasattr(validator, "equal")
  292. )
  293. ]
  294. min_attr, max_attr = (
  295. ("minimum", "maximum")
  296. if set(make_type_list(ret.get("type"))) & {"number", "integer"}
  297. else ("x-minimum", "x-maximum")
  298. )
  299. # Serialize min/max values with the field to which the validator is applied
  300. return {
  301. k: field._serialize(v, None, None)
  302. for k, v in make_min_max_attributes(validators, min_attr, max_attr).items()
  303. }
  304. def field2length(
  305. self, field: marshmallow.fields.Field, **kwargs: typing.Any
  306. ) -> dict:
  307. """Return the dictionary of OpenAPI field attributes for a set of
  308. :class:`Length <marshmallow.validators.Length>` validators.
  309. :param Field field: A marshmallow field.
  310. :rtype: dict
  311. """
  312. validators = [
  313. validator
  314. for validator in field.validators
  315. if (
  316. hasattr(validator, "min")
  317. and hasattr(validator, "max")
  318. and hasattr(validator, "equal")
  319. )
  320. ]
  321. is_array = isinstance(
  322. field, (marshmallow.fields.Nested, marshmallow.fields.List)
  323. )
  324. min_attr = "minItems" if is_array else "minLength"
  325. max_attr = "maxItems" if is_array else "maxLength"
  326. equal_list = [
  327. validator.equal for validator in validators if validator.equal is not None
  328. ]
  329. if equal_list:
  330. return {min_attr: equal_list[0], max_attr: equal_list[0]}
  331. return make_min_max_attributes(validators, min_attr, max_attr)
  332. def field2pattern(
  333. self, field: marshmallow.fields.Field, **kwargs: typing.Any
  334. ) -> dict:
  335. """Return the dictionary of OpenAPI field attributes for a
  336. :class:`Regexp <marshmallow.validators.Regexp>` validator.
  337. If there is more than one such validator, only the first
  338. is used in the output spec.
  339. :param Field field: A marshmallow field.
  340. :rtype: dict
  341. """
  342. regex_validators = (
  343. v
  344. for v in field.validators
  345. if isinstance(getattr(v, "regex", None), re.Pattern)
  346. )
  347. v = next(regex_validators, None)
  348. attributes = {} if v is None else {"pattern": v.regex.pattern} # type:ignore
  349. if next(regex_validators, None) is not None:
  350. warnings.warn(
  351. f"More than one regex validator defined on {type(field)} field. Only the "
  352. "first one will be used in the output spec.",
  353. UserWarning,
  354. stacklevel=2,
  355. )
  356. return attributes
  357. def metadata2properties(
  358. self, field: marshmallow.fields.Field, **kwargs: typing.Any
  359. ) -> dict:
  360. """Return a dictionary of properties extracted from field metadata.
  361. Will include field metadata that are valid properties of `OpenAPI schema
  362. objects
  363. <https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#schemaObject>`_
  364. (e.g. "description", "enum", "example").
  365. In addition, `specification extensions
  366. <https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#specification-extensions>`_
  367. are supported. Prefix `x_` to the desired extension when passing the
  368. keyword argument to the field constructor. apispec will convert `x_` to
  369. `x-` to comply with OpenAPI.
  370. :param Field field: A marshmallow field.
  371. :rtype: dict
  372. """
  373. # Dasherize metadata that starts with x_
  374. metadata = {
  375. key.replace("_", "-") if key.startswith("x_") else key: value
  376. for key, value in field.metadata.items()
  377. if isinstance(key, str)
  378. }
  379. # Avoid validation error with "Additional properties not allowed"
  380. ret = {
  381. key: value
  382. for key, value in metadata.items()
  383. if key in _VALID_PROPERTIES or key.startswith(_VALID_PREFIX)
  384. }
  385. return ret
  386. def nested2properties(self, field: marshmallow.fields.Field, ret) -> dict:
  387. """Return a dictionary of properties from :class:`Nested <marshmallow.fields.Nested` fields.
  388. Typically provides a reference object and will add the schema to the spec
  389. if it is not already present
  390. If a custom `schema_name_resolver` function returns `None` for the nested
  391. schema a JSON schema object will be returned
  392. :param Field field: A marshmallow field.
  393. :rtype: dict
  394. """
  395. # Pluck is a subclass of Nested but is in essence a single field; it
  396. # is treated separately by pluck2properties.
  397. if isinstance(field, marshmallow.fields.Nested) and not isinstance(
  398. field, marshmallow.fields.Pluck
  399. ):
  400. schema_dict = self.resolve_nested_schema(field.schema) # type:ignore
  401. if (
  402. ret
  403. and "$ref" in schema_dict
  404. and (
  405. self.openapi_version.major < 3
  406. or (
  407. self.openapi_version.major == 3
  408. and self.openapi_version.minor == 0
  409. )
  410. )
  411. ):
  412. ret.update({"allOf": [schema_dict]})
  413. else:
  414. ret.update(schema_dict)
  415. return ret
  416. def pluck2properties(self, field, **kwargs: typing.Any) -> dict:
  417. """Return a dictionary of properties from :class:`Pluck <marshmallow.fields.Pluck` fields.
  418. Pluck effectively trans-includes a field from another schema into this,
  419. possibly wrapped in an array (`many=True`).
  420. :param Field field: A marshmallow field.
  421. :rtype: dict
  422. """
  423. if isinstance(field, marshmallow.fields.Pluck):
  424. plucked_field = field.schema.fields[field.field_name]
  425. ret = self.field2property(plucked_field)
  426. return {"type": "array", "items": ret} if field.many else ret
  427. return {}
  428. def list2properties(self, field, **kwargs: typing.Any) -> dict:
  429. """Return a dictionary of properties from :class:`List <marshmallow.fields.List>` fields.
  430. Will provide an `items` property based on the field's `inner` attribute
  431. :param Field field: A marshmallow field.
  432. :rtype: dict
  433. """
  434. ret = {}
  435. if isinstance(field, marshmallow.fields.List):
  436. ret["items"] = self.field2property(field.inner)
  437. return ret
  438. def dict2properties(self, field, **kwargs: typing.Any) -> dict:
  439. """Return a dictionary of properties from :class:`Dict <marshmallow.fields.Dict>` fields.
  440. Only applicable for Marshmallow versions greater than 3. Will provide an
  441. `additionalProperties` property based on the field's `value_field` attribute
  442. :param Field field: A marshmallow field.
  443. :rtype: dict
  444. """
  445. ret = {}
  446. if isinstance(field, marshmallow.fields.Dict):
  447. value_field = field.value_field
  448. if value_field:
  449. ret["additionalProperties"] = self.field2property(value_field)
  450. else:
  451. ret["additionalProperties"] = {}
  452. return ret
  453. def timedelta2properties(self, field, **kwargs: typing.Any) -> dict:
  454. """Return a dictionary of properties from :class:`TimeDelta <marshmallow.fields.TimeDelta>` fields.
  455. Adds a `x-unit` vendor property based on the field's `precision` attribute
  456. :param Field field: A marshmallow field.
  457. :rtype: dict
  458. """
  459. ret = {}
  460. if isinstance(field, marshmallow.fields.TimeDelta):
  461. ret["x-unit"] = field.precision
  462. return ret
  463. def enum2properties(self, field, **kwargs: typing.Any) -> dict:
  464. """Return a dictionary of properties from :class:`Enum <marshmallow.fields.Enum` fields.
  465. :param Field field: A marshmallow field.
  466. :rtype: dict
  467. """
  468. ret = {}
  469. if isinstance(field, marshmallow.fields.Enum):
  470. ret = self.field2property(field.field)
  471. if field.by_value is False:
  472. choices = (m for m in field.enum.__members__)
  473. else:
  474. choices = (m.value for m in field.enum)
  475. ret["enum"] = [field.field._serialize(v, None, None) for v in choices]
  476. if field.allow_none and None not in ret["enum"]:
  477. ret["enum"].append(None)
  478. return ret
  479. def datetime2properties(self, field, **kwargs: typing.Any) -> dict:
  480. """Return a dictionary of properties from :class:`DateTime <marshmallow.fields.DateTime` fields.
  481. :param Field field: A marshmallow field.
  482. :rtype: dict
  483. """
  484. ret = {}
  485. if isinstance(field, marshmallow.fields.DateTime) and not isinstance(
  486. field, marshmallow.fields.Date
  487. ):
  488. if field.format == "iso" or field.format is None:
  489. # Will return { "type": "string", "format": "date-time" }
  490. # as specified inside DEFAULT_FIELD_MAPPING
  491. pass
  492. elif field.format == "rfc":
  493. ret = {
  494. "type": "string",
  495. "format": None,
  496. "example": "Wed, 02 Oct 2002 13:00:00 GMT",
  497. "pattern": r"((Mon|Tue|Wed|Thu|Fri|Sat|Sun), ){0,1}\d{2} "
  498. + r"(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \d{4} \d{2}:\d{2}:\d{2} "
  499. + r"(UT|GMT|EST|EDT|CST|CDT|MST|MDT|PST|PDT|(Z|A|M|N)|(\+|-)\d{4})",
  500. }
  501. elif field.format == "timestamp":
  502. ret = {
  503. "type": "number",
  504. "format": "float",
  505. "example": "1676451245.596",
  506. "min": "0",
  507. }
  508. elif field.format == "timestamp_ms":
  509. ret = {
  510. "type": "number",
  511. "format": "float",
  512. "example": "1676451277514.654",
  513. "min": "0",
  514. }
  515. else:
  516. ret = {
  517. "type": "string",
  518. "format": None,
  519. "pattern": (
  520. field.metadata["pattern"]
  521. if field.metadata.get("pattern")
  522. else None
  523. ),
  524. }
  525. return ret
  526. def make_type_list(types):
  527. """Return a list of types from a type attribute
  528. Since OpenAPI 3.1.0, "type" can be a single type as string or a list of
  529. types, including 'null'. This function takes a "type" attribute as input
  530. and returns it as a list, be it an empty or single-element list.
  531. This is useful to factorize type-conditional code or code adding a type.
  532. """
  533. if types is None:
  534. return []
  535. if isinstance(types, str):
  536. return [types]
  537. return types
  538. def make_min_max_attributes(validators, min_attr, max_attr) -> dict:
  539. """Return a dictionary of minimum and maximum attributes based on a list
  540. of validators. If either minimum or maximum values are not present in any
  541. of the validator objects that attribute will be omitted.
  542. :param validators list: A list of `Marshmallow` validator objects. Each
  543. objct is inspected for a minimum and maximum values
  544. :param min_attr string: The OpenAPI attribute for the minimum value
  545. :param max_attr string: The OpenAPI attribute for the maximum value
  546. """
  547. attributes = {}
  548. min_list = [validator.min for validator in validators if validator.min is not None]
  549. max_list = [validator.max for validator in validators if validator.max is not None]
  550. if min_list:
  551. attributes[min_attr] = max(min_list)
  552. if max_list:
  553. attributes[max_attr] = min(max_list)
  554. return attributes