| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205 | from enum import Enumfrom functools import partialfrom typing import Any, Callable, Dict, List, Optional, Sequence, Typefrom fastapi.routing import APIRouterfrom .value_object import ValueObject__all__ = [    "Resource",    "get",    "post",    "put",    "patch",    "delete",    "APIVersion",    "Stability",    "v",    "clean_resources",]class Stability(str, Enum):    STABLE = "stable"    BETA = "beta"    ALPHA = "alpha"    @property    def description(self) -> str:        return DESCRIPTIONS[self]    def decrease(self) -> "Stability":        index = STABILITY_ORDER.index(self)        if index == 0:            raise ValueError(f"Cannot decrease stability of {self}")        return STABILITY_ORDER[index - 1]STABILITY_ORDER = [Stability.ALPHA, Stability.BETA, Stability.STABLE]DESCRIPTIONS = {    Stability.STABLE: "The stable API version.",    Stability.BETA: "Backwards incompatible changes will be announced beforehand.",    Stability.ALPHA: "May get backwards incompatible changes without warning.",}class APIVersion(ValueObject):    version: int    stability: Stability    @property    def prefix(self) -> str:        result = f"v{self.version}"        if self.stability is not Stability.STABLE:            result += f"-{self.stability.value}"        return result    @property    def description(self) -> str:        return self.stability.description    def decrease_stability(self) -> "APIVersion":        return APIVersion(version=self.version, stability=self.stability.decrease())def http_method(path: str, **route_options):    def wrapper(unbound_method: Callable[..., Any]):        setattr(            unbound_method,            "http_method",            (path, route_options),        )        return unbound_method    return wrapperdef v(version: int, stability: str = "stable") -> APIVersion:    return APIVersion(version=version, stability=Stability(stability))get = partial(http_method, methods=["GET"])post = partial(http_method, methods=["POST"])put = partial(http_method, methods=["PUT"])patch = partial(http_method, methods=["PATCH"])delete = partial(http_method, methods=["DELETE"])class OpenApiTag(ValueObject):    name: str    description: Optional[str]class Resource:    version: APIVersion    name: str    def __init_subclass__(cls, version: APIVersion, name: str = ""):        cls.version = version        cls.name = name        super().__init_subclass__()    @classmethod    def with_version(cls, version: APIVersion) -> Type["Resource"]:        class DynamicResource(cls, version=version, name=cls.name):  # type: ignore            pass        DynamicResource.__doc__ = cls.__doc__        return DynamicResource    def get_less_stable(self, resources: Dict[APIVersion, "Resource"]) -> "Resource":        """Fetch a less stable version of this resource from 'resources'        If it doesn't exist, create it dynamically.        """        less_stable_version = self.version.decrease_stability()        # Fetch the less stable resource; generate it if it does not exist        try:            less_stable_resource = resources[less_stable_version]        except KeyError:            less_stable_resource = self.__class__.with_version(less_stable_version)()        # Validate the less stable version        if less_stable_resource.__class__.__bases__ != (self.__class__,):            raise RuntimeError(                f"{less_stable_resource} should be a direct subclass of {self}"            )        return less_stable_resource    def _endpoints(self):        for attr_name in dir(self):            if attr_name.startswith("_"):                continue            endpoint = getattr(self, attr_name)            if not hasattr(endpoint, "http_method"):                continue            yield endpoint    def get_openapi_tag(self) -> OpenApiTag:        return OpenApiTag(            name=self.name,            description=self.__class__.__doc__,        )    def get_router(        self, version: APIVersion, responses: Optional[Dict[str, Dict[str, Any]]] = None    ) -> APIRouter:        assert version == self.version        router = APIRouter()        operation_ids = set()        for endpoint in self._endpoints():            path, route_options = endpoint.http_method            operation_id = endpoint.__name__            if operation_id in operation_ids:                raise RuntimeError(                    "Multiple operations {operation_id} configured in {self}"                )            operation_ids.add(operation_id)            # The 'name' is used for reverse lookups (request.path_for): include the            # version prefix so that we can uniquely refer to an operation.            name = version.prefix + "/" + endpoint.__name__            router.add_api_route(                path,                endpoint,                tags=[self.name],                operation_id=endpoint.__name__,                name=name,                responses=responses,                **route_options,            )        return routerdef clean_resources_same_name(resources: List[Resource]) -> List[Resource]:    dct = {x.version: x for x in resources}    if len(dct) != len(resources):        raise RuntimeError(            f"Resource with name {resources[0].name} "            f"is defined multiple times with the same version."        )    for stability in [Stability.STABLE, Stability.BETA]:        tmp_resources = {k: v for (k, v) in dct.items() if k.stability is stability}        for version, resource in tmp_resources.items():            dct[version.decrease_stability()] = resource.get_less_stable(dct)    return list(dct.values())def clean_resources(resources: Sequence[Resource]) -> List[Resource]:    """Ensure that resources are consistent:    - ordered by name    - (tag, version) combinations should be unique    - for stable resources, beta & alpha are autocreated if needed    - for beta resources, alpha is autocreated if needed    """    result = []    names = {x.name for x in resources}    for name in sorted(names):        result.extend(            clean_resources_same_name([x for x in resources if x.name == name])        )    return result
 |