diff --git a/examples/BlankApp/main.py b/examples/BlankApp/main.py index 928e12b..ce7590f 100644 --- a/examples/BlankApp/main.py +++ b/examples/BlankApp/main.py @@ -1,4 +1,4 @@ -import uvicorn +from examples.BlankApp.src.app_module import app if __name__ == "__main__": - uvicorn.run("src.app_module:http_server", host="0.0.0.0", port=8010, reload=True) + app.adapter.run() diff --git a/examples/BlankApp/src/app_module.py b/examples/BlankApp/src/app_module.py index a84edbd..8d6d83f 100644 --- a/examples/BlankApp/src/app_module.py +++ b/examples/BlankApp/src/app_module.py @@ -25,5 +25,3 @@ class AppModule: version="1.0.0", debug=True, ) - -http_server: FastAPI = app.get_server() diff --git a/examples/BlankApp/src/user/user_controller.py b/examples/BlankApp/src/user/user_controller.py index b59d9f6..b8d62c9 100644 --- a/examples/BlankApp/src/user/user_controller.py +++ b/examples/BlankApp/src/user/user_controller.py @@ -1,18 +1,63 @@ -from nest.core import Controller, Depends, Get, Post +# app/controllers/user_controller.py -from .user_model import User +import uuid +from typing import Optional, Any from .user_service import UserService +from nest.core import Controller, Get, Post, Param, Query, Body, Form, File +from .user_model import UserCreateDTO, UserDTO -@Controller("user") +@Controller(prefix="/users", tag="Users") class UserController: - def __init__(self, service: UserService): - self.service = service + def __init__(self, user_service: UserService): + self.user_service = user_service + + @Get("/{user_id}") + def get_user_by_id(self, user_id: Param[uuid.UUID]) -> dict: + """ + Retrieve a user by their UUID. + """ + user = self.user_service.get_user_by_id(str(user_id)) + return {"user": user} @Get("/") - def get_user(self): - return self.service.get_user() + def list_users( + self, + page: Query[int] = 1, + limit: Query[int] = 50, + search: Optional[Query[str]] = None, + ) -> dict: + """ + List users with pagination and optional search. + """ + # Implement pagination and search logic here + return { + "message": f"Listing users on page={page}, limit={limit}, search={search}" + } @Post("/") - def add_user(self, user: User): - return self.service.add_user(user) + def create_user(self, user: Body[UserCreateDTO]) -> dict: + """ + Create a new user. + """ + user_data = self.user_service.add_user(user) + return {"message": "User created", "user": user_data} + + # + @Post("/{user_id}/upload-avatar") + def upload_avatar( + self, + user_id: Param[uuid.UUID], + file: File[bytes], + description: Optional[Form[str]] = None, + ) -> dict: + """ + Upload an avatar for a user. + """ + # avatar_url = self.user_service.upload_avatar(user_id, file, description) + print(f"Uploaded avatar for user {user_id}: {file}") + print(f"Description: {description}") + return { + "message": "Avatar uploaded", + "avatar_url": "http://example.com/avatar.jpg", + } diff --git a/examples/BlankApp/src/user/user_model.py b/examples/BlankApp/src/user/user_model.py index cee05ad..8043d84 100644 --- a/examples/BlankApp/src/user/user_model.py +++ b/examples/BlankApp/src/user/user_model.py @@ -1,5 +1,15 @@ +import uuid from pydantic import BaseModel -class User(BaseModel): +class UserDTO(BaseModel): + id: uuid.UUID name: str + email: str + age: int + + +class UserCreateDTO(BaseModel): + name: str + email: str + age: int diff --git a/examples/BlankApp/src/user/user_service.py b/examples/BlankApp/src/user/user_service.py index 90354ba..1803eb4 100644 --- a/examples/BlankApp/src/user/user_service.py +++ b/examples/BlankApp/src/user/user_service.py @@ -2,19 +2,23 @@ from nest.core.decorators import Injectable -from .user_model import User +from .user_model import UserDTO, UserCreateDTO @Injectable class UserService: def __init__(self): self.database = [] - time.sleep(5) - print("UserService initialized") - def get_user(self): + def get_user(self) -> list[UserDTO]: return self.database - def add_user(self, user: User): + def add_user(self, user: UserCreateDTO): self.database.append(user) return user + + def get_user_by_id(self, user_id: str) -> UserDTO: + return next((user for user in self.database if user.id == user_id), None) + + def log_access(self, user_id): + print(user_id, " Access app") diff --git a/nest/common/module.py b/nest/common/module.py index a877eab..267c401 100644 --- a/nest/common/module.py +++ b/nest/common/module.py @@ -26,7 +26,7 @@ def has(self, token: str): return True if self.get(token) is not None else False -class Module: +class NestModule: def __init__(self, metatype: Type[object], container): self._id = str(uuid.uuid4()) self._metatype = metatype diff --git a/nest/common/route_resolver.py b/nest/common/route_resolver.py deleted file mode 100644 index 9d545c4..0000000 --- a/nest/common/route_resolver.py +++ /dev/null @@ -1,16 +0,0 @@ -from fastapi import APIRouter, FastAPI - - -class RoutesResolver: - def __init__(self, container, app_ref: FastAPI): - self.container = container - self.app_ref = app_ref - - def register_routes(self): - for module in self.container.modules.values(): - for controller in module.controllers.values(): - self.register_route(controller) - - def register_route(self, controller): - router: APIRouter = controller.get_router() - self.app_ref.include_router(router) diff --git a/nest/core/__init__.py b/nest/core/__init__.py index 50a4cc8..9da4b60 100644 --- a/nest/core/__init__.py +++ b/nest/core/__init__.py @@ -1,5 +1,3 @@ -from fastapi import Depends - from nest.core.decorators import ( Controller, Delete, @@ -11,6 +9,17 @@ Post, Put, ) +from nest.core.protocols import ( + Param, + Query, + Header, + Body, + Cookie, + RequestProtocol, + ResponseProtocol, + File, + Form, +) from nest.core.pynest_application import PyNestApp from nest.core.pynest_container import PyNestContainer from nest.core.pynest_factory import PyNestFactory diff --git a/nest/core/adapters/__init__.py b/nest/core/adapters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/nest/core/adapters/click/__init__.py b/nest/core/adapters/click/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/nest/core/adapters/click/click_adapter.py b/nest/core/adapters/click/click_adapter.py new file mode 100644 index 0000000..e69de29 diff --git a/nest/core/adapters/fastapi/__init__.py b/nest/core/adapters/fastapi/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/nest/core/adapters/fastapi/fastapi_adapter.py b/nest/core/adapters/fastapi/fastapi_adapter.py new file mode 100644 index 0000000..3e12c29 --- /dev/null +++ b/nest/core/adapters/fastapi/fastapi_adapter.py @@ -0,0 +1,145 @@ +from typing import Any, Callable, List, Optional +import uvicorn +from fastapi import FastAPI, APIRouter +from fastapi.middleware import Middleware + +from nest.core.protocols import ( + WebFrameworkAdapterProtocol, + RouterProtocol, + Container, +) + +from nest.core.adapters.fastapi.utils import wrap_instance_method + + +class FastAPIRouterAdapter(RouterProtocol): + """ + An adapter for registering routes in FastAPI. + """ + + def __init__(self, base_path: str = "") -> None: + """ + Initialize with an optional base path. + """ + print("Initializing FastAPIRouterAdapter") + self._base_path = base_path + self._router = APIRouter(prefix=self._base_path) + + def add_route( + self, + path: str, + endpoint: Callable[..., Any], + methods: List[str], + *, + name: Optional[str] = None, + ) -> None: + """ + Register an HTTP route with FastAPI's APIRouter. + """ + self._router.add_api_route(path, endpoint, methods=methods, name=name) + + def get_router(self) -> APIRouter: + """ + Return the underlying FastAPI APIRouter. + """ + return self._router + + +############################################################################### +# FastAPI Adapter +############################################################################### + + +class FastAPIAdapter(WebFrameworkAdapterProtocol): + """ + A FastAPI-based implementation of WebFrameworkAdapterProtocol. + """ + + def __init__(self) -> None: + self._app: Optional[FastAPI] = None + self._router_adapter = FastAPIRouterAdapter() + self._middlewares: List[Middleware] = [] + self._initialized = False + + def create_app(self, **kwargs: Any) -> FastAPI: + """ + Create and configure the FastAPI application. + """ + print("Creating FastAPI app") + self._app = FastAPI(**kwargs) + self._app.include_router(self._router_adapter.get_router()) + # Add any pre-collected middlewares + for mw in self._middlewares: + self._app.add_middleware(mw.cls, **mw.options) + + self._initialized = True + return self._app + + def get_router(self) -> RouterProtocol: + """ + Return the RouterProtocol implementation. + """ + return self._router_adapter + + def add_middleware( + self, + middleware_cls: Any, + **options: Any, + ) -> None: + """ + Add middleware to the FastAPI application. + """ + if not self._app: + # Collect middlewares before app creation + self._middlewares.append(Middleware(middleware_cls, **options)) + else: + # Add middleware directly if app is already created + self._app.add_middleware(middleware_cls, **options) + + def run(self, host: str = "127.0.0.1", port: int = 8000, **kwargs) -> None: + """ + Run the FastAPI application using Uvicorn. + """ + if not self._initialized or not self._app: + raise RuntimeError("FastAPI app not created yet. Call create_app() first.") + + uvicorn.run(self._app, host=host, port=port, **kwargs) + + async def startup(self) -> None: + """ + Handle any startup tasks if necessary. + """ + if self._app: + await self._app.router.startup() + + async def shutdown(self) -> None: + """ + Handle any shutdown tasks if necessary. + """ + if self._app: + await self._app.router.shutdown() + + def register_routes(self, container: Container) -> None: + """ + Register multiple routes at once. + """ + for module in container.modules.values(): + for controller_cls in module.controllers.values(): + instance = container.get_instance(controller_cls) + + route_definitions = getattr(controller_cls, "__pynest_routes__", []) + for route_definition in route_definitions: + path = route_definition["path"] + method = route_definition["method"] + original_method = route_definition["endpoint"] + + final_endpoint = wrap_instance_method( + instance, controller_cls, original_method + ) + + self._router_adapter.add_route( + path=path, + endpoint=final_endpoint, + methods=[method], + name=f"{controller_cls.__name__}.{original_method.__name__}", + ) diff --git a/nest/core/adapters/fastapi/utils.py b/nest/core/adapters/fastapi/utils.py new file mode 100644 index 0000000..e586e2c --- /dev/null +++ b/nest/core/adapters/fastapi/utils.py @@ -0,0 +1,153 @@ +from typing import Callable, Annotated, get_origin, get_args, Optional, Any, Union + +from fastapi import ( + Path, + Query, + Header, + Body, + File, + UploadFile, + Response, + Request, + BackgroundTasks, + Form, + Cookie, + Depends, +) +from nest.core.protocols import ( + Param, + Query as QueryParam, + Header as HeaderParam, + Body as BodyParam, + Cookie as CookieParam, + File as FileParam, + Form as FormParam, + BackgroundTasks as BackgroundTasksParam, +) +import functools +import inspect +import typing + + +def _provide_bg_tasks(bg: BackgroundTasks) -> BackgroundTasks: + """ + A simple dependency function: FastAPI will inject + its `BackgroundTasks` object as 'bg' here, and we return it. + """ + return bg + + +def wrap_instance_method( + instance, + cls, + method: Callable, +) -> Callable: + """ + 1. Create a new plain function that calls `method(instance, ...)`. + 2. Rewrite its signature so that 'self' is removed, and Param/Query/Body become Annotated[...] for FastAPI. + 3. Return that new function, which you can pass to fastapi's router. + + This avoids "invalid method signature" by not rewriting the bound method in place. + """ + + # The unbound function object: + if hasattr(method, "__func__"): + # If 'method' is a bound method, get the actual function + unbound_func = method.__func__ + else: + # If it's already an unbound function, use it + unbound_func = method + + # Create a wrapper function that calls the unbound function with 'instance' as the first arg + @functools.wraps(unbound_func) + def wrapper(*args, **kwargs): + return unbound_func(instance, *args, **kwargs) + + # Now rewrite the wrapper's signature: + # - removing 'self' + # - converting Param/Query/Body to Annotated + new_wrapper = rewrite_signature_for_fastapi(wrapper) + return new_wrapper + + +def rewrite_signature_for_fastapi(func: Callable) -> Callable: + """ + Modify the function's signature: + - Remove 'self' if it's the first param + - Convert Param[T], QueryParam[T], HeaderParam[T], BodyParam[T], + CookieParam[T], FormParam[T], FileParam[T] into Annotated[InnerType, fastapi.Param(...)] + - Handle nested types like Optional and Union + - Leave special FastAPI types (Request, Response, BackgroundTasks, UploadFile) unchanged + """ + sig = inspect.signature(func) + old_params = list(sig.parameters.values()) + + # Remove 'self' if it's the first parameter + if old_params and old_params[0].name == "self": + old_params = old_params[1:] + + new_params = [] + for param in old_params: + new_annotation = transform_annotation(param.annotation) + if new_annotation: + new_params.append(param.replace(annotation=new_annotation)) + continue + + # Handle special FastAPI types by keeping them as-is + if param.annotation in (Request, Response, BackgroundTasks, UploadFile): + new_params.append(param) + continue + + # Leave unchanged + new_params.append(param) + + # Replace the function's signature with the new parameters + new_sig = sig.replace(parameters=new_params) + func.__signature__ = new_sig + return func + + +def transform_annotation(annotation: typing.Any) -> Optional[typing.Any]: + """ + Recursively transform the annotation by replacing custom marker classes + with FastAPI's Annotated types with appropriate parameters. + """ + origin = get_origin(annotation) + args = get_args(annotation) + + if origin is Annotated: + # Already annotated, no further transformation + return annotation + + if origin is Union: + # Handle Union types (e.g., Optional[X] which is Union[X, NoneType]) + transformed_args = tuple(transform_annotation(arg) for arg in args) + return Union[transformed_args] + + # Handle custom marker classes + if origin == Param: + inner_type = args[0] + return Annotated[inner_type, Path()] + elif origin == QueryParam: + inner_type = args[0] + return Annotated[inner_type, Query()] + elif origin == HeaderParam: + inner_type = args[0] + return Annotated[inner_type, Header()] + elif origin == BodyParam: + inner_type = args[0] + return Annotated[inner_type, Body()] + elif origin == CookieParam: + inner_type = args[0] + return Annotated[inner_type, Cookie()] + elif origin == FormParam: + inner_type = args[0] + return Annotated[inner_type, Form()] + elif origin == FileParam: + inner_type = args[0] + return Annotated[inner_type, File()] + if annotation is BackgroundTasksParam: # or if origin == BackgroundTasksParam + return BackgroundTasks + else: + # Not a custom marker, return None to indicate no transformation + return None diff --git a/nest/core/cli_factory.py b/nest/core/cli_factory.py index 99284be..fe3c04d 100644 --- a/nest/core/cli_factory.py +++ b/nest/core/cli_factory.py @@ -1,14 +1,14 @@ import asyncio import click +from typing import TypeVar from nest.core.pynest_container import PyNestContainer -from nest.core.pynest_factory import AbstractPyNestFactory, ModuleType +ModuleType = TypeVar("ModuleType") -class CLIAppFactory(AbstractPyNestFactory): - def __init__(self): - super().__init__() + +class CLIAppFactory: def create(self, app_module: ModuleType, **kwargs): container = PyNestContainer() diff --git a/nest/core/decorators/class_based_view.py b/nest/core/decorators/class_based_view.py deleted file mode 100644 index 18cf0b8..0000000 --- a/nest/core/decorators/class_based_view.py +++ /dev/null @@ -1,113 +0,0 @@ -""" -Credit: FastAPI-Utils -Source: https://github.com/dmontagu/fastapi-utils/blob/master/fastapi_utils/cbv.py -""" - -import inspect -from typing import ( - Any, - Callable, - ClassVar, - List, - Type, - TypeVar, - Union, - get_origin, - get_type_hints, -) - -from fastapi import APIRouter, Depends -from starlette.routing import Route, WebSocketRoute - -T = TypeVar("T") -K = TypeVar("K", bound=Callable[..., Any]) - -CBV_CLASS_KEY = "__cbv_class__" - - -def class_based_view(router: APIRouter, cls: Type[T]) -> Type[T]: - """ - Replaces any methods of the provided class `cls` that are endpoints of routes in `router` with updated - function calls that will properly inject an instance of `cls`. - """ - _init_cbv(cls) - cbv_router = APIRouter() - function_members = inspect.getmembers(cls, inspect.isfunction) - functions_set = set(func for _, func in function_members) - cbv_routes = [ - route - for route in router.routes - if isinstance(route, (Route, WebSocketRoute)) - and route.endpoint in functions_set - ] - for route in cbv_routes: - router.routes.remove(route) - _update_cbv_route_endpoint_signature(cls, route) - cbv_router.routes.append(route) - router.include_router(cbv_router) - return cls - - -def _init_cbv(cls: Type[Any]) -> None: - """ - Idempotently modifies the provided `cls`, performing the following modifications: - * The `__init__` function is updated to set any class-annotated dependencies as instance attributes - * The `__signature__` attribute is updated to indicate to FastAPI what arguments should be passed to the initializer - """ - if getattr(cls, CBV_CLASS_KEY, False): # pragma: no cover - return # Already initialized - old_init: Callable[..., Any] = cls.__init__ - old_signature = inspect.signature(old_init) - old_parameters = list(old_signature.parameters.values())[ - 1: - ] # drop `self` parameter - new_parameters = [ - x - for x in old_parameters - if x.kind - not in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD) - ] - dependency_names: List[str] = [] - for name, hint in get_type_hints(cls).items(): - if get_origin(hint) is ClassVar: - continue - parameter_kwargs = {"default": getattr(cls, name, Ellipsis)} - dependency_names.append(name) - new_parameters.append( - inspect.Parameter( - name=name, - kind=inspect.Parameter.KEYWORD_ONLY, - annotation=hint, - **parameter_kwargs, - ) - ) - new_signature = old_signature.replace(parameters=new_parameters) - - def new_init(self: Any, *args: Any, **kwargs: Any) -> None: - for dep_name in dependency_names: - dep_value = kwargs.pop(dep_name) - setattr(self, dep_name, dep_value) - old_init(self, *args, **kwargs) - - setattr(cls, "__signature__", new_signature) - setattr(cls, "__init__", new_init) - setattr(cls, CBV_CLASS_KEY, True) - - -def _update_cbv_route_endpoint_signature( - cls: Type[Any], route: Union[Route, WebSocketRoute] -) -> None: - """ - Fixes the endpoint signature for a cbv route to ensure FastAPI performs dependency injection properly. - """ - old_endpoint = route.endpoint - old_signature = inspect.signature(old_endpoint) - old_parameters: List[inspect.Parameter] = list(old_signature.parameters.values()) - old_first_parameter = old_parameters[0] - new_first_parameter = old_first_parameter.replace(default=Depends(cls)) - new_parameters = [new_first_parameter] + [ - parameter.replace(kind=inspect.Parameter.KEYWORD_ONLY) - for parameter in old_parameters[1:] - ] - new_signature = old_signature.replace(parameters=new_parameters) - setattr(route.endpoint, "__signature__", new_signature) \ No newline at end of file diff --git a/nest/core/decorators/cli/cli_decorators.py b/nest/core/decorators/cli/cli_decorators.py index 3d82011..802f3ed 100644 --- a/nest/core/decorators/cli/cli_decorators.py +++ b/nest/core/decorators/cli/cli_decorators.py @@ -2,7 +2,6 @@ import click -from nest.core import Controller from nest.core.decorators.utils import ( get_instance_variables, parse_dependencies, diff --git a/nest/core/decorators/controller.py b/nest/core/decorators/controller.py index 8073083..e929761 100644 --- a/nest/core/decorators/controller.py +++ b/nest/core/decorators/controller.py @@ -1,47 +1,45 @@ from typing import Optional, Type -from fastapi.routing import APIRouter - -from nest.core.decorators.class_based_view import class_based_view as ClassBasedView from nest.core.decorators.http_method import HTTPMethod from nest.core.decorators.utils import get_instance_variables, parse_dependencies def Controller(prefix: Optional[str] = None, tag: Optional[str] = None): """ - Decorator that turns a class into a controller, allowing you to define - routes using FastAPI decorators. + Decorator that marks a class as a controller, collecting route metadata + for future registration in the underlying framework. Args: prefix (str, optional): The prefix to use for all routes. - tag (str, optional): The tag to use for OpenAPI documentation. + tag (str, optional): The tag to use for grouping or doc generation. Returns: - class: The decorated class. + class: The decorated class (with route metadata added). """ - # Default route_prefix to tag_name if route_prefix is not provided route_prefix = process_prefix(prefix, tag) - def wrapper(cls: Type) -> Type[ClassBasedView]: - router = APIRouter(tags=[tag] if tag else None) - - # Process class dependencies + def wrapper(cls: Type) -> Type: + # 1. Process class-level dependencies process_dependencies(cls) - # Set instance variables + # 2. Set instance variables for any non-injected fields set_instance_variables(cls) - # Ensure the class has an __init__ method + # 3. Ensure the class has an __init__ method ensure_init_method(cls) - # Add routes to the router - add_routes(cls, router, route_prefix) + # 4. Gather route metadata + route_definitions = collect_route_definitions(cls, route_prefix) + + # 5. Store routes in class attribute for later usage + setattr(cls, "__pynest_routes__", route_definitions) - # Add get_router method to the class - cls.get_router = classmethod(lambda cls: router) + # (Optional) Store prefix / tag for doc generation + setattr(cls, "__pynest_prefix__", route_prefix) + setattr(cls, "__pynest_tag__", tag) - return ClassBasedView(router=router, cls=cls) + return cls return wrapper @@ -50,21 +48,20 @@ def process_prefix(route_prefix: Optional[str], tag_name: Optional[str]) -> str: """Process and format the prefix.""" if route_prefix is None: if tag_name is None: - return None + return "" else: route_prefix = tag_name if not route_prefix.startswith("/"): route_prefix = "/" + route_prefix - if route_prefix.endswith("/"): - route_prefix = route_prefix.rstrip("/") - + # Remove any trailing slash to keep consistent + route_prefix = route_prefix.rstrip("/") return route_prefix def process_dependencies(cls: Type) -> None: - """Parse and set dependencies for the class.""" + """Parse and set dependencies for the class (via your DI system).""" dependencies = parse_dependencies(cls) setattr(cls, "__dependencies__", dependencies) @@ -79,20 +76,33 @@ def set_instance_variables(cls: Type) -> None: def ensure_init_method(cls: Type) -> None: """Ensure the class has an __init__ method.""" if not hasattr(cls, "__init__"): - raise AttributeError("Class must have an __init__ method") + raise AttributeError(f"{cls.__name__} must have an __init__ method") + + # We do the same removal trick if needed try: delattr(cls, "__init__") except AttributeError: pass -def add_routes(cls: Type, router: APIRouter, route_prefix: str) -> None: - """Add routes from class methods to the router.""" +def collect_route_definitions(cls: Type, base_prefix: str): + """Scan class methods for HTTP method decorators and build route metadata.""" + route_definitions = [] for method_name, method_function in cls.__dict__.items(): if callable(method_function) and hasattr(method_function, "__http_method__"): validate_method_decorator(method_function, method_name) - configure_method_route(method_function, route_prefix) - add_route_to_router(router, method_function) + configure_method_route(method_function, base_prefix) + + route_info = { + "path": method_function.__route_path__, + "method": method_function.__http_method__.value, + "endpoint": method_function, + "kwargs": method_function.__kwargs__, + "status_code": getattr(method_function, "status_code", None), + "name": f"{cls.__name__}.{method_name}", + } + route_definitions.append(route_info) + return route_definitions def validate_method_decorator(method_function: callable, method_name: str) -> None: @@ -107,36 +117,15 @@ def validate_method_decorator(method_function: callable, method_name: str) -> No raise AssertionError(f"Invalid method {method_function.__http_method__}") -def configure_method_route(method_function: callable, route_prefix: str) -> None: - """Configure the route for the method.""" - if not method_function.__route_path__.startswith("/"): - method_function.__route_path__ = "/" + method_function.__route_path__ - - method_function.__route_path__ = ( - route_prefix + method_function.__route_path__ - if route_prefix - else method_function.__route_path__ - ) - - # remove trailing "/" fro __route_path__ - # it converts "/api/users/" to "/api/users" - if ( - method_function.__route_path__ != "/" - and method_function.__route_path__.endswith("/") - ): - method_function.__route_path__ = method_function.__route_path__.rstrip("/") - +def configure_method_route(method_function: callable, base_prefix: str) -> None: + """Configure the final route path by prepending base_prefix.""" + raw_path = method_function.__route_path__ -def add_route_to_router(router: APIRouter, method_function: callable) -> None: - """Add the configured route to the router.""" - route_kwargs = { - "path": method_function.__route_path__, - "endpoint": method_function, - "methods": [method_function.__http_method__.value], - **method_function.__kwargs__, - } + if not raw_path.startswith("/"): + raw_path = "/" + raw_path - if hasattr(method_function, "status_code"): - route_kwargs["status_code"] = method_function.status_code + # Combine prefix + path + full_path = f"{base_prefix}{raw_path}" + full_path = full_path.rstrip("/") if full_path != "/" else full_path - router.add_api_route(**route_kwargs) + method_function.__route_path__ = full_path diff --git a/nest/core/protocols.py b/nest/core/protocols.py new file mode 100644 index 0000000..ce18804 --- /dev/null +++ b/nest/core/protocols.py @@ -0,0 +1,457 @@ +# protocols.py + +from __future__ import annotations +from typing import ( + Any, + Callable, + Dict, + List, + Optional, + Protocol, + Type, + TypeVar, + Union, + runtime_checkable, + Generic, +) + +############################################################################### +# 1. REQUEST & RESPONSE +############################################################################### + + +@runtime_checkable +class RequestProtocol(Protocol): + """ + Abstract representation of an HTTP request. + """ + + @property + def method(self) -> str: + """ + HTTP method (GET, POST, PUT, DELETE, etc.). + """ + ... + + @property + def url(self) -> str: + """ + The full request URL (or path). + """ + ... + + @property + def headers(self) -> Dict[str, str]: + """ + A dictionary of header names to values. + """ + ... + + @property + def cookies(self) -> Dict[str, str]: + """ + A dictionary of cookie names to values. + """ + ... + + @property + def query_params(self) -> Dict[str, str]: + """ + A dictionary of query parameter names to values. + """ + ... + + @property + def path_params(self) -> Dict[str, Any]: + """ + A dictionary of path parameter names to values. + Usually extracted from the URL pattern. + """ + ... + + async def body(self) -> Union[bytes, str]: + """ + Return the raw request body (bytes or text). + """ + ... + + +@runtime_checkable +class ResponseProtocol(Protocol): + """ + Abstract representation of an HTTP response. + """ + + def set_status_code(self, status_code: int) -> None: + """ + Set the HTTP status code (e.g. 200, 404, etc.). + """ + ... + + def set_header(self, name: str, value: str) -> None: + """ + Set a single header on the response. + """ + ... + + def set_cookie(self, name: str, value: str, **options: Any) -> None: + """ + Set a cookie on the response. + 'options' might include expires, domain, secure, httponly, etc. + """ + ... + + def delete_cookie(self, name: str, **options: Any) -> None: + """ + Instruct the browser to delete a cookie (by setting an expired cookie). + """ + ... + + def set_body(self, content: Union[str, bytes]) -> None: + """ + Set the final body (string or bytes). + (You might add overloads for JSON or streaming in a real system.) + """ + ... + + def set_json(self, data: Any, status_code: Optional[int] = None) -> None: + """ + Encode data as JSON, set the body, and optionally set the status code. + """ + ... + + +############################################################################### +# 2. ROUTING & HTTP METHODS +############################################################################### + + +@runtime_checkable +class RouteDefinition(Protocol): + """ + Represents a single route definition: path, HTTP methods, etc. + """ + + path: str + http_methods: List[str] + endpoint: Callable[..., Any] + name: Optional[str] + + +@runtime_checkable +class RouterProtocol(Protocol): + """ + A protocol for registering routes, websocket endpoints, + or other specialized routes (SSE, GraphQL, etc.). + """ + + def add_route( + self, + path: str, + endpoint: Callable[..., Any], + methods: List[str], + *, + name: Optional[str] = None, + ) -> None: + """ + Register a normal HTTP endpoint at the given path with the given methods. + Example: GET/POST/PUT, etc. + """ + ... + + def add_websocket_route( + self, + path: str, + endpoint: Callable[..., Any], + *, + name: Optional[str] = None, + ) -> None: + """ + Register a WebSocket endpoint at the given path. + """ + ... + + +Container = TypeVar("Container") + + +@runtime_checkable +class FrameworkAdapterProtocol(Protocol): + + def create_app(self, **kwargs: Any) -> Any: + """ + Create and store the main web application object. + **kwargs** can pass parameters (e.g., title, debug, etc.). + Returns the native app object (like FastAPI instance). + """ + ... + + +@runtime_checkable +class WebFrameworkAdapterProtocol(Protocol): + """ + High-level interface for an HTTP framework adapter. + The PyNest system can call these methods to: + - create and manage the main application object + - get a RouterProtocol to register routes + - add middlewares + - run the server + - optionally handle startup/shutdown hooks, etc. + """ + + def create_app(self, **kwargs: Any) -> Any: + """ + Create and store the main web application object. + **kwargs** can pass parameters (e.g., title, debug, etc.). + Returns the native app object (like FastAPI instance). + """ + ... + + def get_router(self) -> RouterProtocol: + """ + Return a RouterProtocol for the main router (or a sub-router). + """ + ... + + def add_middleware( + self, + middleware_cls: Any, + **options: Any, + ) -> None: + """ + Add a middleware class to the application, with config options. + """ + ... + + def run(self, host: str = "127.0.0.1", port: int = 8000) -> None: + """ + Blockingly run the HTTP server on the given host/port. + In production, you might prefer to return an ASGI app + and let an external server (e.g., gunicorn) run it. + """ + ... + + async def startup(self) -> None: + """ + Optional: If the framework has an 'on_startup' event, run it. + """ + ... + + async def shutdown(self) -> None: + """ + Optional: If the framework has an 'on_shutdown' event, run it. + """ + ... + + def register_routes(self, container: Container) -> None: + """ + Register multiple routes at once. + """ + ... + + +class CLIAdapterProtocol(FrameworkAdapterProtocol): + """ + High-level interface for an CLI adapter. + The PyNest system can call these methods to: + - create and manage the main application object + - run the cli app + - register commands into the cli + """ + + def register_commands(self, container: Container) -> None: + """ + Register multiple routes at once. + """ + ... + + +############################################################################### +# 3. PARAMETER EXTRACTION (NEST-JS LIKE) +############################################################################### + +T = TypeVar("T") + + +class RequestAttribute: + """ + Marker/base class for typed request attributes + like Param, Query, Header, Cookie, etc. + """ + + ... + + +class Param(Generic[T], RequestAttribute): + """ + Usage: + def get_item(self, id: Param[int]): ... + """ + + pass + + +class Query(Generic[T], RequestAttribute): + """ + Usage: + def search(self, q: Query[str]): ... + """ + + pass + + +class Header(Generic[T], RequestAttribute): + """ + Usage: + def do_something(self, token: Header[str]): ... + """ + + pass + + +class Cookie(Generic[T], RequestAttribute): + """ + Usage: + def show_info(self, user_id: Cookie[str]): ... + """ + + pass + + +class Body(Generic[T], RequestAttribute): + """ + Usage: + def create_user(self, data: Body[UserDTO]): ... + """ + + pass + + +class Form(Generic[T], RequestAttribute): + """ + Usage: + def post_form(self, form_data: Form[LoginForm]): ... + """ + + pass + + +class File(Generic[T], RequestAttribute): + """ + Usage: + def upload(self, file: File[UploadedFile]): ... + """ + + pass + + +class RawRequest(RequestAttribute): + """ + Sometimes you just need the entire request object. + Usage: + def debug(self, req: RawRequest): ... + """ + + pass + + +class BackgroundTasks(RequestAttribute): + """ + Sometimes you just need the entire request object. + Usage: + def debug(self, req: RawRequest): ... + """ + + pass + + +############################################################################### +# 4. ERROR HANDLING +############################################################################### + + +@runtime_checkable +class HTTPExceptionProtocol(Protocol): + """ + A standardized interface for raising an HTTP exception or error + that can be recognized by the underlying framework. + """ + + status_code: int + detail: str + + def to_response(self) -> ResponseProtocol: + """ + Convert this exception into a protocol-level response + (or a native framework response). + """ + ... + + +@runtime_checkable +class ExceptionHandlerProtocol(Protocol): + """ + For registering custom exception handlers. + """ + + def handle_exception(self, exc: Exception) -> ResponseProtocol: + """ + Given an exception, return a response. + """ + ... + + +############################################################################### +# 5. MIDDLEWARE & FILTERS +############################################################################### + + +@runtime_checkable +class MiddlewareProtocol(Protocol): + """ + A protocol for middleware classes/functions + that can be added to the application. + """ + + def __call__(self, request: RequestProtocol, call_next: Callable) -> Any: + """ + Some frameworks pass 'call_next' to chain the next handler. + Others might do this differently. + This is just an abstract representation. + """ + ... + + +@runtime_checkable +class FilterProtocol(Protocol): + """ + A smaller, more focused pre/post processing "filter" + that can manipulate requests or responses. + Could be integrated as an alternative or layer on top of middleware. + """ + + def before_request(self, request: RequestProtocol) -> None: ... + + def after_request( + self, request: RequestProtocol, response: ResponseProtocol + ) -> None: ... + + +############################################################################### +# 6. SECURITY & AUTH +############################################################################### + + +@runtime_checkable +class AuthGuardProtocol(Protocol): + """ + Something that checks authentication or authorization + and possibly raises an HTTP exception if unauthorized. + """ + + def check(self, request: RequestProtocol) -> None: + """ + If the user is not authorized, raise an error (HTTPExceptionProtocol). + Otherwise, do nothing. + """ + ... diff --git a/nest/core/pynest_application.py b/nest/core/pynest_application.py index 22e2669..420ee72 100644 --- a/nest/core/pynest_application.py +++ b/nest/core/pynest_application.py @@ -1,64 +1,31 @@ -from typing import Any - -from fastapi import FastAPI +# nest/core/pynest_application.py -from nest.common.route_resolver import RoutesResolver -from nest.core.pynest_app_context import PyNestApplicationContext -from nest.core.pynest_container import PyNestContainer +from typing import Any +from nest.core.protocols import WebFrameworkAdapterProtocol +from nest.core.pynest_app_context import ( + PyNestApplicationContext, +) class PyNestApp(PyNestApplicationContext): - """ - PyNestApp is the main application class for the PyNest framework, - managing the container and HTTP server. - """ + def __init__(self, container, adapter: WebFrameworkAdapterProtocol): + super().__init__(container) + self.adapter = adapter + self._is_listening = False - _is_listening = False + # Register all routes + self.adapter.register_routes(self.container) - @property - def is_listening(self) -> bool: - return self._is_listening + # Create and configure the web application via the adapter + self.web_app = self.adapter.create_app() - def __init__(self, container: PyNestContainer, http_server: FastAPI): + def use_middleware(self, middleware_cls: type, **options: Any) -> "PyNestApp": """ - Initialize the PyNestApp with the given container and HTTP server. - - Args: - container (PyNestContainer): The PyNestContainer container instance. - http_server (FastAPI): The FastAPI server instance. - """ - self.container = container - self.http_server = http_server - super().__init__(self.container) - self.routes_resolver = RoutesResolver(self.container, self.http_server) - self.select_context_module() - self.register_routes() - - def use(self, middleware: type, **options: Any) -> "PyNestApp": + Add middleware to the application. """ - Add middleware to the FastAPI server. - - Args: - middleware (type): The middleware class. - **options (Any): Additional options for the middleware. - - Returns: - PyNestApp: The current instance of PyNestApp, allowing method chaining. - """ - self.http_server.add_middleware(middleware, **options) + self.adapter.add_middleware(middleware_cls, **options) return self - def get_server(self) -> FastAPI: - """ - Get the FastAPI server instance. - - Returns: - FastAPI: The FastAPI server instance. - """ - return self.http_server - - def register_routes(self): - """ - Register the routes using the RoutesResolver. - """ - self.routes_resolver.register_routes() + @property + def is_listening(self) -> bool: + return self._is_listening diff --git a/nest/core/pynest_container.py b/nest/core/pynest_container.py index 9e524f8..b101e03 100644 --- a/nest/core/pynest_container.py +++ b/nest/core/pynest_container.py @@ -11,7 +11,7 @@ UnknownModuleException, ) from nest.common.module import ( - Module, + NestModule, ModuleCompiler, ModuleFactory, ModulesContainer, @@ -95,7 +95,7 @@ def add_module(self, metaclass) -> dict: return {"module_ref": self.modules.get(token), "inserted": False} return {"module_ref": self.register_module(module_factory), "inserted": True} - def register_module(self, module_factory: ModuleFactory) -> Module: + def register_module(self, module_factory: ModuleFactory) -> NestModule: """ Register a module in the container. @@ -108,10 +108,10 @@ def register_module(self, module_factory: ModuleFactory) -> Module: for creating the module. Returns: - Module: The module reference that has been registered in the container. + NestModule: The module reference that has been registered in the container. """ - module_ref = Module(module_factory.type, self) + module_ref = NestModule(module_factory.type, self) module_ref.token = module_factory.token self._modules[module_factory.token] = module_ref @@ -140,7 +140,7 @@ def add_import(self, token: str): if not self.modules.has(token): return module_metadata = self._modules_metadata.get(token) - module_ref: Module = self.modules.get(token) + module_ref: NestModule = self.modules.get(token) imports_mod: List[Any] = module_metadata.get("imports") self.add_modules(imports_mod) module_ref.add_imports(imports_mod) @@ -158,7 +158,7 @@ def add_providers(self, providers: List[Any], module_token: str) -> None: def add_provider(self, token: str, provider): """Add a provider to a module.""" - module_ref: Module = self.modules[token] + module_ref: NestModule = self.modules[token] if not provider: raise CircularDependencyException(module_ref.metatype) @@ -200,7 +200,7 @@ def _add_controller(self, token: str, controller: TController) -> None: """Add a controller to a module.""" if not self.modules.has(token): raise UnknownModuleException() - module_ref: Module = self.modules[token] + module_ref: NestModule = self.modules[token] module_ref.add_controller(controller) if hasattr(controller, DEPENDENCIES): for provider_name, provider_type in getattr( @@ -228,5 +228,5 @@ def add_related_module(self, related_module, token: str) -> None: # UNUSED: This function is currently not used but retained for potential future use. # It retrieves a module from the container by its key. - def get_module_by_key(self, module_key: str) -> Module: + def get_module_by_key(self, module_key: str) -> NestModule: return self._modules[module_key] diff --git a/nest/core/pynest_factory.py b/nest/core/pynest_factory.py index 2d988ae..af499f6 100644 --- a/nest/core/pynest_factory.py +++ b/nest/core/pynest_factory.py @@ -1,49 +1,43 @@ -from abc import ABC, abstractmethod -from typing import Type, TypeVar - -from fastapi import FastAPI - +from typing import Type, TypeVar, Optional from nest.core.pynest_application import PyNestApp from nest.core.pynest_container import PyNestContainer +from nest.core.protocols import WebFrameworkAdapterProtocol ModuleType = TypeVar("ModuleType") -class AbstractPyNestFactory(ABC): - @abstractmethod - def create(self, main_module: Type[ModuleType], **kwargs): - raise NotImplementedError +def adapter_map(adapter: str) -> WebFrameworkAdapterProtocol: + if adapter == "fastapi": + from nest.core.adapters.fastapi.fastapi_adapter import FastAPIAdapter + + return FastAPIAdapter() + else: + raise ValueError(f"Unknown adapter: {adapter}") -class PyNestFactory(AbstractPyNestFactory): +class PyNestFactory: """Factory class for creating PyNest applications.""" @staticmethod - def create(main_module: Type[ModuleType], **kwargs) -> PyNestApp: + def create( + main_module: Type[ModuleType], adapter: Optional[str] = "fastapi", **kwargs + ) -> PyNestApp: """ - Create a PyNest application with the specified main module class. - - Args: - main_module (ModuleType): The main module for the PyNest application. - **kwargs: Additional keyword arguments for the FastAPI server. - - Returns: - PyNestApp: The created PyNest application. + Create a PyNest application with the specified main module class + and a chosen adapter (defaults to FastAPIAdapter if none given). """ + # Get the adapter instance + if adapter is None: + adapter = "fastapi" + + adapter = adapter_map(adapter) container = PyNestContainer() container.add_module(main_module) - http_server = PyNestFactory._create_server(**kwargs) - return PyNestApp(container, http_server) - @staticmethod - def _create_server(**kwargs) -> FastAPI: - """ - Create a FastAPI server. + # Create the PyNest application + app = PyNestApp(container=container, adapter=adapter) - Args: - **kwargs: Additional keyword arguments for the FastAPI server. + # Optionally add middlewares here before running + # app.use_middleware(SomeMiddlewareClass, optionA=123) - Returns: - FastAPI: The created FastAPI server. - """ - return FastAPI(**kwargs) + return app