import inspect import json import pathlib from typing import Annotated, Any, cast, get_args, get_origin import click import yaml from click.exceptions import Exit from fansifter_common.auth.types import Permission from fastapi import FastAPI from fastapi.routing import APIRoute from rich.console import Console from rich.table import Table from dmp.config import settings cli = click.Group(short_help="Perform API commands.", no_args_is_help=True) @cli.command("gen-spec") def gen_spec() -> None: """ Generate OpenAPI specification. """ from dmp.api.app import get_app app = get_app() dump_schema(app, filepath=settings.openapi_filepath) click.secho( ( f"OpenAPI specification was successfully " f"generated to `{settings.openapi_filepath}`", ), fg="green", ) @cli.command("check-spec") def check_spec() -> None: """ Check that service has the latest OpenAPI specification. """ from dmp.api.app import get_app app = get_app() if not check_schema(app, filepath=settings.openapi_filepath): click.secho( "The OpenAPI specification for the service does not match the actual one. " "Please, generate new one with `python manage.py api gen-spec`.", fg="red", ) raise Exit(1) click.secho("You have the latest OpenAPI specification generated.", fg="green") @cli.command("routes") @click.option("--prefix", default="") @click.option("--permission/--no-permission", "has_permission_flag", default=None) @click.option("--internal/--public", "has_internal_flag", default=None) def get_routes( prefix: str, has_permission_flag: bool | None, has_internal_flag: bool | None, ) -> None: """ Show API routes. """ from dmp.api.app import get_app app = get_app() table = Table(title="API routes", show_lines=True, expand=True) table.add_column("Method") table.add_column("Path") table.add_column("Handler") table.add_column("Permission") table.add_column("Is Internal?") for route in sorted(app.routes, key=lambda route: getattr(route, "path", "")): if not isinstance(route, APIRoute): continue if prefix and not route.path.startswith(prefix): continue path = route.path method = ", ".join(route.methods) handler_name = "" permission_name = "" is_internal = "internal" in route.tags handler = _get_handler(route) if handler: permission = getattr(handler, "permission", None) if isinstance(permission, Permission): permission_name = f"{permission.resource_type}.{permission.action}" handler_module = f"{handler.__module__.split('.handlers.')[0]}.handlers" handler_name = f"{handler_module}.{handler.__name__}" if has_permission_flag is not None and ( (has_permission_flag and not permission_name) or (not has_permission_flag and permission_name) ): continue if has_internal_flag is not None and ( (has_internal_flag and not is_internal) or (not has_internal_flag and is_internal) ): continue table.add_row( method, path, handler_name, permission_name, "Yes" if is_internal else "No", ) console = Console() console.print(table) def _get_handler(route: APIRoute) -> type[Any] | None: for _, parameter in inspect.signature(route.endpoint).parameters.items(): tp = parameter.annotation if get_origin(tp) is Annotated: handler_cls = get_args(tp)[0] else: handler_cls = tp if inspect.isclass(handler_cls) and hasattr(handler_cls, "handle"): return handler_cls return None def dump_schema(api: FastAPI, *, filepath: pathlib.Path) -> None: with filepath.open("w+") as fp: yaml.dump(json.loads(json.dumps(api.openapi())), stream=fp, sort_keys=False) def check_schema(api: FastAPI, *, filepath: pathlib.Path) -> bool: with filepath.open() as fp: return cast(dict[str, Any], yaml.safe_load(fp)) == api.openapi()