v0.159.0
  1import json
  2from functools import cached_property
  3from typing import Any, ClassVar
  4
  5from plain.http import Request, Response
  6from plain.views.exceptions import ResponseException
  7
  8from .views import APIView
  9
 10__all__ = [
 11    "APIVersionChange",
 12    "VersionedAPIView",
 13]
 14
 15
 16class APIVersionChange:
 17    description: str = ""
 18
 19    def transform_request_forward(self, request: Request, data: dict[str, Any]) -> None:
 20        """
 21        If this version of the API made a change in how a request is processed,
 22        (ex. the name of an input changed) then you can
 23        """
 24
 25    def transform_response_backward(
 26        self, response: Response, data: dict[str, Any]
 27    ) -> None:
 28        """
 29        Transform the response data for this version.
 30
 31        We only transform the response data if we are moving backward to an older version.
 32        This is because the response data is always in the latest version.
 33        """
 34
 35
 36class VersionedAPIView(APIView):
 37    # API versions from newest to oldest
 38    api_versions: ClassVar[dict[str, list[type[APIVersionChange]]]] = {}
 39    api_version_header = "API-Version"
 40    default_api_version: str = ""
 41
 42    @cached_property
 43    def api_version(self) -> str:
 44        return self.get_api_version()
 45
 46    def get_api_version(self) -> str:
 47        version = ""
 48
 49        if version_name := self.request.headers.get(self.api_version_header, ""):
 50            version = version_name
 51        elif default_version := self.get_default_api_version():
 52            version = default_version
 53        else:
 54            raise ResponseException(
 55                Response(
 56                    f"Missing API version header '{self.api_version_header}'",
 57                    status_code=400,
 58                )
 59            )
 60
 61        if version in self.api_versions:
 62            return version
 63        else:
 64            raise ResponseException(
 65                Response(
 66                    f"Invalid API version '{version_name}'. Valid versions are: {', '.join(self.api_versions.keys())}",
 67                    status_code=400,
 68                )
 69            )
 70
 71    def get_default_api_version(self) -> str:
 72        # If this view has an api_key, use its version name
 73        if (api_key := getattr(self, "api_key", None)) and api_key.api_version:
 74            # If the API key has a version, use that
 75            return api_key.api_version
 76
 77        return self.default_api_version
 78
 79    def before_request(self) -> None:
 80        super().before_request()
 81        if self.request.content_type == "application/json":
 82            self.transform_request(self.request)
 83
 84    def after_response(self, response: Response) -> Response:
 85        response = super().after_response(response)
 86        if response.headers.get("Content-Type") == "application/json":
 87            self.transform_response(response)
 88
 89        # Put the API version on the response
 90        response.headers[self.api_version_header] = self.api_version
 91
 92        return response
 93
 94    def transform_request(self, request: Request) -> None:
 95        request_changes = []
 96
 97        # Find the version being requested,
 98        # then get every change after that up to the latest
 99        changing = False
100        for version, changes in reversed(self.api_versions.items()):
101            if version == self.api_version:
102                changing = True
103
104            if changing:
105                request_changes.extend(changes)
106
107        if not request_changes:
108            return
109
110        # Get the original request JSON
111        request_data = json.loads(request.body)
112
113        # Transform the request data for this version
114        for change in changes:
115            change().transform_request_forward(request, request_data)
116
117        # Update the request body with the transformed data
118        request._body = json.dumps(request_data).encode("utf-8")
119
120    def transform_response(self, response: Response) -> None:
121        response_changes = []
122
123        # Get the changes starting AFTER the current version
124        matching = False
125        for version, changes in reversed(self.api_versions.items()):
126            if matching:
127                response_changes.extend(changes)
128
129            if version == self.api_version:
130                matching = True
131
132        if not response_changes:
133            # No changes to apply, just return
134            return
135
136        # Get the original response JSON
137        response_data = json.loads(response.content)
138
139        for change in reversed(response_changes):
140            # Transform the response data for this version
141            change().transform_response_backward(response, response_data)
142
143        # Update the response body with the transformed data
144        response.content = json.dumps(response_data).encode("utf-8")