deprecation_utils.py 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234
  1. from __future__ import annotations
  2. from typing import Any
  3. from flask import abort, current_app, request, Blueprint, Response, after_this_request
  4. from flask_security.core import current_user
  5. import pandas as pd
  6. from flexmeasures.utils.time_utils import to_http_time
  7. def sunset_blueprint(
  8. blueprint,
  9. api_version_being_sunset: str,
  10. sunset_link: str,
  11. api_version_upgrade_to: str = "3.0",
  12. rollback_possible: bool = True,
  13. **kwargs,
  14. ):
  15. """Sunsets every route on a blueprint by returning 410 (Gone) responses, if sunset is active.
  16. Whether the sunset is active can be toggled using the config setting "FLEXMEASURES_API_SUNSET_ACTIVE".
  17. If the sunset is inactive, this function will not affect any requests in this blueprint.
  18. If the endpoint implementations have been removed, set rollback_possible=False.
  19. Errors will be logged by utils.error_utils.error_handling_router.
  20. """
  21. def return_410_unless_host_rolls_back_sunrise():
  22. if (
  23. rollback_possible
  24. and not current_app.config["FLEXMEASURES_API_SUNSET_ACTIVE"]
  25. ):
  26. # Sunset is inactive and blueprint contents should still be there,
  27. # so we let the request pass to the endpoint implementation
  28. pass
  29. else:
  30. # Override with custom info link, if set by host
  31. link = override_from_config(sunset_link, "FLEXMEASURES_API_SUNSET_LINK")
  32. abort(
  33. 410,
  34. f"API version {api_version_being_sunset} has been sunset. Please upgrade to API version {api_version_upgrade_to}. See {link} for more information.",
  35. )
  36. blueprint.before_request(return_410_unless_host_rolls_back_sunrise)
  37. def deprecate_fields(
  38. fields: str | list[str],
  39. deprecation_date: pd.Timestamp | str | None = None,
  40. deprecation_link: str | None = None,
  41. sunset_date: pd.Timestamp | str | None = None,
  42. sunset_link: str | None = None,
  43. ):
  44. """Deprecates a field (or fields) on a route by adding the "Deprecation" header with a deprecation date.
  45. Also logs a warning when a deprecated field is used.
  46. >>> from flask_classful import route
  47. >>> @route("/item/", methods=["POST"])
  48. @use_kwargs(
  49. {
  50. "color": ColorField,
  51. "length": LengthField,
  52. }
  53. )
  54. def post_item(color, length):
  55. deprecate_field(
  56. "color",
  57. deprecation_date="2022-12-14",
  58. deprecation_link="https://flexmeasures.readthedocs.io/some-deprecation-notice",
  59. sunset_date="2023-02-01",
  60. sunset_link="https://flexmeasures.readthedocs.io/some-sunset-notice",
  61. )
  62. :param fields: The fields (as a list of strings) to be deprecated
  63. :param deprecation_date: date indicating when the field was deprecated, used for the "Deprecation" header
  64. if no date is given, defaults to "true"
  65. see https://datatracker.ietf.org/doc/html/draft-ietf-httpapi-deprecation-header#section-2-1
  66. :param deprecation_link: url providing more information about the deprecation
  67. :param sunset_date: date indicating when the field is likely to become unresponsive
  68. :param sunset_link: url providing more information about the sunset
  69. References
  70. ----------
  71. - Deprecation header: https://datatracker.ietf.org/doc/html/draft-ietf-httpapi-deprecation-header
  72. - Sunset header: https://www.rfc-editor.org/rfc/rfc8594
  73. """
  74. if not isinstance(fields, list):
  75. fields = [fields]
  76. deprecation = _format_deprecation(deprecation_date)
  77. sunset = _format_sunset(sunset_date)
  78. @after_this_request
  79. def _after_request_handler(response: Response) -> Response:
  80. deprecated_fields_used = set(fields) & set(
  81. request.json.keys()
  82. ) # sets intersect
  83. # If any deprecated field is used, log a warning and add deprecation and sunset headers
  84. if deprecated_fields_used:
  85. current_app.logger.warning(
  86. f"Endpoint {request.endpoint} called by {current_user} with deprecated fields: {deprecated_fields_used}"
  87. )
  88. # Override sunset date if host used corresponding config setting
  89. _sunset = override_from_config(sunset, "FLEXMEASURES_API_SUNSET_DATE")
  90. # Override sunset link if host used corresponding config setting
  91. _sunset_link = override_from_config(
  92. sunset_link, "FLEXMEASURES_API_SUNSET_LINK"
  93. )
  94. return _add_headers(
  95. response,
  96. deprecation,
  97. deprecation_link,
  98. _sunset,
  99. _sunset_link,
  100. )
  101. return response
  102. def deprecate_blueprint(
  103. blueprint: Blueprint,
  104. deprecation_date: pd.Timestamp | str | None = None,
  105. deprecation_link: str | None = None,
  106. sunset_date: pd.Timestamp | str | None = None,
  107. sunset_link: str | None = None,
  108. **kwargs,
  109. ):
  110. """Deprecates every route on a blueprint by adding the "Deprecation" header with a deprecation date.
  111. Also logs a warning when a deprecated endpoint is called.
  112. >>> from flask import Flask, Blueprint
  113. >>> app = Flask('some_app')
  114. >>> deprecated_bp = Blueprint('API version 1', 'v1_bp')
  115. >>> app.register_blueprint(deprecated_bp, url_prefix='/v1')
  116. >>> deprecate_blueprint(
  117. deprecated_bp,
  118. deprecation_date="2022-12-14",
  119. deprecation_link="https://flexmeasures.readthedocs.io/some-deprecation-notice",
  120. sunset_date="2023-02-01",
  121. sunset_link="https://flexmeasures.readthedocs.io/some-sunset-notice",
  122. )
  123. :param blueprint: The blueprint to be deprecated
  124. :param deprecation_date: date indicating when the API endpoint was deprecated, used for the "Deprecation" header
  125. if no date is given, defaults to "true"
  126. see https://datatracker.ietf.org/doc/html/draft-ietf-httpapi-deprecation-header#section-2-1
  127. :param deprecation_link: url providing more information about the deprecation
  128. :param sunset_date: date indicating when the API endpoint is likely to become unresponsive
  129. :param sunset_link: url providing more information about the sunset
  130. References
  131. ----------
  132. - Deprecation header: https://datatracker.ietf.org/doc/html/draft-ietf-httpapi-deprecation-header
  133. - Sunset header: https://www.rfc-editor.org/rfc/rfc8594
  134. """
  135. deprecation = _format_deprecation(deprecation_date)
  136. sunset = _format_sunset(sunset_date)
  137. def _after_request_handler(response: Response) -> Response:
  138. current_app.logger.warning(
  139. f"Deprecated endpoint {request.endpoint} called by {current_user}"
  140. )
  141. # Override sunset date if host used corresponding config setting
  142. _sunset = override_from_config(sunset, "FLEXMEASURES_API_SUNSET_DATE")
  143. # Override sunset link if host used corresponding config setting
  144. _sunset_link = override_from_config(sunset_link, "FLEXMEASURES_API_SUNSET_LINK")
  145. return _add_headers(
  146. response,
  147. deprecation,
  148. deprecation_link,
  149. _sunset,
  150. _sunset_link,
  151. )
  152. blueprint.after_request(_after_request_handler)
  153. def _add_headers(
  154. response: Response,
  155. deprecation: str,
  156. deprecation_link: str | None,
  157. sunset: str | None,
  158. sunset_link: str | None,
  159. ) -> Response:
  160. response.headers.extend({"Deprecation": deprecation})
  161. if deprecation_link:
  162. response = _add_link(response, deprecation_link, "deprecation")
  163. if sunset:
  164. response.headers.extend({"Sunset": sunset})
  165. if sunset_link:
  166. response = _add_link(response, sunset_link, "sunset")
  167. return response
  168. def _add_link(response: Response, link: str, rel: str) -> Response:
  169. link_text = f'<{link}>; rel="{rel}"; type="text/html"'
  170. response.headers.extend({"Link": link_text})
  171. return response
  172. def _format_deprecation(deprecation_date):
  173. if deprecation_date:
  174. deprecation = to_http_time(pd.Timestamp(deprecation_date) - pd.Timedelta("1s"))
  175. else:
  176. deprecation = "true"
  177. return deprecation
  178. def _format_sunset(sunset_date):
  179. if sunset_date:
  180. sunset = to_http_time(pd.Timestamp(sunset_date) - pd.Timedelta("1s"))
  181. else:
  182. sunset = None
  183. return sunset
  184. def override_from_config(setting: Any, config_setting_name: str) -> Any:
  185. """Override setting by config setting, unless the latter is None or is missing."""
  186. config_setting = current_app.config.get(config_setting_name)
  187. if config_setting is not None:
  188. _setting = config_setting
  189. else:
  190. _setting = setting
  191. return _setting