diff --git a/.gitignore b/.gitignore index 3c8f313..85a5b87 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,12 @@ htmlcov/ # Miscellaneous .DS_Store + +# Local dev sandbox — not part of the package +test-app/ + +# Agent runtime cache/logs — local only +.a5c/ + +# Local planning notes — not part of the published docs +docs/plans/ diff --git a/docs/engine/fastapi_adapter.md b/docs/engine/fastapi_adapter.md new file mode 100644 index 0000000..0e4357d --- /dev/null +++ b/docs/engine/fastapi_adapter.md @@ -0,0 +1,146 @@ +# FastAPI Adapter + +The default and currently only engine. Wraps a FastAPI application and exposes +the `AbstractHttpAdapter` contract. + +## Construction + +Three forms, all equivalent in spirit but differing in explicitness: + +```python +from nest.core import PyNestFactory +from nest.engines.fastapi import FastAPIAdapter + +# 1. Implicit — PyNestFactory creates a FastAPIAdapter() with no kwargs. +app = PyNestFactory.create(AppModule) + +# 2. Kwarg passthrough — kwargs flow through to FastAPI(...). +app = PyNestFactory.create( + AppModule, + title="My API", + version="1.0.0", + docs_url="/docs", + redoc_url=None, +) + +# 3. Explicit adapter — full control, with the option to pre-build the FastAPI instance. +app = PyNestFactory.create( + AppModule, + adapter=FastAPIAdapter(title="My API", version="1.0.0"), +) + +# 4. Bring your own FastAPI instance +from fastapi import FastAPI +existing = FastAPI(title="Pre-existing", lifespan=my_lifespan) +app = PyNestFactory.create( + AppModule, + adapter=FastAPIAdapter(instance=existing), +) +``` + +Passing `adapter=` and additional kwargs in the same call raises `TypeError` — +configure the adapter directly instead. + +## Accessing the underlying FastAPI app + +For uvicorn, Hypercorn, or any ASGI runner: + +```python +app = PyNestFactory.create(AppModule) +asgi_app = app.get_http_server() # returns the FastAPI instance +``` + +The backward-compat shim `app.http_server` is still available but deprecated; +prefer `app.get_http_server()` or `app.adapter.get_http_server()`. + +## FastAPI-specific features in phase 1 + +Two PyNest features remain coupled to FastAPI in phase 1 and will only work +under the FastAPI engine until phase 2 lands: + +### 1. Guards with `security_scheme` + +The `BaseGuard.security_scheme` attribute integrates with FastAPI's +`fastapi.security.SecurityBase` system to populate the OpenAPI `securitySchemes` +section and render the "Authorize" button in Swagger UI. Other engines have +their own security primitives and will need adapter-level reimplementation. + +```python +from fastapi.security import APIKeyHeader +from nest.core.decorators.guards import BaseGuard + +class ApiKeyGuard(BaseGuard): + security_scheme = APIKeyHeader(name="X-API-Key") + + def can_activate(self, request, credentials=None) -> bool: + return credentials == "expected-key" +``` + +A basic guard without `security_scheme` (just `can_activate(request, credentials)`) +will be portable to other engines once their guard pipeline lands. + +### 2. WebSocket gateways + +`@WebSocketGateway` uses Starlette's `WebSocket` class via FastAPI's +`add_api_websocket_route`. Other engines will get their own WebSocket pipeline +later; until then, gateways register only under `FastAPIAdapter`. + +## Translating PyNest → FastAPI + +When `adapter.add_route(spec)` runs on the FastAPI adapter: + +| `RouteSpec` field | FastAPI translation | +|----------------------|-------------------------------------------| +| `method` + `path` | `add_api_route(path=..., methods=[...])` | +| `endpoint` | wrapped with `bind_params(...)` and `_wrap_with_filters(...)` | +| `params` | introspected from endpoint signature; each `ParamSpec` becomes a `Depends(...)` wrapping `Body/Query/Path/Header` | +| `guards` | each guard's `.as_dependency()` → `dependencies=[...]` | +| `filters` | endpoint wrapped to route exceptions through filters | +| `status_code` | `add_api_route(status_code=...)` | +| `tags` | `add_api_route(tags=[...])` | +| `extra` | merged into `add_api_route(**spec.extra)` | + +`extra` is the escape hatch for FastAPI-only kwargs that PyNest core doesn't +model (e.g. `response_model_exclude_none`, `deprecated`, `openapi_extra`). + +## What's not changed vs. 0.6 + +Everything user-facing in 0.6 still works: + +- All controller decorators (`@Get`, `@Post`, `@Put`, `@Patch`, `@Delete`, `@Head`, `@Options`) +- All param decorators (`@Body`, `@Query`, `@Param`, `@Headers`, `@Req`, `@Res`, `@Ip`, `@HostParam`) +- `createParamDecorator(factory)` for custom param sources +- All guards (`BaseGuard`, `@UseGuards`, `security_scheme`) +- All exception filters (`@Catch`, `@UseFilters`, `app.use_global_filters(...)`) +- Lifecycle hooks (`OnApplicationBootstrap`, `OnApplicationShutdown`, `OnModuleInit`, `OnModuleDestroy`) +- WebSocket gateways (`@WebSocketGateway`, `@SubscribeMessage`, `MessageBody`, `ConnectedSocket`) +- ORM/ODM providers +- `app.use(MiddlewareClass, **opts)`, `app.use_global_filters(...)`, `app.enable_shutdown_hooks()` + +## Common questions + +**Q: I have an existing FastAPI app I want to mount PyNest into. Can I?** + +Yes — pass it as `instance=`: + +```python +fastapi_app = FastAPI(...) # your existing app with its own routes +fastapi_app.include_router(legacy_router) + +app = PyNestFactory.create( + AppModule, + adapter=FastAPIAdapter(instance=fastapi_app), +) +# fastapi_app now has PyNest routes registered alongside legacy routes +``` + +**Q: How do I access the FastAPI dependency injection from PyNest controllers?** + +`from nest.http import Depends` re-exports `fastapi.Depends`. You can use both +PyNest's `@Body`/`@Query` and FastAPI's `Depends(...)` in the same handler. + +**Q: Does PyNest add overhead?** + +The translation happens once at startup (during route registration). At +runtime, each request is handled by FastAPI directly. There is no per-request +PyNest overhead beyond what FastAPI itself does. diff --git a/docs/engine/migration_0.7.md b/docs/engine/migration_0.7.md new file mode 100644 index 0000000..5ba4af7 --- /dev/null +++ b/docs/engine/migration_0.7.md @@ -0,0 +1,183 @@ +# Migrating to PyNest 0.7 + +PyNest 0.7 introduces the engine adapter architecture. **Every 0.6 application +continues to work without code changes.** This page documents what's new, +what's deprecated, and what's unchanged. + +## TL;DR + +```python +# 0.6 code — still works in 0.7 +from nest.core import PyNestFactory +app = PyNestFactory.create(AppModule, title="My API") +asgi = app.get_http_server() +``` + +No required changes. New optional features are additive. + +## What's new + +### 1. Explicit adapter parameter + +```python +from nest.engines.fastapi import FastAPIAdapter + +app = PyNestFactory.create( + AppModule, + adapter=FastAPIAdapter(title="My API", version="1.0.0"), +) +``` + +Useful when you want to: +- Pre-configure FastAPI with non-trivial settings +- Inject an existing FastAPI app: `FastAPIAdapter(instance=existing_app)` +- Prepare for swapping engines later + +### 2. `nest.http` import facade + +Recommended for new code: + +```python +# 0.7+ recommended +from nest.http import Request, Response, Depends, HTTPException +``` + +Under the FastAPI default these are identical to `from fastapi import …`. When +you switch to a non-FastAPI engine later, only the imports change. + +The old imports still work without warning in 0.7. + +### 3. `nest.engine.*` public API + +The contract layer is public and documented. You can: +- Build custom adapters by subclassing `AbstractHttpAdapter` +- Construct `RouteSpec` / `ParamSpec` instances for testing +- Use the `ExecutionContext` for custom param decorators + +### 4. Adapter-conformance test suite + +`tests/test_engine/conformance/` parametrizes every adapter test against +every registered engine. Adding a new engine = adding one line and making +the suite green. + +## What's deprecated (still works) + +The following are kept for 0.6 compatibility and will be removed in **1.0**. + +| Deprecated | Replacement | Reason | +|------------|-------------|--------| +| `app.http_server` (attribute) | `app.get_http_server()` / `app.adapter.get_http_server()` | Becomes ambiguous under non-FastAPI engines | +| `from fastapi import Request, Response, Depends` in PyNest code | `from nest.http import …` | Engine-neutral import path | +| `PyNestApp(container, fastapi_instance)` direct construction with a raw FastAPI | `PyNestApp(container, FastAPIAdapter(instance=fastapi_instance))` | Type uniformity | +| `RoutesResolver(container, fastapi_instance)` direct construction with a raw FastAPI | `RoutesResolver(container, FastAPIAdapter(instance=fastapi_instance))` | Same | + +Calling the deprecated forms continues to work — the constructor accepts +either an adapter or a raw FastAPI instance and wraps as needed. + +There are **no deprecation warnings emitted in 0.7.0**. Warnings start in 0.7.1 +so 0.7.0 itself is a quiet upgrade. + +## What's unchanged + +Everything user-facing: + +- All controller decorators (`@Get`/`@Post`/`@Put`/`@Patch`/`@Delete`/`@Head`/`@Options`) +- All param decorators (`@Body`/`@Query`/`@Param`/`@Headers`/`@Req`/`@Res`/`@Ip`/`@HostParam`) +- `createParamDecorator(factory)` and `ExecutionContext` +- `BaseGuard`, `@UseGuards`, `security_scheme` — full FastAPI security integration preserved +- `@Catch`, `@UseFilters`, `app.use_global_filters(...)` +- `OnApplicationBootstrap`, `OnApplicationShutdown`, `OnModuleInit`, `OnModuleDestroy` +- `@WebSocketGateway`, `@SubscribeMessage`, `MessageBody`, `ConnectedSocket` +- ORM / ODM provider system +- `@Module(imports=, controllers=, providers=, exports=)` +- All `@Injectable` semantics (singleton, scoped, etc.) +- CLI: `pynest generate application`, `pynest generate resource` +- `app.use(MiddlewareClass, **opts)` +- `app.enable_shutdown_hooks(signals=...)` +- `await app.close()` + +## What's NOT yet portable to non-FastAPI engines (phase 1 limitation) + +These work fine with the default FastAPI engine but are explicitly documented +as `engines.fastapi`-only until phase 2: + +1. **Guards with `security_scheme`** — uses `fastapi.security.SecurityBase` + directly. A guard that only implements `can_activate(request, credentials)` + (no `security_scheme`) will be portable. + +2. **WebSocket gateways** — uses Starlette's `WebSocket` class. Litestar has a + similar abstraction; Robyn has a different one. The neutral WebSocket + protocol comes in 0.8. + +If you stay on FastAPI, both work exactly as in 0.6. + +## Bug fixes in 0.7 (carried over from late-0.6 development) + +These were fixed during the 0.7 development cycle and apply automatically: + +1. **`from __future__ import annotations` in controllers** — previously broke + `@Body` Pydantic parsing because annotations were strings. Now resolved via + `typing.get_type_hints()` in both input parsing and response serialization. + +2. **Pipe `ValueError` propagation** — `ValueError`/`TypeError` raised from a + pipe's `transform()` now becomes `HTTPException(422)` instead of a 500 + that breaks the ASGI transport. + +## Concrete migration examples + +### Example 1: A standard app + +```python +# Before (0.6) — UNCHANGED in 0.7 +from nest.core import PyNestFactory +from src.app_module import AppModule + +app = PyNestFactory.create(AppModule, title="My API", version="1.0.0") +asgi_app = app.get_http_server() +``` + +No changes needed. The implicit FastAPIAdapter is used. + +### Example 2: Pre-built FastAPI instance + +```python +# Before (0.6) +from fastapi import FastAPI +custom = FastAPI(title="...", lifespan=my_lifespan) +# Old PyNest had no clean way to do this; people often patched .__init__ + +# After (0.7) — clean +from nest.engines.fastapi import FastAPIAdapter +app = PyNestFactory.create( + AppModule, + adapter=FastAPIAdapter(instance=custom), +) +``` + +### Example 3: Engine-neutral imports + +```python +# Old controller +from fastapi import Request + +@Controller("/users") +class UserController: + @Get("/{id}") + def show(self, id: int = Param("id"), request: Request = Req()): + return {"ip": request.client.host} + +# New controller (functionally identical, future-proof) +from nest.http import Request + +@Controller("/users") +class UserController: + @Get("/{id}") + def show(self, id: int = Param("id"), request: Request = Req()): + return {"ip": request.client.host} +``` + +## Got questions? + +- **Open an issue** in the [PyNest GitHub repo](https://github.com/PythonNest/PyNest). +- See [Engine Overview](overview.md) for architecture. +- See [FastAPI Adapter](fastapi_adapter.md) for engine-specific reference. diff --git a/docs/engine/overview.md b/docs/engine/overview.md new file mode 100644 index 0000000..846c183 --- /dev/null +++ b/docs/engine/overview.md @@ -0,0 +1,122 @@ +# Engine Architecture + +PyNest decouples its application API from the underlying HTTP framework. The +default engine is FastAPI; future versions add Litestar, Robyn, and Flask as +interchangeable engines under the same PyNest application code. + +This page explains the contract, the wiring, and how to choose an engine. + +## The mental model + +``` +┌────────────────────────────────────────────────────────────────┐ +│ User code (Controllers, Modules, Services, Guards, Filters) │ +├────────────────────────────────────────────────────────────────┤ +│ PyNest core (DI, routing, lifecycle, exception filters) │ ← engine-neutral +├────────────────────────────────────────────────────────────────┤ +│ AbstractHttpAdapter contract (nest.engine.http_adapter) │ ← the seam +├────────────────────────────────────────────────────────────────┤ +│ FastAPIAdapter / LitestarAdapter / RobynAdapter / … │ ← concrete engines +├────────────────────────────────────────────────────────────────┤ +│ FastAPI / Litestar / Robyn / Flask │ +└────────────────────────────────────────────────────────────────┘ +``` + +The seam is `AbstractHttpAdapter`. Every PyNest application talks to its HTTP +engine *only* through this contract — never directly to FastAPI. Swapping +engines is therefore an adapter swap, not an application rewrite. + +## The contract + +`AbstractHttpAdapter` has two layers, mirroring NestJS's +`AbstractHttpAdapter` pattern: + +1. **High-level: `add_route(spec: RouteSpec)`** — a single entry point that + carries everything about a route (method, path, endpoint, parameters, + guards, filters, status code, OpenAPI extras). Each adapter translates + this neutral `RouteSpec` into its framework's native route registration. + +2. **Low-level: request/response accessors** — `get_request_method(req)`, + `set_header(res, name, value)`, `reply(res, body, status)`, etc. Used for + the few places that need raw request/response access (exception filters, + custom param decorator factories). + +Why two layers? PyNest delegates validation and OpenAPI generation to the +underlying framework, so the adapter is one level higher than NestJS's +(which uses Express/Fastify as raw HTTP transports). The accessors stay +NestJS-style so request introspection works the same regardless of engine. + +## Engine support matrix + +| Feature | FastAPI | Litestar | Robyn | Flask | +|------------------------|---------|----------|--------|--------| +| HTTP routes | ✅ | planned | planned | planned | +| Param decorators | ✅ | planned | planned | planned | +| Middleware | ✅ | planned | planned | planned | +| Exception filters | ✅ | planned | planned | planned | +| Lifespan hooks | ✅ | planned | planned | planned | +| Guards (basic flow) | ✅ | planned | planned | planned | +| Guards (security_scheme + OpenAPI) | ✅ | n/a | n/a | n/a | +| WebSocket gateways | ✅ | planned | n/a | n/a | +| OpenAPI generation | native | native | native | manual | + +## Choosing an engine + +By default `PyNestFactory.create(AppModule)` instantiates a `FastAPIAdapter`. +Pass an explicit adapter when you want to override: + +```python +from nest.core import PyNestFactory +from nest.engines.fastapi import FastAPIAdapter + +# Implicit (default = FastAPI) +app = PyNestFactory.create(AppModule) + +# Explicit, with FastAPI kwargs +app = PyNestFactory.create( + AppModule, + adapter=FastAPIAdapter(title="My API", version="1.0.0", docs_url="/docs"), +) + +# Shorthand: forward kwargs through to the default FastAPI adapter +app = PyNestFactory.create(AppModule, title="My API", version="1.0.0") +``` + +When a second adapter ships: + +```python +from nest.engines.litestar import LitestarAdapter # phase 2 + +app = PyNestFactory.create( + AppModule, + adapter=LitestarAdapter(debug=True), +) +``` + +All controllers, modules, guards, and filters stay unchanged. + +## Where things live + +``` +nest/ +├── core/ # framework-neutral PyNest core +├── common/ # decorators, exceptions, route resolver +├── engine/ # contracts (the seam) +│ ├── http_adapter.py # AbstractHttpAdapter (abc.ABC) +│ ├── params.py # ParamSpec dataclass +│ ├── route_spec.py # RouteSpec dataclass +│ ├── execution_context.py # framework-neutral ExecutionContext +│ └── types.py # HttpMethod, Endpoint +├── engines/ # concrete adapters +│ └── fastapi/ +│ ├── adapter.py # FastAPIAdapter implementation +│ └── params.py # ParamSpec → FastAPI Depends/Body/Query/… +└── http/ # user-facing import facade + └── __init__.py # re-exports Request, Response, Depends, … +``` + +## See also + +- [FastAPI Adapter](fastapi_adapter.md) — the default engine reference. +- [Writing an Adapter](writing_an_adapter.md) — for building Litestar/Robyn/Flask adapters. +- [Migration to 0.7](migration_0.7.md) — upgrading from 0.6. diff --git a/docs/engine/writing_an_adapter.md b/docs/engine/writing_an_adapter.md new file mode 100644 index 0000000..22d2bd1 --- /dev/null +++ b/docs/engine/writing_an_adapter.md @@ -0,0 +1,196 @@ +# Writing a PyNest Engine Adapter + +For framework authors and contributors building a new engine (Litestar, Robyn, +Flask, …). The conformance test suite is the gate — make it green and your +adapter is mergeable. + +## The contract + +Subclass `AbstractHttpAdapter[TServer, TRequest, TResponse]` and implement +the 18 abstract methods. The base class provides `get_http_server()` and +`get_type()` for free. + +```python +from typing import Any, Callable, Optional +from nest.engine.http_adapter import AbstractHttpAdapter +from nest.engine.route_spec import RouteSpec + + +class MyEngineAdapter(AbstractHttpAdapter): + def __init__(self, instance=None, **engine_kwargs): + self._engine_kwargs = engine_kwargs + super().__init__(instance) + + # 1. Server lifecycle + def _create_instance(self): + ... # build the framework's app object + + async def close(self) -> None: + ... + + # 2. Route registration + def add_route(self, spec: RouteSpec) -> None: + ... # the heavyweight — translates RouteSpec into the framework's route + + def add_websocket_route(self, path: str, endpoint: Callable) -> None: + ... + + # 3. Middleware / CORS + def use(self, middleware: Any, **options: Any) -> None: + ... + + def enable_cors(self, **options: Any) -> None: + ... + + # 4. Lifecycle hooks + def register_startup_hook(self, fn: Callable[[], Any]) -> None: + ... + + def register_shutdown_hook(self, fn: Callable[[], Any]) -> None: + ... + + # 5. Exception handlers + def register_exception_handler(self, exc_type: type, handler: Callable) -> None: + ... + + # 6. Request accessors (NestJS-style) + def get_request_method(self, req): ... + def get_request_url(self, req): ... + def get_request_hostname(self, req) -> Optional[str]: ... + def get_request_headers(self, req) -> dict: ... + def get_request_client_ip(self, req) -> Optional[str]: ... + + # 7. Response writers + def reply(self, res, body, status_code=None): ... + def set_header(self, res, name, value) -> None: ... + def is_headers_sent(self, res) -> bool: ... + def redirect(self, res, url, status_code=302): ... +``` + +## Translating `RouteSpec` + +`RouteSpec` carries everything about a route in a framework-neutral way: + +```python +@dataclass(frozen=True) +class RouteSpec: + method: HttpMethod + path: str + endpoint: Callable + params: Tuple[ParamSpec, ...] # neutral param descriptors + guards: Tuple[Any, ...] # guard instances + filters: Tuple[Any, ...] # ExceptionFilter instances + status_code: Optional[int] + tags: Tuple[str, ...] + name: Optional[str] + summary: Optional[str] + description: Optional[str] + extra: Dict[str, Any] # adapter-specific kwargs +``` + +Your adapter's job: + +1. **Endpoint transformation.** If any `ParamSpec` defaults are present in the + endpoint signature, build a framework-flavoured wrapper that resolves each + param from the request and forwards the values. The FastAPI adapter does + this in `nest/engines/fastapi/params.py:bind_params`. For Litestar this + would translate to `Parameter(query=...)` / `Body()` / `Provide(...)`. + +2. **Guard translation.** Each guard exposes `.as_dependency()` returning the + framework's dependency primitive. Under FastAPI this is `Depends(...)`. + Under Litestar it would be `Provide(...)`. The framework runs each + dependency before the endpoint; if it raises 403, the route fails. + +3. **Filter wrapping.** If `spec.filters` is non-empty, wrap the endpoint so + exceptions route through `ExceptionFilter.catch(exc, host)`. The FastAPI + adapter's `_wrap_with_filters` is the reference implementation. + +4. **Native registration.** Call the framework's add-route call with the + transformed endpoint and the translated metadata. Merge `spec.extra` last + so user overrides win. + +## Translating `ParamSpec` + +The `ParamSpec.source` literal has 9 values: + +| Source | Meaning | FastAPI translation | +|------------|------------------------------------------------|----------------------------------| +| `body` | request body | `Body(default, alias=name, embed=name is not None)` | +| `query` | query parameter (named or full dict if `name=None`) | `Query(default, alias=...)` | +| `path` | path parameter | `Path(..., alias=...)` | +| `header` | HTTP header | `Header(default, alias=...)` | +| `request` | raw `Request` object | inject `Request` | +| `response` | raw `Response` object | inject `Response` | +| `ip` | client IP address | read from `request.client.host` | +| `host` | request hostname (or named host segment) | read from `request.url.hostname` | +| `custom` | user-supplied factory via `createParamDecorator` | wrap factory in `Depends(...)` | + +After resolving the raw value, apply `ParamSpec.pipes` in order (`pipe.transform(value)`), +then coerce to `ParamSpec.annotation` via `pydantic.TypeAdapter` (or your +framework's equivalent). + +## Conformance test suite + +Every adapter must pass `tests/test_engine/conformance/`. Add your adapter to +`REGISTERED_ADAPTERS` in `tests/test_engine/conformance/conftest.py`: + +```python +REGISTERED_ADAPTERS = [ + pytest.param(_fastapi_adapter, id="fastapi"), + pytest.param(_litestar_adapter, id="litestar"), # <— your new line +] +``` + +The suite covers: + +- `test_route_registration.py` — `add_route` for all HTTP methods, with path + params, status codes, and tags +- `test_param_binding.py` — body, query, header, path params (required and + with defaults) +- `test_request_accessors.py` — every `get_request_*` method against a real + request +- `test_middleware.py` — `adapter.use(middleware)` actually runs per request +- `test_exception_handlers.py` — `register_exception_handler` routes + exceptions to the registered handler +- `test_lifespan.py` — startup / shutdown hooks fire in order +- `test_cors.py` — preflight requests respect `enable_cors` settings +- `test_websocket.py` — `add_websocket_route` connects and echoes + +If your engine doesn't support a feature (e.g. Flask + WebSockets), mark the +test as `pytest.mark.skip` for your adapter via a parametrize-aware skip. + +## Recommended directory layout + +``` +nest/engines// +├── __init__.py # exports YourEngineAdapter +├── adapter.py # the AbstractHttpAdapter implementation +├── params.py # ParamSpec → engine-native param markers +├── filters.py # ExceptionFilter wrapping (optional) +└── lifespan.py # startup/shutdown helpers (optional) +``` + +Optional installation extra in `pyproject.toml`: + +```toml +[project.optional-dependencies] +litestar = ["litestar>=2.0,<3.0"] +``` + +So users can do `pip install pynest-api[litestar]` once their adapter ships. + +## Minimum-viable adapter checklist + +- [ ] All 18 abstract methods implemented +- [ ] Conformance suite registered and green +- [ ] Per-adapter unit tests for the translation layer (`params.py`) +- [ ] README section: "Using PyNest with ``" +- [ ] Coverage ≥ 90% on `nest/engines//` +- [ ] No `from fastapi import …` outside `nest/engines/fastapi/` + +## Reference + +The FastAPI adapter (`nest/engines/fastapi/`) is the canonical reference. Read +`adapter.py` (~250 lines) and `params.py` (~200 lines) end-to-end before +starting on a new engine — most of the same logic applies, just with +framework-specific primitives. diff --git a/mkdocs.yml b/mkdocs.yml index 202ee6d..f69b0cf 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -60,6 +60,11 @@ nav: - Guards: guards.md - Exception Filters: exception_filters.md - WebSockets: websockets.md + - Engine: + - Overview: engine/overview.md + - FastAPI Adapter: engine/fastapi_adapter.md + - Writing an Adapter: engine/writing_an_adapter.md + - Migration to 0.7: engine/migration_0.7.md - Dependency Injection: dependency_injection.md - Deployment: - Docker: docker.md diff --git a/nest/common/decorators.py b/nest/common/decorators.py index b2364cf..cb1a580 100644 --- a/nest/common/decorators.py +++ b/nest/common/decorators.py @@ -1,99 +1,78 @@ +""" +PyNest parameter decorators — engine-neutral. + +These factory functions produce ``ParamSpec`` instances that the engine adapter +(default ``FastAPIAdapter``) translates into its framework's native param +markers. Under the FastAPI adapter, the translation lives in +``nest/engines/fastapi/params.py``. + +Backward-compat aliases: +- ``ParamMetadata`` is an alias for ``ParamSpec`` +- ``ExecutionContext`` re-exports from ``nest.engine.execution_context`` +- ``has_param_decorators`` and ``wrap_param_decorators`` re-export the FastAPI + binding helpers so legacy callers continue to work. +""" from __future__ import annotations -import inspect -import keyword -from dataclasses import dataclass from typing import Any, Callable, Optional, Tuple -from fastapi import Body as FastAPIBody -from fastapi import Depends -from fastapi import Header as FastAPIHeader -from fastapi import Path as FastAPIPath -from fastapi import Query as FastAPIQuery -from fastapi import Request, Response -from pydantic import TypeAdapter +# Public re-exports (backward compatibility) +from nest.engine.execution_context import ( + ExecutionContext, + HttpExecutionContext, +) +from nest.engine.params import ParamSpec +# ParamMetadata used to be the FastAPI-aware dataclass; it now aliases ParamSpec. +ParamMetadata = ParamSpec -_MISSING = object() - -@dataclass(frozen=True) -class ParamMetadata: - source: str - name: Optional[str] = None - data: Any = None - factory: Optional[Callable[[Any, "ExecutionContext"], Any]] = None - pipes: Tuple[Any, ...] = () - default: Any = _MISSING - - -class HttpExecutionContext: - def __init__(self, request: Request, response: Optional[Response] = None): - self._request = request - self._response = response - - def get_request(self) -> Request: - return self._request - - def get_response(self) -> Optional[Response]: - return self._response - - -class ExecutionContext: - def __init__(self, request: Request, response: Optional[Response] = None): - self._request = request - self._response = response - - def switch_to_http(self) -> HttpExecutionContext: - return HttpExecutionContext(self._request, self._response) - - def get_type(self) -> str: - return "http" - - -def Body(key: Optional[str] = None, *pipes: Any, default: Any = _MISSING): +def Body(key: Optional[str] = None, *pipes: Any, default: Any = ...) -> ParamSpec: key, pipes = _normalize_name_and_pipes(key, pipes) - return ParamMetadata(source="body", name=key, pipes=pipes, default=default) + return ParamSpec(source="body", name=key, pipes=pipes, default=default) -def Param(name: Optional[str] = None, *pipes: Any, default: Any = _MISSING): +def Param(name: Optional[str] = None, *pipes: Any, default: Any = ...) -> ParamSpec: name, pipes = _normalize_name_and_pipes(name, pipes) - return ParamMetadata(source="param", name=name, pipes=pipes, default=default) + return ParamSpec(source="path", name=name, pipes=pipes, default=default) -def Query(name: Optional[str] = None, *pipes: Any, default: Any = _MISSING): +def Query(name: Optional[str] = None, *pipes: Any, default: Any = ...) -> ParamSpec: name, pipes = _normalize_name_and_pipes(name, pipes) - return ParamMetadata(source="query", name=name, pipes=pipes, default=default) + return ParamSpec(source="query", name=name, pipes=pipes, default=default) -def Headers(name: Optional[str] = None, *pipes: Any, default: Any = _MISSING): +def Headers(name: Optional[str] = None, *pipes: Any, default: Any = ...) -> ParamSpec: name, pipes = _normalize_name_and_pipes(name, pipes) - return ParamMetadata(source="headers", name=name, pipes=pipes, default=default) + return ParamSpec(source="header", name=name, pipes=pipes, default=default) -def Req(): - return ParamMetadata(source="request") +def Req() -> ParamSpec: + return ParamSpec(source="request") -def Res(): - return ParamMetadata(source="response") +def Res() -> ParamSpec: + return ParamSpec(source="response") -def Ip(*pipes: Any, default: Any = _MISSING): - return ParamMetadata(source="ip", pipes=pipes, default=default) +def Ip(*pipes: Any, default: Any = ...) -> ParamSpec: + return ParamSpec(source="ip", pipes=pipes, default=default) -def HostParam(name: Optional[str] = None, *pipes: Any, default: Any = _MISSING): +def HostParam(name: Optional[str] = None, *pipes: Any, default: Any = ...) -> ParamSpec: name, pipes = _normalize_name_and_pipes(name, pipes) - return ParamMetadata(source="host", name=name, pipes=pipes, default=default) + return ParamSpec(source="host", name=name, pipes=pipes, default=default) -def createParamDecorator(factory: Callable[[Any, ExecutionContext], Any]) -> Callable: +def createParamDecorator( + factory: Callable[[Any, ExecutionContext], Any], +) -> Callable: + """Build a reusable param decorator backed by a user-supplied factory.""" if not callable(factory): raise TypeError("createParamDecorator requires a callable factory") - def decorator(data: Any = None, *pipes: Any, default: Any = _MISSING): - return ParamMetadata( + def decorator(data: Any = None, *pipes: Any, default: Any = ...) -> ParamSpec: + return ParamSpec( source="custom", data=data, factory=factory, @@ -104,219 +83,24 @@ def decorator(data: Any = None, *pipes: Any, default: Any = _MISSING): return decorator -def has_param_decorators(endpoint: Callable) -> bool: - signature = inspect.signature(endpoint) - return any( - isinstance(parameter.default, ParamMetadata) - for parameter in signature.parameters.values() - ) - - -def wrap_param_decorators(endpoint: Callable) -> Callable: - signature = inspect.signature(endpoint) - wrapped_parameters = [] - - for parameter in signature.parameters.values(): - if isinstance(parameter.default, ParamMetadata): - dependency = _build_dependency(parameter) - wrapped_parameters.append( - parameter.replace( - annotation=inspect.Parameter.empty, - default=Depends(dependency), - ) - ) - else: - wrapped_parameters.append(parameter) - - wrapper_signature = signature.replace(parameters=wrapped_parameters) - handler_param_names = set(signature.parameters) - - async def wrapper(*args, **kwargs): - call_kwargs = {k: v for k, v in kwargs.items() if k in handler_param_names} - result = endpoint(*args, **call_kwargs) - if inspect.isawaitable(result): - return await result - return result - - wrapper.__name__ = getattr(endpoint, "__name__", "param_decorator_wrapper") - wrapper.__signature__ = wrapper_signature - return wrapper - - -def _build_dependency(parameter: inspect.Parameter) -> Callable: - metadata = parameter.default - annotation = parameter.annotation - - async def dependency(**kwargs): - value = await _resolve_value(metadata, kwargs) - value = await _apply_pipes(value, metadata.pipes) - return _coerce_value(value, annotation) - - dependency.__name__ = f"resolve_{parameter.name}_{metadata.source}" - dependency.__signature__ = _dependency_signature(parameter, metadata) - return dependency - - -async def _resolve_value(metadata: ParamMetadata, kwargs: dict) -> Any: - request = kwargs.get("request") - response = kwargs.get("response") - - if metadata.source == "request": - return request - if metadata.source == "response": - return response - if metadata.source == "param" and metadata.name is None: - return dict(request.path_params) - if metadata.source == "query" and metadata.name is None: - return dict(request.query_params) - if metadata.source == "headers" and metadata.name is None: - return dict(request.headers) - if metadata.source == "ip": - return request.client.host if request.client else None - if metadata.source == "host": - if metadata.name: - return request.path_params.get(metadata.name) - return request.url.hostname - if metadata.source == "custom": - context = ExecutionContext(request, response) - result = metadata.factory(metadata.data, context) - if inspect.isawaitable(result): - return await result - return result - - return _first_source_value(kwargs) - - -def _dependency_signature( - parameter: inspect.Parameter, - metadata: ParamMetadata, -) -> inspect.Signature: - source = metadata.source - annotation = parameter.annotation - - if source == "request": - return inspect.Signature( - parameters=[ - inspect.Parameter( - "request", - inspect.Parameter.KEYWORD_ONLY, - annotation=Request, - ) - ] - ) - - if source == "response": - return inspect.Signature( - parameters=[ - inspect.Parameter( - "response", - inspect.Parameter.KEYWORD_ONLY, - annotation=Response, - ) - ] - ) +# ── backward-compat re-exports ──────────────────────────────────────────────── +# These used to live here and contained FastAPI-specific logic. Now they're +# thin re-exports from the FastAPI adapter so legacy callers continue to work. - if source in {"param", "query", "headers"} and metadata.name is None: - return _request_only_signature() - - if source in {"ip", "host"}: - return _request_only_signature() - - if source == "custom": - return inspect.Signature( - parameters=[ - inspect.Parameter( - "request", - inspect.Parameter.KEYWORD_ONLY, - annotation=Request, - ), - inspect.Parameter( - "response", - inspect.Parameter.KEYWORD_ONLY, - annotation=Response, - ), - ] - ) - - if source == "body": - name = _source_parameter_name(metadata.name or parameter.name) - default = _default_value(metadata) - fastapi_default = FastAPIBody( - default, - alias=metadata.name, - embed=metadata.name is not None, - ) - return inspect.Signature( - parameters=[ - inspect.Parameter( - name, - inspect.Parameter.KEYWORD_ONLY, - annotation=annotation, - default=fastapi_default, - ) - ] - ) - - if source == "param": - name = _source_parameter_name(metadata.name or parameter.name) - alias = None if name == (metadata.name or parameter.name) else metadata.name - fastapi_default = FastAPIPath(..., alias=alias) - return inspect.Signature( - parameters=[ - inspect.Parameter( - name, - inspect.Parameter.KEYWORD_ONLY, - annotation=annotation, - default=fastapi_default, - ) - ] - ) - - if source == "query": - return _simple_source_signature( - parameter, - metadata, - lambda default, alias: FastAPIQuery(default, alias=alias), - ) - - if source == "headers": - return _simple_source_signature( - parameter, - metadata, - lambda default, alias: FastAPIHeader(default, alias=alias), - ) - - return inspect.Signature() +def has_param_decorators(endpoint: Callable) -> bool: + """Deprecated alias — use ``has_param_specs`` from ``nest.engines.fastapi.params``.""" + from nest.engines.fastapi.params import has_param_specs + return has_param_specs(endpoint) -def _simple_source_signature(parameter, metadata, marker_factory) -> inspect.Signature: - source_name = metadata.name or parameter.name - name = _source_parameter_name(source_name) - alias = source_name if name != source_name or metadata.name else None - fastapi_default = marker_factory(_default_value(metadata), alias) - return inspect.Signature( - parameters=[ - inspect.Parameter( - name, - inspect.Parameter.KEYWORD_ONLY, - annotation=parameter.annotation, - default=fastapi_default, - ) - ] - ) +def wrap_param_decorators(endpoint: Callable) -> Callable: + """Deprecated alias — use ``bind_params`` from ``nest.engines.fastapi.params``.""" + from nest.engines.fastapi.params import bind_params + return bind_params(endpoint) -def _request_only_signature() -> inspect.Signature: - return inspect.Signature( - parameters=[ - inspect.Parameter( - "request", - inspect.Parameter.KEYWORD_ONLY, - annotation=Request, - ) - ] - ) +# ── internals ──────────────────────────────────────────────────────────────── def _normalize_name_and_pipes(name: Any, pipes: Tuple[Any, ...]): @@ -325,42 +109,20 @@ def _normalize_name_and_pipes(name: Any, pipes: Tuple[Any, ...]): return name, pipes -def _source_parameter_name(name: str) -> str: - if name.isidentifier() and not keyword.iskeyword(name): - return name - return "value" - - -def _default_value(metadata: ParamMetadata) -> Any: - if metadata.default is _MISSING: - return ... - return metadata.default - - -def _first_source_value(kwargs: dict) -> Any: - for key, value in kwargs.items(): - if key not in {"request", "response"}: - return value - return None - - -async def _apply_pipes(value: Any, pipes: Tuple[Any, ...]) -> Any: - for pipe in pipes: - pipe_instance = pipe() if inspect.isclass(pipe) else pipe - if hasattr(pipe_instance, "transform"): - value = pipe_instance.transform(value) - elif callable(pipe_instance): - value = pipe_instance(value) - else: - raise TypeError("Pipe must be callable or expose a transform method") - if inspect.isawaitable(value): - value = await value - return value - - -def _coerce_value(value: Any, annotation: Any) -> Any: - if value is None or annotation in {inspect.Parameter.empty, Any}: - return value - if inspect.isclass(annotation) and isinstance(value, annotation): - return value - return TypeAdapter(annotation).validate_python(value) +__all__ = [ + "Body", + "Param", + "Query", + "Headers", + "Req", + "Res", + "Ip", + "HostParam", + "ExecutionContext", + "HttpExecutionContext", + "ParamMetadata", + "ParamSpec", + "createParamDecorator", + "has_param_decorators", + "wrap_param_decorators", +] diff --git a/nest/common/route_resolver.py b/nest/common/route_resolver.py index bc5801e..37e9914 100644 --- a/nest/common/route_resolver.py +++ b/nest/common/route_resolver.py @@ -1,10 +1,14 @@ from __future__ import annotations import inspect -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Union -from fastapi import APIRouter, FastAPI, Request -from nest.common.decorators import has_param_decorators, wrap_param_decorators +from fastapi import FastAPI + +from nest.engine.http_adapter import AbstractHttpAdapter +from nest.engine.params import ParamSpec +from nest.engine.route_spec import RouteSpec +from nest.engine.types import HttpMethod if TYPE_CHECKING: from nest.core.pynest_container import PyNestContainer @@ -13,12 +17,30 @@ class RoutesResolver: """ Walks the module graph, resolves controller and gateway instances from the - container, and registers their bound methods on the FastAPI app. + container, and registers their bound methods via the engine adapter. + + Builds a RouteSpec for each route and calls ``adapter.add_route(spec)``, + so the engine-specific translation (FastAPI Depends/Body/Query, Litestar + Parameter, etc.) lives entirely inside the adapter. """ - def __init__(self, container: "PyNestContainer", app_ref: FastAPI) -> None: + def __init__( + self, + container: "PyNestContainer", + adapter_or_server: Union[AbstractHttpAdapter, FastAPI], + ) -> None: self.container = container - self.app_ref = app_ref + # Backward-compat: accept either an adapter or a raw FastAPI instance. + if isinstance(adapter_or_server, AbstractHttpAdapter): + self.adapter = adapter_or_server + else: + from nest.engines.fastapi import FastAPIAdapter + self.adapter = FastAPIAdapter(instance=adapter_or_server) + + @property + def app_ref(self): + """Deprecated — kept for backward compatibility with code that accessed app_ref directly.""" + return self.adapter.get_http_server() def register_routes(self) -> None: seen_controllers: set = set() @@ -48,17 +70,13 @@ def _register_controller(self, controller_class: type) -> None: tag = getattr(controller_class, "__controller_tag__", None) prefix = getattr(controller_class, "__route_prefix__", None) or "" - router = APIRouter(tags=[tag] if tag else None) - for method_name, unbound in inspect.getmembers( controller_class, predicate=callable ): if not hasattr(unbound, "__http_method__"): continue bound = getattr(instance, method_name) - self._add_route(router, bound, unbound, controller_class, prefix) - - self.app_ref.include_router(router) + self._add_route(bound, unbound, controller_class, prefix, tag) def _register_gateway(self, gateway_class: type, gateway_instance: Any) -> None: from nest.websockets.gateway import NativeWebSocketGateway @@ -66,98 +84,62 @@ def _register_gateway(self, gateway_class: type, gateway_instance: Any) -> None: NativeWebSocketGateway( gateway=gateway_instance, metadata=getattr(gateway_class, "__websocket_gateway__"), - ).register(self.app_ref) + ).register(self.adapter.get_http_server()) def _add_route( self, - router: APIRouter, bound_method, original_method, cls: type, prefix: str, + tag: Any, ) -> None: from nest.core.decorators.controller import _collect_guards from nest.core.decorators.http_method import HTTPMethod path = getattr(original_method, "__route_path__", "/") http_method = getattr(original_method, "__http_method__", None) - extra_kwargs = getattr(original_method, "__kwargs__", {}) + extra_kwargs = dict(getattr(original_method, "__kwargs__", {})) if not isinstance(http_method, HTTPMethod): return full_path = _join_paths(prefix, path) + status_code = getattr(original_method, "status_code", None) - route_kwargs = { - "path": full_path, - "endpoint": bound_method, - "methods": [http_method.value], - **extra_kwargs, - } - - if has_param_decorators(bound_method): - route_kwargs["endpoint"] = wrap_param_decorators(bound_method) - - if hasattr(original_method, "status_code"): - route_kwargs["status_code"] = original_method.status_code - - guards = _collect_guards(cls, original_method) - if guards: - route_kwargs["dependencies"] = [g.as_dependency() for g in guards] - + guards = tuple(_collect_guards(cls, original_method)) route_filters = list(getattr(original_method, "__filters__", [])) controller_filters = list(getattr(cls, "__filters__", [])) - if route_filters or controller_filters: - route_kwargs["endpoint"] = _wrap_with_filters( - route_kwargs["endpoint"], route_filters + controller_filters - ) - - router.add_api_route(**route_kwargs) - - -def _wrap_with_filters(endpoint, filters) -> callable: - """Wrap a bound-method endpoint with exception filter logic.""" - from nest.common.exceptions import ArgumentsHost - - original_sig = inspect.signature(endpoint) - existing_params = list(original_sig.parameters.values()) - has_request = any(p.name == "request" for p in existing_params) - - if not has_request: - request_param = inspect.Parameter( - "request", - inspect.Parameter.KEYWORD_ONLY, - annotation=Request, + filters = tuple(route_filters + controller_filters) + + # Extract ParamSpecs from the bound method signature. + params = _extract_param_specs(bound_method) + + spec = RouteSpec( + method=HttpMethod(http_method.value), + path=full_path, + endpoint=bound_method, + params=params, + guards=guards, + filters=filters, + status_code=status_code, + tags=(tag,) if tag else (), + extra=extra_kwargs, ) - wrapper_sig = original_sig.replace(parameters=existing_params + [request_param]) - else: - wrapper_sig = original_sig - - orig_param_names = {p.name for p in existing_params} - - async def filter_wrapper(*args, **kwargs): - request = kwargs.get("request") - call_kwargs = {k: v for k, v in kwargs.items() if k in orig_param_names} - try: - result = endpoint(*args, **call_kwargs) - if inspect.isawaitable(result): - result = await result - return result - except Exception as exc: - host = ArgumentsHost(request=request) - for raw_filter in filters: - f = raw_filter() if isinstance(raw_filter, type) else raw_filter - caught = getattr(f, "__caught_exceptions__", ()) - if not caught or isinstance(exc, caught): - result = f.catch(exc, host) - if inspect.isawaitable(result): - return await result - return result - raise - - filter_wrapper.__name__ = getattr(endpoint, "__name__", "filter_wrapper") - filter_wrapper.__signature__ = wrapper_sig - return filter_wrapper + self.adapter.add_route(spec) + + +def _extract_param_specs(endpoint) -> tuple: + """Read ParamSpec defaults off the endpoint's signature into a tuple.""" + try: + signature = inspect.signature(endpoint) + except (TypeError, ValueError): + return () + specs = [] + for parameter in signature.parameters.values(): + if isinstance(parameter.default, ParamSpec): + specs.append(parameter.default) + return tuple(specs) def _join_paths(prefix: str, path: str) -> str: diff --git a/nest/core/decorators/http_method.py b/nest/core/decorators/http_method.py index 9067033..1091f97 100644 --- a/nest/core/decorators/http_method.py +++ b/nest/core/decorators/http_method.py @@ -1,15 +1,16 @@ -from enum import Enum -from typing import Any, Callable, List, Union +""" +HTTP method decorators (@Get, @Post, etc.) and HTTPMethod enum re-export. + +The canonical HttpMethod enum now lives in ``nest.engine.types``; the old +``HTTPMethod`` name is preserved here as a backward-compat alias so any +existing imports continue to work. +""" +from __future__ import annotations +from typing import Any, Callable, List, Union -class HTTPMethod(Enum): - GET = "GET" - POST = "POST" - DELETE = "DELETE" - PUT = "PUT" - PATCH = "PATCH" - HEAD = "HEAD" - OPTIONS = "OPTIONS" +# Re-export from canonical location for backward compatibility. +from nest.engine.types import HttpMethod as HTTPMethod # noqa: N814 def route(http_method: HTTPMethod, route_path: Union[str, List[str]] = "/", **kwargs): diff --git a/nest/core/pynest_application.py b/nest/core/pynest_application.py index 45f2295..338fea4 100644 --- a/nest/core/pynest_application.py +++ b/nest/core/pynest_application.py @@ -4,39 +4,63 @@ import inspect import signal as signal_module from contextlib import asynccontextmanager -from typing import Any, Iterable, Optional +from typing import Any, Iterable, Optional, Union from fastapi import FastAPI, Request from fastapi.responses import JSONResponse from nest.common.route_resolver import RoutesResolver from nest.core.pynest_container import PyNestContainer +from nest.engine.http_adapter import AbstractHttpAdapter class PyNestApp: """ - Main PyNest application. Wraps a built container and a FastAPI HTTP server. + Main PyNest application. Wraps a built container and an HTTP engine adapter. + + The adapter (default FastAPIAdapter) hides the underlying web framework. + Backward-compat shims are provided so existing code that referenced + ``app.http_server`` / ``app.get_server()`` still works. """ - def __init__(self, container: PyNestContainer, http_server: FastAPI) -> None: + def __init__( + self, + container: PyNestContainer, + adapter_or_server: Union[AbstractHttpAdapter, FastAPI], + ) -> None: self.container = container - self.http_server = http_server + + # Backward-compat: callers used to pass a FastAPI instance here. + # Wrap it in a FastAPIAdapter so the rest of the code is uniform. + if isinstance(adapter_or_server, AbstractHttpAdapter): + self.adapter = adapter_or_server + else: + from nest.engines.fastapi import FastAPIAdapter + self.adapter = FastAPIAdapter(instance=adapter_or_server) + self._closed = False self._closing = False self._install_lifespan_shutdown() - routes_resolver = RoutesResolver(self.container, self.http_server) + routes_resolver = RoutesResolver(self.container, self.adapter) routes_resolver.register_routes() + # ── public API ────────────────────────────────────────────────────── + + @property + def http_server(self) -> FastAPI: + """Deprecated — prefer ``app.adapter.get_http_server()``. Kept for 0.6 compatibility.""" + return self.adapter.get_http_server() + def get_server(self) -> FastAPI: - return self.http_server + return self.adapter.get_http_server() def get_http_server(self) -> FastAPI: """Alias for get_server() — kept for backward compatibility.""" - return self.http_server + return self.adapter.get_http_server() def use(self, middleware: type, **options: Any) -> "PyNestApp": - """Add ASGI middleware to the FastAPI server.""" - self.http_server.add_middleware(middleware, **options) + """Add ASGI middleware via the engine adapter.""" + self.adapter.use(middleware, **options) return self def enable_shutdown_hooks( @@ -65,22 +89,7 @@ async def close(self, signal: Optional[str] = None) -> None: self._closing = False def use_global_filters(self, *filters) -> "PyNestApp": - """Register one or more exception filters that apply to every route. - - Filters are tried in the order provided. Each filter must be an - instance of an ExceptionFilter subclass decorated with @Catch. - - Args: - *filters: ExceptionFilter instances to register globally. - - Returns: - PyNestApp: The current instance (allows method chaining). - - Example:: - - app = PyNestFactory.create(AppModule) - app.use_global_filters(AllExceptionsFilter()) - """ + """Register exception filters that apply to every route.""" for f in filters: caught = getattr(f, "__caught_exceptions__", None) if caught is None: @@ -92,6 +101,8 @@ def use_global_filters(self, *filters) -> "PyNestApp": self._register_global_handler(exc_type, f) return self + # ── internals ────────────────────────────────────────────────────── + def _register_global_handler(self, exc_type: type, filter_instance) -> None: async def handler(request: Request, exc: Exception): result = filter_instance.catch(exc, None) @@ -103,7 +114,7 @@ async def handler(request: Request, exc: Exception): ) return result - self.http_server.add_exception_handler(exc_type, handler) + self.adapter.register_exception_handler(exc_type, handler) def _make_signal_handler(self, shutdown_signal: signal_module.Signals): def handler(signum, frame): @@ -128,14 +139,18 @@ def _signal_name(signum) -> str: return str(signum) def _install_lifespan_shutdown(self) -> None: - original_lifespan_context = self.http_server.router.lifespan_context - - @asynccontextmanager - async def lifespan_context(app: FastAPI): - async with original_lifespan_context(app) as state: - try: - yield state - finally: - await self.close() - - self.http_server.router.lifespan_context = lifespan_context + """Patch the engine's lifespan so PyNestApp.close runs on app shutdown.""" + http_server = self.adapter.get_http_server() + # FastAPI-specific lifespan patching; future engines can override via adapter. + if hasattr(http_server, "router") and hasattr(http_server.router, "lifespan_context"): + original_lifespan_context = http_server.router.lifespan_context + + @asynccontextmanager + async def lifespan_context(app): + async with original_lifespan_context(app) as state: + try: + yield state + finally: + await self.close() + + http_server.router.lifespan_context = lifespan_context diff --git a/nest/core/pynest_factory.py b/nest/core/pynest_factory.py index a74b24f..5f5f810 100644 --- a/nest/core/pynest_factory.py +++ b/nest/core/pynest_factory.py @@ -3,12 +3,13 @@ import asyncio import threading from abc import ABC, abstractmethod -from typing import Type, TypeVar +from typing import Any, Optional, Type, TypeVar from fastapi import FastAPI from nest.core.pynest_application import PyNestApp from nest.core.pynest_container import PyNestContainer +from nest.engine.http_adapter import AbstractHttpAdapter ModuleType = TypeVar("ModuleType") @@ -23,26 +24,47 @@ class PyNestFactory(AbstractPyNestFactory): """Factory that creates a fully-wired PyNest application from a root module.""" @staticmethod - def create(main_module: Type[ModuleType], **kwargs) -> PyNestApp: + def create( + main_module: Type[ModuleType], + adapter: Optional[AbstractHttpAdapter] = None, + **kwargs: Any, + ) -> PyNestApp: """ Build and return a PyNestApp. 1. Creates a fresh container (NOT a singleton) 2. Adds the root module (recursively registers all imported modules) 3. Validates the dependency graph and builds the injector - 4. Creates the FastAPI HTTP server + 4. Initialises the HTTP engine via the supplied adapter (defaults to FastAPIAdapter) 5. Registers all routes via RoutesResolver + + Args: + main_module: The root PyNest module class. + adapter: Optional HTTP engine adapter. Defaults to FastAPIAdapter. + **kwargs: Forwarded to the default FastAPIAdapter (and from there to + FastAPI). When ``adapter=`` is passed explicitly, additional + kwargs are not allowed — configure the adapter directly instead. """ container = PyNestContainer() container.add_module(main_module) container.build() PyNestFactory._run_async(container.initialize_lifecycle()) - http_server = FastAPI(**kwargs) - return PyNestApp(container, http_server) + if adapter is None: + from nest.engines.fastapi import FastAPIAdapter + adapter = FastAPIAdapter(**kwargs) + elif kwargs: + raise TypeError( + "Cannot pass kwargs to PyNestFactory.create() when adapter= is " + "set. Pass FastAPI/Litestar/... kwargs to the adapter " + "constructor instead, e.g. FastAPIAdapter(title='My App')." + ) + + return PyNestApp(container, adapter) @staticmethod def _create_server(**kwargs) -> FastAPI: + """Deprecated: kept for backward compatibility with code that called this directly.""" return FastAPI(**kwargs) @staticmethod diff --git a/nest/engine/__init__.py b/nest/engine/__init__.py new file mode 100644 index 0000000..f37a7e0 --- /dev/null +++ b/nest/engine/__init__.py @@ -0,0 +1,17 @@ +# nest/engine/__init__.py +from nest.engine.execution_context import ExecutionContext, HttpExecutionContext +from nest.engine.http_adapter import AbstractHttpAdapter +from nest.engine.params import ParamSpec, VALID_SOURCES +from nest.engine.route_spec import RouteSpec +from nest.engine.types import Endpoint, HttpMethod + +__all__ = [ + "AbstractHttpAdapter", + "Endpoint", + "ExecutionContext", + "HttpExecutionContext", + "HttpMethod", + "ParamSpec", + "RouteSpec", + "VALID_SOURCES", +] diff --git a/nest/engine/execution_context.py b/nest/engine/execution_context.py new file mode 100644 index 0000000..b68ad88 --- /dev/null +++ b/nest/engine/execution_context.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from typing import Any, Optional + + +class HttpExecutionContext: + def __init__(self, request: Any, response: Optional[Any] = None) -> None: + self._request = request + self._response = response + + def get_request(self) -> Any: + return self._request + + def get_response(self) -> Optional[Any]: + return self._response + + +class ExecutionContext: + def __init__(self, request: Any, response: Optional[Any] = None) -> None: + self._request = request + self._response = response + + def switch_to_http(self) -> HttpExecutionContext: + return HttpExecutionContext(self._request, self._response) + + def get_type(self) -> str: + return "http" + + +__all__ = ["ExecutionContext", "HttpExecutionContext"] diff --git a/nest/engine/http_adapter.py b/nest/engine/http_adapter.py new file mode 100644 index 0000000..d743a06 --- /dev/null +++ b/nest/engine/http_adapter.py @@ -0,0 +1,115 @@ +# nest/engine/http_adapter.py +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any, Callable, Generic, Optional, TypeVar + +from nest.engine.route_spec import RouteSpec + +TServer = TypeVar("TServer") +TRequest = TypeVar("TRequest") +TResponse = TypeVar("TResponse") + + +class AbstractHttpAdapter(ABC, Generic[TServer, TRequest, TResponse]): + """ + Base class for PyNest HTTP engine adapters. NestJS-inspired. + + Wraps a web framework instance and exposes a neutral API for route + registration, middleware, lifecycle hooks, exception handling, and + request/response accessors. + + Key difference from NestJS AbstractHttpAdapter: add_route() accepts a + full RouteSpec (including param bindings, guards, filters) because PyNest + delegates validation and OpenAPI generation to the underlying framework + rather than reimplementing them in core. + """ + + def __init__(self, instance: Optional[Any] = None) -> None: + self._instance: Any = ( + instance if instance is not None else self._create_instance() + ) + + # ── concrete helpers ──────────────────────────────────────────────── + def get_http_server(self) -> Any: + return self._instance + + def get_type(self) -> str: + name = type(self).__name__ + if name.endswith("Adapter"): + name = name[: -len("Adapter")] + return name.lower() + + # ── server lifecycle ──────────────────────────────────────────────── + @abstractmethod + def _create_instance(self) -> Any: ... + + @abstractmethod + async def close(self) -> None: ... + + # ── route registration ────────────────────────────────────────────── + @abstractmethod + def add_route(self, spec: RouteSpec) -> None: ... + + @abstractmethod + def add_websocket_route( + self, path: str, endpoint: Callable[..., Any] + ) -> None: ... + + # ── middleware / cors ─────────────────────────────────────────────── + @abstractmethod + def use(self, middleware: Any, **options: Any) -> None: ... + + @abstractmethod + def enable_cors(self, **options: Any) -> None: ... + + # ── lifecycle hooks ───────────────────────────────────────────────── + @abstractmethod + def register_startup_hook(self, fn: Callable[[], Any]) -> None: ... + + @abstractmethod + def register_shutdown_hook(self, fn: Callable[[], Any]) -> None: ... + + # ── exception handling ────────────────────────────────────────────── + @abstractmethod + def register_exception_handler( + self, + exc_type: type, + handler: Callable[..., Any], + ) -> None: ... + + # ── NestJS-style request accessors ────────────────────────────────── + @abstractmethod + def get_request_method(self, req: Any) -> str: ... + + @abstractmethod + def get_request_url(self, req: Any) -> str: ... + + @abstractmethod + def get_request_hostname(self, req: Any) -> Optional[str]: ... + + @abstractmethod + def get_request_headers(self, req: Any) -> dict: ... + + @abstractmethod + def get_request_client_ip(self, req: Any) -> Optional[str]: ... + + # ── NestJS-style response writers ─────────────────────────────────── + @abstractmethod + def reply( + self, res: Any, body: Any, status_code: Optional[int] = None + ) -> Any: ... + + @abstractmethod + def set_header(self, res: Any, name: str, value: str) -> None: ... + + @abstractmethod + def is_headers_sent(self, res: Any) -> bool: ... + + @abstractmethod + def redirect( + self, res: Any, url: str, status_code: int = 302 + ) -> Any: ... + + +__all__ = ["AbstractHttpAdapter"] diff --git a/nest/engine/params.py b/nest/engine/params.py new file mode 100644 index 0000000..fb0292f --- /dev/null +++ b/nest/engine/params.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Callable, Optional, Tuple + +VALID_SOURCES = ( + "body", + "query", + "path", + "header", + "request", + "response", + "ip", + "host", + "custom", +) + + +@dataclass(frozen=True) +class ParamSpec: + source: str + name: Optional[str] = None + annotation: Any = None + default: Any = ... + pipes: Tuple[Any, ...] = () + factory: Optional[Callable[[Any, Any], Any]] = None + data: Any = None + + def __post_init__(self) -> None: + if self.source not in VALID_SOURCES: + raise ValueError( + f"Invalid param source {self.source!r}. " + f"Must be one of: {VALID_SOURCES}" + ) + + +__all__ = ["ParamSpec", "VALID_SOURCES"] diff --git a/nest/engine/route_spec.py b/nest/engine/route_spec.py new file mode 100644 index 0000000..7e6c9da --- /dev/null +++ b/nest/engine/route_spec.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Callable, Dict, Optional, Tuple + +from nest.engine.params import ParamSpec +from nest.engine.types import HttpMethod + + +@dataclass(frozen=True) +class RouteSpec: + method: HttpMethod + path: str + endpoint: Callable[..., Any] + params: Tuple[ParamSpec, ...] = () + guards: Tuple[Any, ...] = () + filters: Tuple[Any, ...] = () + status_code: Optional[int] = None + tags: Tuple[str, ...] = () + name: Optional[str] = None + summary: Optional[str] = None + description: Optional[str] = None + extra: Dict[str, Any] = field(default_factory=dict) + + +__all__ = ["RouteSpec"] diff --git a/nest/engine/types.py b/nest/engine/types.py new file mode 100644 index 0000000..3574eb0 --- /dev/null +++ b/nest/engine/types.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from enum import Enum +from typing import Any, Callable + + +class HttpMethod(Enum): + """HTTP methods supported by PyNest routes (engine-neutral).""" + GET = "GET" + POST = "POST" + DELETE = "DELETE" + PUT = "PUT" + PATCH = "PATCH" + HEAD = "HEAD" + OPTIONS = "OPTIONS" + + +# Endpoint is just a callable returning anything — used as a type alias. +Endpoint = Callable[..., Any] + + +__all__ = ["HttpMethod", "Endpoint"] diff --git a/nest/engines/__init__.py b/nest/engines/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/nest/engines/fastapi/__init__.py b/nest/engines/fastapi/__init__.py new file mode 100644 index 0000000..85611f0 --- /dev/null +++ b/nest/engines/fastapi/__init__.py @@ -0,0 +1,6 @@ +"""FastAPI engine adapter — the default PyNest HTTP engine.""" +from __future__ import annotations + +from nest.engines.fastapi.adapter import FastAPIAdapter + +__all__ = ["FastAPIAdapter"] diff --git a/nest/engines/fastapi/adapter.py b/nest/engines/fastapi/adapter.py new file mode 100644 index 0000000..8d11ede --- /dev/null +++ b/nest/engines/fastapi/adapter.py @@ -0,0 +1,228 @@ +""" +FastAPI implementation of AbstractHttpAdapter. + +The FastAPI adapter is one level higher than NestJS's because PyNest delegates +validation/OpenAPI to FastAPI rather than reimplementing them in core. So +add_route() accepts a full RouteSpec and translates it to FastAPI's native +add_api_route(...) call, applying: + - ParamSpec → FastAPI Body/Query/Path/Header + Depends (via params.bind_params) + - Guards → FastAPI dependencies via guard.as_dependency() + - Filters → ExceptionFilter wrapper that catches and routes to handler + +Request/response accessors are thin pass-throughs to Starlette's Request/Response. +""" +from __future__ import annotations + +import inspect +from typing import Any, Callable, Optional + +from fastapi import FastAPI, Request, Response +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse, RedirectResponse + +from nest.engine.http_adapter import AbstractHttpAdapter +from nest.engine.route_spec import RouteSpec +from nest.engines.fastapi.params import bind_params, has_param_specs + + +class FastAPIAdapter(AbstractHttpAdapter[FastAPI, Request, Response]): + """ + PyNest's default HTTP engine adapter. Wraps a FastAPI instance. + + Construct with no arguments to get a fresh FastAPI app, with kwargs to + forward to FastAPI(), or with ``instance=`` to wrap an existing FastAPI app. + """ + + def __init__( + self, + instance: Optional[FastAPI] = None, + **fastapi_kwargs: Any, + ) -> None: + # Store kwargs so _create_instance can use them if needed. + self._fastapi_kwargs = fastapi_kwargs + super().__init__(instance) + + # ── server lifecycle ──────────────────────────────────────────────── + + def _create_instance(self) -> FastAPI: + return FastAPI(**self._fastapi_kwargs) + + async def close(self) -> None: + # FastAPI handles cleanup via lifespan; nothing else to do here. + return None + + # ── route registration (central entry point) ──────────────────────── + + def add_route(self, spec: RouteSpec) -> None: + endpoint = spec.endpoint + if has_param_specs(endpoint): + endpoint = bind_params(endpoint) + + if spec.filters: + endpoint = _wrap_with_filters(endpoint, spec.filters) + + kwargs: dict = { + "path": spec.path, + "endpoint": endpoint, + "methods": [spec.method.value], + } + if spec.status_code is not None: + kwargs["status_code"] = spec.status_code + if spec.tags: + kwargs["tags"] = list(spec.tags) + if spec.name: + kwargs["name"] = spec.name + if spec.summary: + kwargs["summary"] = spec.summary + if spec.description: + kwargs["description"] = spec.description + if spec.guards: + kwargs["dependencies"] = [_guard_to_dependency(g) for g in spec.guards] + # Merge adapter-specific extras last so user overrides win. + for k, v in spec.extra.items(): + kwargs[k] = v + + self._instance.add_api_route(**kwargs) + + def add_websocket_route(self, path: str, endpoint: Callable[..., Any]) -> None: + self._instance.add_api_websocket_route(path, endpoint) + + # ── middleware / CORS ─────────────────────────────────────────────── + + def use(self, middleware: Any, **options: Any) -> None: + self._instance.add_middleware(middleware, **options) + + def enable_cors(self, **options: Any) -> None: + self._instance.add_middleware(CORSMiddleware, **options) + + # ── lifecycle hooks ───────────────────────────────────────────────── + + def register_startup_hook(self, fn: Callable[[], Any]) -> None: + self._instance.router.add_event_handler("startup", fn) + + def register_shutdown_hook(self, fn: Callable[[], Any]) -> None: + self._instance.router.add_event_handler("shutdown", fn) + + # ── exception handling ────────────────────────────────────────────── + + def register_exception_handler( + self, + exc_type: type, + handler: Callable[..., Any], + ) -> None: + self._instance.add_exception_handler(exc_type, handler) + + # ── NestJS-style request accessors ────────────────────────────────── + + def get_request_method(self, req: Request) -> str: + return req.method + + def get_request_url(self, req: Request) -> str: + return str(req.url) + + def get_request_hostname(self, req: Request) -> Optional[str]: + return req.url.hostname + + def get_request_headers(self, req: Request) -> dict: + return dict(req.headers) + + def get_request_client_ip(self, req: Request) -> Optional[str]: + return req.client.host if req.client else None + + # ── NestJS-style response writers ─────────────────────────────────── + + def reply( + self, + res: Response, + body: Any, + status_code: Optional[int] = None, + ) -> Any: + return JSONResponse(content=body, status_code=status_code or 200) + + def set_header(self, res: Response, name: str, value: str) -> None: + res.headers[name] = value + + def is_headers_sent(self, res: Response) -> bool: + # Starlette doesn't expose headers_sent directly. Return False as the + # safe default; callers that need stream-aware logic should query + # the underlying ASGI response themselves. + return False + + def redirect(self, res: Response, url: str, status_code: int = 302) -> Any: + return RedirectResponse(url=url, status_code=status_code) + + +# ── helpers ──────────────────────────────────────────────────────────────────── + + +def _guard_to_dependency(guard: Any) -> Any: + """Translate a guard (instance or class) into a FastAPI Depends(...).""" + # Guards that expose .as_dependency() (the canonical PyNest BaseGuard path). + if hasattr(guard, "as_dependency"): + return guard.as_dependency() + # Classes get instantiated for as_dependency lookup. + if inspect.isclass(guard) and hasattr(guard, "as_dependency"): + return guard.as_dependency() + # Fallback: raise a helpful error. + raise TypeError( + f"Guard {guard!r} must expose .as_dependency() — inherit from BaseGuard " + "or implement the method directly." + ) + + +def _wrap_with_filters(endpoint: Callable, filters: tuple) -> Callable: + """ + Wrap an endpoint so exceptions are routed through ExceptionFilter instances. + + Filters are tried in order; the first one whose @Catch types match handles + the exception. If no filter matches, the exception re-raises. + """ + import typing as _typing + + from nest.common.exceptions import ArgumentsHost + + original_sig = inspect.signature(endpoint) + existing_params = list(original_sig.parameters.values()) + has_request = any(p.name == "request" for p in existing_params) + + if not has_request: + request_param = inspect.Parameter( + "request", + inspect.Parameter.KEYWORD_ONLY, + annotation=Request, + ) + wrapper_sig = original_sig.replace( + parameters=existing_params + [request_param] + ) + else: + wrapper_sig = original_sig + + orig_param_names = {p.name for p in existing_params} + + async def filter_wrapper(*args, **kwargs): + request = kwargs.get("request") + call_kwargs = {k: v for k, v in kwargs.items() if k in orig_param_names} + try: + result = endpoint(*args, **call_kwargs) + if inspect.isawaitable(result): + result = await result + return result + except Exception as exc: + host = ArgumentsHost(request=request) + for raw_filter in filters: + f = raw_filter() if inspect.isclass(raw_filter) else raw_filter + caught = getattr(f, "__caught_exceptions__", ()) + if not caught or isinstance(exc, caught): + result = f.catch(exc, host) + if inspect.isawaitable(result): + return await result + return result + raise + + filter_wrapper.__name__ = getattr(endpoint, "__name__", "filter_wrapper") + filter_wrapper.__signature__ = wrapper_sig + try: + filter_wrapper.__annotations__ = _typing.get_type_hints(endpoint) + except Exception: + filter_wrapper.__annotations__ = getattr(endpoint, "__annotations__", {}) + return filter_wrapper diff --git a/nest/engines/fastapi/params.py b/nest/engines/fastapi/params.py new file mode 100644 index 0000000..bb792d7 --- /dev/null +++ b/nest/engines/fastapi/params.py @@ -0,0 +1,250 @@ +""" +Translation layer: ParamSpec → FastAPI param markers. + +For each ParamSpec attached to an endpoint's parameter defaults, build a +FastAPI-compatible dependency (Depends-wrapped) that resolves the value from +the request and applies pipes, then wrap the endpoint with a new signature +that FastAPI understands. + +This is the heaviest piece of the FastAPI adapter — it mirrors what NestJS's +adapter would do for Express/Fastify but at a higher level because FastAPI +itself owns body parsing, validation, and OpenAPI generation. +""" +from __future__ import annotations + +import inspect +import keyword +import typing +from typing import Any, Callable, Optional, Tuple + +from fastapi import Body as FABody +from fastapi import Depends +from fastapi import Header as FAHeader +from fastapi import HTTPException +from fastapi import Path as FAPath +from fastapi import Query as FAQuery +from fastapi import Request, Response +from pydantic import TypeAdapter + +from nest.engine.execution_context import ExecutionContext +from nest.engine.params import ParamSpec + + +def has_param_specs(endpoint: Callable) -> bool: + """True if any parameter of ``endpoint`` has a ParamSpec default.""" + signature = inspect.signature(endpoint) + return any( + isinstance(parameter.default, ParamSpec) + for parameter in signature.parameters.values() + ) + + +def bind_params(endpoint: Callable) -> Callable: + """ + Return a wrapper function that FastAPI can route to. + + The wrapper signature has FastAPI-flavored defaults (Body/Query/Path/Header + + Depends) so FastAPI's dependency injection populates them, then the wrapper + forwards resolved values to the original endpoint. + """ + signature = inspect.signature(endpoint) + + # Resolve string annotations from `from __future__ import annotations`. + try: + resolved_hints = typing.get_type_hints(endpoint) + except Exception: + resolved_hints = {} + + wrapped_parameters = [] + for parameter in signature.parameters.values(): + resolved_annotation = resolved_hints.get(parameter.name, parameter.annotation) + if resolved_annotation is not parameter.annotation: + parameter = parameter.replace(annotation=resolved_annotation) + + if isinstance(parameter.default, ParamSpec): + dependency = _build_dependency(parameter) + wrapped_parameters.append( + parameter.replace( + annotation=inspect.Parameter.empty, + default=Depends(dependency), + ) + ) + else: + wrapped_parameters.append(parameter) + + resolved_return = resolved_hints.get("return", signature.return_annotation) + wrapper_signature = signature.replace( + parameters=wrapped_parameters, + return_annotation=resolved_return, + ) + handler_param_names = set(signature.parameters) + + async def wrapper(*args, **kwargs): + call_kwargs = {k: v for k, v in kwargs.items() if k in handler_param_names} + result = endpoint(*args, **call_kwargs) + if inspect.isawaitable(result): + return await result + return result + + wrapper.__name__ = getattr(endpoint, "__name__", "param_decorator_wrapper") + wrapper.__signature__ = wrapper_signature + wrapper.__annotations__ = {k: v for k, v in resolved_hints.items()} + return wrapper + + +# ─── internals ──────────────────────────────────────────────────────────────── + + +def _build_dependency(parameter: inspect.Parameter) -> Callable: + spec: ParamSpec = parameter.default + annotation = parameter.annotation + + async def dependency(**kwargs): + value = await _resolve_value(spec, kwargs) + value = await _apply_pipes(value, spec.pipes) + return _coerce_value(value, annotation) + + dependency.__name__ = f"resolve_{parameter.name}_{spec.source}" + dependency.__signature__ = _dependency_signature(parameter, spec) + return dependency + + +async def _resolve_value(spec: ParamSpec, kwargs: dict) -> Any: + request = kwargs.get("request") + response = kwargs.get("response") + + if spec.source == "request": + return request + if spec.source == "response": + return response + if spec.source == "path" and spec.name is None: + return dict(request.path_params) + if spec.source == "query" and spec.name is None: + return dict(request.query_params) + if spec.source == "header" and spec.name is None: + return dict(request.headers) + if spec.source == "ip": + return request.client.host if request.client else None + if spec.source == "host": + if spec.name: + return request.path_params.get(spec.name) + return request.url.hostname + if spec.source == "custom": + context = ExecutionContext(request, response) + result = spec.factory(spec.data, context) + if inspect.isawaitable(result): + return await result + return result + + return _first_source_value(kwargs) + + +def _dependency_signature( + parameter: inspect.Parameter, spec: ParamSpec, +) -> inspect.Signature: + source = spec.source + annotation = parameter.annotation + + if source == "request": + return inspect.Signature(parameters=[inspect.Parameter( + "request", inspect.Parameter.KEYWORD_ONLY, annotation=Request)]) + + if source == "response": + return inspect.Signature(parameters=[inspect.Parameter( + "response", inspect.Parameter.KEYWORD_ONLY, annotation=Response)]) + + if source in {"path", "query", "header"} and spec.name is None: + return _request_only_signature() + if source in {"ip", "host"}: + return _request_only_signature() + + if source == "custom": + return inspect.Signature(parameters=[ + inspect.Parameter("request", inspect.Parameter.KEYWORD_ONLY, annotation=Request), + inspect.Parameter("response", inspect.Parameter.KEYWORD_ONLY, annotation=Response), + ]) + + if source == "body": + name = _source_parameter_name(spec.name or parameter.name) + default = _default_value(spec) + fa_default = FABody(default, alias=spec.name, embed=spec.name is not None) + return inspect.Signature(parameters=[inspect.Parameter( + name, inspect.Parameter.KEYWORD_ONLY, annotation=annotation, default=fa_default, + )]) + + if source == "path": + name = _source_parameter_name(spec.name or parameter.name) + alias = None if name == (spec.name or parameter.name) else spec.name + fa_default = FAPath(..., alias=alias) + return inspect.Signature(parameters=[inspect.Parameter( + name, inspect.Parameter.KEYWORD_ONLY, annotation=annotation, default=fa_default, + )]) + + if source == "query": + return _simple_source_signature(parameter, spec, + lambda d, alias: FAQuery(d, alias=alias)) + + if source == "header": + return _simple_source_signature(parameter, spec, + lambda d, alias: FAHeader(d, alias=alias)) + + return inspect.Signature() + + +def _simple_source_signature(parameter, spec, marker_factory) -> inspect.Signature: + source_name = spec.name or parameter.name + name = _source_parameter_name(source_name) + alias = source_name if name != source_name or spec.name else None + fa_default = marker_factory(_default_value(spec), alias) + return inspect.Signature(parameters=[inspect.Parameter( + name, inspect.Parameter.KEYWORD_ONLY, annotation=parameter.annotation, default=fa_default, + )]) + + +def _request_only_signature() -> inspect.Signature: + return inspect.Signature(parameters=[inspect.Parameter( + "request", inspect.Parameter.KEYWORD_ONLY, annotation=Request)]) + + +def _source_parameter_name(name: str) -> str: + if name and name.isidentifier() and not keyword.iskeyword(name): + return name + return "value" + + +def _default_value(spec: ParamSpec) -> Any: + if spec.default is ...: + return ... + return spec.default + + +def _first_source_value(kwargs: dict) -> Any: + for key, value in kwargs.items(): + if key not in {"request", "response"}: + return value + return None + + +async def _apply_pipes(value: Any, pipes: Tuple[Any, ...]) -> Any: + for pipe in pipes: + pipe_instance = pipe() if inspect.isclass(pipe) else pipe + try: + if hasattr(pipe_instance, "transform"): + value = pipe_instance.transform(value) + elif callable(pipe_instance): + value = pipe_instance(value) + else: + raise TypeError("Pipe must be callable or expose a transform method") + except (ValueError, TypeError) as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + if inspect.isawaitable(value): + value = await value + return value + + +def _coerce_value(value: Any, annotation: Any) -> Any: + if value is None or annotation in {inspect.Parameter.empty, Any}: + return value + if inspect.isclass(annotation) and isinstance(value, annotation): + return value + return TypeAdapter(annotation).validate_python(value) diff --git a/nest/http/__init__.py b/nest/http/__init__.py new file mode 100644 index 0000000..216cceb --- /dev/null +++ b/nest/http/__init__.py @@ -0,0 +1,17 @@ +""" +User-facing HTTP import facade. + +Under the default FastAPI engine these are direct re-exports of the +corresponding fastapi symbols. When a second engine adapter is introduced, +this module will resolve based on the active adapter instead. + +Recommended usage: + from nest.http import Request, Response, Depends + +FastAPI imports still work and are not deprecated in 0.7.x. +""" +from __future__ import annotations + +from fastapi import Depends, HTTPException, Request, Response + +__all__ = ["Depends", "HTTPException", "Request", "Response"] diff --git a/pyproject.toml b/pyproject.toml index af6005f..d1cca67 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -116,3 +116,4 @@ exclude = [ "/*venv*" ] ignore_missing_imports = true + diff --git a/tests/test_engine/__init__.py b/tests/test_engine/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_engine/conformance/__init__.py b/tests/test_engine/conformance/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_engine/conformance/conftest.py b/tests/test_engine/conformance/conftest.py new file mode 100644 index 0000000..ecd9932 --- /dev/null +++ b/tests/test_engine/conformance/conftest.py @@ -0,0 +1,25 @@ +""" +Adapter conformance fixture. + +Add new adapters to REGISTERED_ADAPTERS when they land. +Every test in tests/test_engine/conformance/ runs against each adapter. +""" +from __future__ import annotations + +import pytest + + +def _fastapi_adapter(): + from nest.engines.fastapi import FastAPIAdapter + return FastAPIAdapter() + + +REGISTERED_ADAPTERS = [ + pytest.param(_fastapi_adapter, id="fastapi"), + # pytest.param(_litestar_adapter, id="litestar"), # phase 2 +] + + +@pytest.fixture(params=REGISTERED_ADAPTERS) +def adapter(request): + return request.param() diff --git a/tests/test_engine/conformance/test_cors.py b/tests/test_engine/conformance/test_cors.py new file mode 100644 index 0000000..125aa7a --- /dev/null +++ b/tests/test_engine/conformance/test_cors.py @@ -0,0 +1,28 @@ +"""Conformance tests: CORS enablement via adapter.enable_cors().""" +from __future__ import annotations + +import pytest +from httpx import AsyncClient, ASGITransport + +from nest.engine.route_spec import RouteSpec +from nest.engine.types import HttpMethod + + +@pytest.mark.asyncio +async def test_cors_preflight(adapter): + async def handler(): + return {"ok": True} + + adapter.enable_cors( + allow_origins=["https://example.com"], + allow_methods=["GET", "POST"], + allow_headers=["*"], + ) + adapter.add_route(RouteSpec(method=HttpMethod.GET, path="/c", endpoint=handler)) + + async with AsyncClient( + transport=ASGITransport(adapter.get_http_server()), base_url="http://t" + ) as c: + r = await c.get("/c", headers={"Origin": "https://example.com"}) + assert r.status_code == 200 + assert r.headers.get("access-control-allow-origin") == "https://example.com" diff --git a/tests/test_engine/conformance/test_exception_handlers.py b/tests/test_engine/conformance/test_exception_handlers.py new file mode 100644 index 0000000..20bb0bd --- /dev/null +++ b/tests/test_engine/conformance/test_exception_handlers.py @@ -0,0 +1,33 @@ +"""Conformance tests: exception handler registration.""" +from __future__ import annotations + +import pytest +from httpx import AsyncClient, ASGITransport + +from nest.engine.route_spec import RouteSpec +from nest.engine.types import HttpMethod + + +class BoomException(Exception): + pass + + +@pytest.mark.asyncio +async def test_exception_handler_invoked(adapter): + async def handler(): + raise BoomException("kaboom") + + from fastapi.responses import JSONResponse + + async def boom_handler(request, exc): + return JSONResponse(status_code=418, content={"detail": str(exc)}) + + adapter.register_exception_handler(BoomException, boom_handler) + adapter.add_route(RouteSpec(method=HttpMethod.GET, path="/boom", endpoint=handler)) + + async with AsyncClient( + transport=ASGITransport(adapter.get_http_server()), base_url="http://t" + ) as c: + r = await c.get("/boom") + assert r.status_code == 418 + assert r.json() == {"detail": "kaboom"} diff --git a/tests/test_engine/conformance/test_lifespan.py b/tests/test_engine/conformance/test_lifespan.py new file mode 100644 index 0000000..56a8c65 --- /dev/null +++ b/tests/test_engine/conformance/test_lifespan.py @@ -0,0 +1,49 @@ +"""Conformance tests: startup/shutdown lifecycle hooks.""" +from __future__ import annotations + +import pytest +from httpx import AsyncClient, ASGITransport + +from nest.engine.route_spec import RouteSpec +from nest.engine.types import HttpMethod + + +@pytest.mark.asyncio +async def test_startup_and_shutdown_hooks_fire(adapter): + events: list[str] = [] + + async def on_startup(): + events.append("startup") + + async def on_shutdown(): + events.append("shutdown") + + adapter.register_startup_hook(on_startup) + adapter.register_shutdown_hook(on_shutdown) + + async def handler(): + events.append("request") + return {"ok": True} + + adapter.add_route(RouteSpec(method=HttpMethod.GET, path="/l", endpoint=handler)) + + app = adapter.get_http_server() + await app.router.startup() # ASGITransport doesn't auto-run lifespan + async with AsyncClient(transport=ASGITransport(app), base_url="http://t") as c: + await c.get("/l") + await app.router.shutdown() + + assert events == ["startup", "request", "shutdown"] + + +@pytest.mark.asyncio +async def test_sync_startup_hook(adapter): + fired = [] + + def on_startup_sync(): + fired.append(True) + + adapter.register_startup_hook(on_startup_sync) + app = adapter.get_http_server() + await app.router.startup() + assert fired == [True] diff --git a/tests/test_engine/conformance/test_middleware.py b/tests/test_engine/conformance/test_middleware.py new file mode 100644 index 0000000..4b9248d --- /dev/null +++ b/tests/test_engine/conformance/test_middleware.py @@ -0,0 +1,32 @@ +"""Conformance tests: middleware registration via adapter.use().""" +from __future__ import annotations + +import pytest +from httpx import AsyncClient, ASGITransport +from starlette.middleware.base import BaseHTTPMiddleware + +from nest.engine.route_spec import RouteSpec +from nest.engine.types import HttpMethod + + +class StampingMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request, call_next): + response = await call_next(request) + response.headers["X-Stamped-By"] = "test-middleware" + return response + + +@pytest.mark.asyncio +async def test_middleware_runs_per_request(adapter): + async def handler(): + return {"ok": True} + + adapter.use(StampingMiddleware) + adapter.add_route(RouteSpec(method=HttpMethod.GET, path="/m", endpoint=handler)) + + async with AsyncClient( + transport=ASGITransport(adapter.get_http_server()), base_url="http://t" + ) as c: + r = await c.get("/m") + assert r.status_code == 200 + assert r.headers.get("X-Stamped-By") == "test-middleware" diff --git a/tests/test_engine/conformance/test_param_binding.py b/tests/test_engine/conformance/test_param_binding.py new file mode 100644 index 0000000..403c10c --- /dev/null +++ b/tests/test_engine/conformance/test_param_binding.py @@ -0,0 +1,91 @@ +"""Conformance tests: ParamSpec → engine-native param binding.""" +from __future__ import annotations + +import pytest +from httpx import AsyncClient, ASGITransport + +from nest.engine.params import ParamSpec +from nest.engine.route_spec import RouteSpec +from nest.engine.types import HttpMethod + + +@pytest.mark.asyncio +async def test_query_param_required(adapter): + async def handler(name: str = ParamSpec(source="query", name="name")): + return {"name": name} + + adapter.add_route(RouteSpec( + method=HttpMethod.GET, path="/q", endpoint=handler, + params=(ParamSpec(source="query", name="name", annotation=str),), + )) + async with AsyncClient( + transport=ASGITransport(adapter.get_http_server()), base_url="http://t" + ) as c: + r = await c.get("/q", params={"name": "ada"}) + assert r.status_code == 200 and r.json() == {"name": "ada"} + r2 = await c.get("/q") # missing required + assert r2.status_code == 422 + + +@pytest.mark.asyncio +async def test_query_param_with_default(adapter): + async def handler(page: int = ParamSpec(source="query", name="page", default=1)): + return {"page": page} + + adapter.add_route(RouteSpec( + method=HttpMethod.GET, path="/qd", endpoint=handler, + params=(ParamSpec(source="query", name="page", annotation=int, default=1),), + )) + async with AsyncClient( + transport=ASGITransport(adapter.get_http_server()), base_url="http://t" + ) as c: + assert (await c.get("/qd")).json() == {"page": 1} + assert (await c.get("/qd", params={"page": 5})).json() == {"page": 5} + + +@pytest.mark.asyncio +async def test_header_param(adapter): + async def handler(auth: str = ParamSpec(source="header", name="Authorization")): + return {"auth": auth} + + adapter.add_route(RouteSpec( + method=HttpMethod.GET, path="/h", endpoint=handler, + params=(ParamSpec(source="header", name="Authorization", annotation=str),), + )) + async with AsyncClient( + transport=ASGITransport(adapter.get_http_server()), base_url="http://t" + ) as c: + r = await c.get("/h", headers={"Authorization": "Bearer xyz"}) + assert r.json() == {"auth": "Bearer xyz"} + + +@pytest.mark.asyncio +async def test_path_param(adapter): + async def handler(item_id: int = ParamSpec(source="path", name="item_id")): + return {"id": item_id} + + adapter.add_route(RouteSpec( + method=HttpMethod.GET, path="/p/{item_id}", endpoint=handler, + params=(ParamSpec(source="path", name="item_id", annotation=int),), + )) + async with AsyncClient( + transport=ASGITransport(adapter.get_http_server()), base_url="http://t" + ) as c: + r = await c.get("/p/99") + assert r.json() == {"id": 99} + + +@pytest.mark.asyncio +async def test_body_param(adapter): + async def handler(body: dict = ParamSpec(source="body")): + return {"echo": body} + + adapter.add_route(RouteSpec( + method=HttpMethod.POST, path="/b", endpoint=handler, + params=(ParamSpec(source="body", annotation=dict),), + )) + async with AsyncClient( + transport=ASGITransport(adapter.get_http_server()), base_url="http://t" + ) as c: + r = await c.post("/b", json={"a": 1, "b": "two"}) + assert r.json() == {"echo": {"a": 1, "b": "two"}} diff --git a/tests/test_engine/conformance/test_request_accessors.py b/tests/test_engine/conformance/test_request_accessors.py new file mode 100644 index 0000000..7d48bf3 --- /dev/null +++ b/tests/test_engine/conformance/test_request_accessors.py @@ -0,0 +1,38 @@ +"""Conformance tests: NestJS-style request accessor methods.""" +from __future__ import annotations + +import pytest +from httpx import AsyncClient, ASGITransport + +from nest.engine.params import ParamSpec +from nest.engine.route_spec import RouteSpec +from nest.engine.types import HttpMethod + + +@pytest.mark.asyncio +async def test_request_accessors(adapter): + captured = {} + + async def handler(request=ParamSpec(source="request")): + captured["method"] = adapter.get_request_method(request) + captured["url"] = adapter.get_request_url(request) + captured["hostname"] = adapter.get_request_hostname(request) + captured["headers"] = adapter.get_request_headers(request) + captured["ip"] = adapter.get_request_client_ip(request) + return {"ok": True} + + adapter.add_route(RouteSpec( + method=HttpMethod.GET, path="/probe", endpoint=handler, + params=(ParamSpec(source="request"),), + )) + async with AsyncClient( + transport=ASGITransport(adapter.get_http_server()), base_url="http://t" + ) as c: + r = await c.get("/probe", headers={"x-custom": "val"}) + assert r.status_code == 200 + assert captured["method"] == "GET" + assert "/probe" in captured["url"] + assert captured["hostname"] == "t" + assert captured["headers"].get("x-custom") == "val" + # IP is None or a string under ASGITransport + assert captured["ip"] is None or isinstance(captured["ip"], str) diff --git a/tests/test_engine/conformance/test_route_registration.py b/tests/test_engine/conformance/test_route_registration.py new file mode 100644 index 0000000..49b7367 --- /dev/null +++ b/tests/test_engine/conformance/test_route_registration.py @@ -0,0 +1,107 @@ +"""Conformance tests: route registration via adapter.add_route().""" +from __future__ import annotations + +import pytest +from httpx import AsyncClient, ASGITransport + +from nest.engine.params import ParamSpec +from nest.engine.route_spec import RouteSpec +from nest.engine.types import HttpMethod + + +@pytest.mark.asyncio +async def test_add_route_get_no_params(adapter): + async def handler(): + return {"hello": "world"} + + adapter.add_route(RouteSpec(method=HttpMethod.GET, path="/hello", endpoint=handler)) + async with AsyncClient( + transport=ASGITransport(adapter.get_http_server()), base_url="http://t" + ) as c: + r = await c.get("/hello") + assert r.status_code == 200 + assert r.json() == {"hello": "world"} + + +@pytest.mark.asyncio +async def test_add_route_with_path_param(adapter): + async def handler(item_id: int = ParamSpec(source="path", name="item_id")): + return {"id": item_id} + + adapter.add_route( + RouteSpec( + method=HttpMethod.GET, + path="/items/{item_id}", + endpoint=handler, + params=(ParamSpec(source="path", name="item_id", annotation=int),), + ) + ) + async with AsyncClient( + transport=ASGITransport(adapter.get_http_server()), base_url="http://t" + ) as c: + r = await c.get("/items/42") + assert r.status_code == 200 + assert r.json() == {"id": 42} + + +@pytest.mark.asyncio +async def test_add_route_post_returns_201_with_status_code(adapter): + async def handler(): + return {"created": True} + + adapter.add_route( + RouteSpec( + method=HttpMethod.POST, + path="/things", + endpoint=handler, + status_code=201, + ) + ) + async with AsyncClient( + transport=ASGITransport(adapter.get_http_server()), base_url="http://t" + ) as c: + r = await c.post("/things") + assert r.status_code == 201 + + +@pytest.mark.asyncio +async def test_add_route_tags_appear_in_openapi(adapter): + async def handler(): + return {} + + adapter.add_route( + RouteSpec( + method=HttpMethod.GET, + path="/tagged", + endpoint=handler, + tags=("custom-tag",), + ) + ) + async with AsyncClient( + transport=ASGITransport(adapter.get_http_server()), base_url="http://t" + ) as c: + r = await c.get("/openapi.json") + spec = r.json() + op = spec["paths"]["/tagged"]["get"] + assert "custom-tag" in op.get("tags", []) + + +@pytest.mark.asyncio +async def test_add_route_all_http_methods(adapter): + for method in [HttpMethod.GET, HttpMethod.POST, HttpMethod.PUT, + HttpMethod.PATCH, HttpMethod.DELETE]: + async def handler(m=method): + return {"method": m.value} + + adapter.add_route( + RouteSpec(method=method, path=f"/m{method.value.lower()}", endpoint=handler) + ) + + async with AsyncClient( + transport=ASGITransport(adapter.get_http_server()), base_url="http://t" + ) as c: + assert (await c.get("/mget")).status_code == 200 + assert (await c.post("/mpost")).status_code == 200 + assert (await c.put("/mput")).status_code == 200 + assert (await c.patch("/mpatch")).status_code == 200 + assert (await c.delete("/mdelete")).status_code == 200 diff --git a/tests/test_engine/conformance/test_websocket.py b/tests/test_engine/conformance/test_websocket.py new file mode 100644 index 0000000..d48125a --- /dev/null +++ b/tests/test_engine/conformance/test_websocket.py @@ -0,0 +1,21 @@ +"""Conformance tests: WebSocket route registration.""" +from __future__ import annotations + +import pytest +from fastapi import WebSocket +from starlette.testclient import TestClient + + +def test_websocket_route_registers_and_handles_connection(adapter): + async def ws_endpoint(websocket: WebSocket): + await websocket.accept() + data = await websocket.receive_text() + await websocket.send_text(f"echo:{data}") + await websocket.close() + + adapter.add_websocket_route("/ws", ws_endpoint) + + client = TestClient(adapter.get_http_server()) + with client.websocket_connect("/ws") as ws: + ws.send_text("hello") + assert ws.receive_text() == "echo:hello" diff --git a/tests/test_engine/unit/__init__.py b/tests/test_engine/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_engine/unit/test_execution_context.py b/tests/test_engine/unit/test_execution_context.py new file mode 100644 index 0000000..0897214 --- /dev/null +++ b/tests/test_engine/unit/test_execution_context.py @@ -0,0 +1,46 @@ +from __future__ import annotations + + +def test_execution_context_http(): + from nest.engine.execution_context import ExecutionContext + sentinel_req = object() + sentinel_res = object() + ctx = ExecutionContext(request=sentinel_req, response=sentinel_res) + http = ctx.switch_to_http() + assert http.get_request() is sentinel_req + assert http.get_response() is sentinel_res + + +def test_execution_context_get_type(): + from nest.engine.execution_context import ExecutionContext + ctx = ExecutionContext(request=object()) + assert ctx.get_type() == "http" + + +def test_execution_context_response_optional(): + from nest.engine.execution_context import ExecutionContext + ctx = ExecutionContext(request=object()) + http = ctx.switch_to_http() + assert http.get_response() is None + + +def test_http_execution_context_standalone(): + from nest.engine.execution_context import HttpExecutionContext + req = object() + http = HttpExecutionContext(request=req) + assert http.get_request() is req + assert http.get_response() is None + + +def test_execution_context_holds_raw_objects(): + from nest.engine.execution_context import ExecutionContext + # Verify it accepts any object — not just fastapi.Request + class FakeRequest: + pass + class FakeResponse: + pass + req, res = FakeRequest(), FakeResponse() + ctx = ExecutionContext(request=req, response=res) + http = ctx.switch_to_http() + assert isinstance(http.get_request(), FakeRequest) + assert isinstance(http.get_response(), FakeResponse) diff --git a/tests/test_engine/unit/test_http_adapter_base.py b/tests/test_engine/unit/test_http_adapter_base.py new file mode 100644 index 0000000..4c4ee8b --- /dev/null +++ b/tests/test_engine/unit/test_http_adapter_base.py @@ -0,0 +1,138 @@ +# tests/test_engine/unit/test_http_adapter_base.py +from __future__ import annotations + +import inspect +import pytest + + +def _make_concrete_adapter(): + """Return a concrete AbstractHttpAdapter subclass with all methods implemented.""" + from nest.engine.http_adapter import AbstractHttpAdapter + + class ConcreteAdapter(AbstractHttpAdapter): + def _create_instance(self): + return object() + + async def close(self): + pass + + def add_route(self, spec): + pass + + def add_websocket_route(self, path, endpoint): + pass + + def use(self, middleware, **opts): + pass + + def enable_cors(self, **opts): + pass + + def register_startup_hook(self, fn): + pass + + def register_shutdown_hook(self, fn): + pass + + def register_exception_handler(self, exc_type, handler): + pass + + def get_request_method(self, req): + return "GET" + + def get_request_url(self, req): + return "/test" + + def get_request_hostname(self, req): + return "localhost" + + def get_request_headers(self, req): + return {} + + def get_request_client_ip(self, req): + return "127.0.0.1" + + def reply(self, res, body, status_code=None): + pass + + def set_header(self, res, name, value): + pass + + def is_headers_sent(self, res): + return False + + def redirect(self, res, url, status_code=302): + pass + + return ConcreteAdapter + + +def test_abstract_adapter_cannot_be_instantiated_directly(): + from nest.engine.http_adapter import AbstractHttpAdapter + with pytest.raises(TypeError): + AbstractHttpAdapter() # type: ignore[abstract] + + +def test_concrete_adapter_requires_all_abstract_methods(): + from nest.engine.http_adapter import AbstractHttpAdapter + + class Incomplete(AbstractHttpAdapter): + def _create_instance(self): + return object() + # missing all other abstract methods + + with pytest.raises(TypeError): + Incomplete() + + +def test_get_http_server_returns_instance(): + cls = _make_concrete_adapter() + adapter = cls() + assert adapter.get_http_server() is not None + + +def test_get_type_strips_adapter_suffix(): + cls = _make_concrete_adapter() + adapter = cls() + # Class is named "ConcreteAdapter" → type is "concrete" + assert adapter.get_type() == "concrete" + + +def test_get_type_no_adapter_suffix(): + from nest.engine.http_adapter import AbstractHttpAdapter + + cls = _make_concrete_adapter() + + class MyEngine(cls): + pass + + # Rename for the test + MyEngine.__name__ = "MyEngine" + adapter = MyEngine() + assert adapter.get_type() == "myengine" + + +def test_adapter_instance_passthrough(): + cls = _make_concrete_adapter() + sentinel = object() + adapter = cls(instance=sentinel) + assert adapter.get_http_server() is sentinel + + +def test_all_abstract_methods_are_listed(): + """Verify the ABC enforces the full expected contract surface.""" + from nest.engine.http_adapter import AbstractHttpAdapter + + abstract_methods = { + name + for name, method in inspect.getmembers(AbstractHttpAdapter) + if getattr(method, "__isabstractmethod__", False) + } + expected = { + "_create_instance", "close", "add_route", "add_websocket_route", + "use", "enable_cors", "register_startup_hook", "register_shutdown_hook", + "register_exception_handler", "get_request_method", "get_request_url", + "get_request_hostname", "get_request_headers", "get_request_client_ip", + "reply", "set_header", "is_headers_sent", "redirect", + } + assert abstract_methods == expected diff --git a/tests/test_engine/unit/test_http_facade.py b/tests/test_engine/unit/test_http_facade.py new file mode 100644 index 0000000..a3d0ef6 --- /dev/null +++ b/tests/test_engine/unit/test_http_facade.py @@ -0,0 +1,31 @@ +from __future__ import annotations + + +def test_request_is_fastapi_request(): + from nest.http import Request + from fastapi import Request as FastAPIRequest + assert Request is FastAPIRequest + + +def test_response_is_fastapi_response(): + from nest.http import Response + from fastapi import Response as FastAPIResponse + assert Response is FastAPIResponse + + +def test_depends_is_fastapi_depends(): + from nest.http import Depends + from fastapi import Depends as FastAPIDepends + assert Depends is FastAPIDepends + + +def test_http_exception_is_fastapi_http_exception(): + from nest.http import HTTPException + from fastapi import HTTPException as FastAPIHTTPException + assert HTTPException is FastAPIHTTPException + + +def test_all_exports_present(): + import nest.http as http + for name in ["Request", "Response", "Depends", "HTTPException"]: + assert hasattr(http, name), f"nest.http missing: {name}" diff --git a/tests/test_engine/unit/test_param_spec.py b/tests/test_engine/unit/test_param_spec.py new file mode 100644 index 0000000..24a84bb --- /dev/null +++ b/tests/test_engine/unit/test_param_spec.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +import pytest + + +def test_paramspec_defaults(): + from nest.engine.params import ParamSpec + p = ParamSpec(source="query") + assert p.source == "query" + assert p.name is None + assert p.annotation is None + assert p.default is ... + assert p.pipes == () + assert p.factory is None + assert p.data is None + + +def test_paramspec_is_frozen(): + from nest.engine.params import ParamSpec + p = ParamSpec(source="body", name="payload") + with pytest.raises((AttributeError, TypeError)): + p.name = "other" # type: ignore[misc] + + +def test_paramspec_all_sources_valid(): + from nest.engine.params import ParamSpec, VALID_SOURCES + for src in VALID_SOURCES: + p = ParamSpec(source=src) + assert p.source == src + + +def test_paramspec_with_pipes(): + from nest.engine.params import ParamSpec + + def trim(v): + return v.strip() + + p = ParamSpec(source="query", name="q", pipes=(trim,)) + assert p.pipes == (trim,) + + +def test_paramspec_with_default(): + from nest.engine.params import ParamSpec + p = ParamSpec(source="query", name="page", default=1) + assert p.default == 1 + + +def test_paramspec_with_custom_factory(): + from nest.engine.params import ParamSpec + + def my_factory(data, ctx): + return "value" + + p = ParamSpec(source="custom", factory=my_factory, data={"key": "val"}) + assert p.factory is my_factory + assert p.data == {"key": "val"} + + +def test_paramspec_invalid_source_raises(): + from nest.engine.params import ParamSpec + with pytest.raises(ValueError, match="Invalid param source"): + ParamSpec(source="invalid_source") diff --git a/tests/test_engine/unit/test_route_spec.py b/tests/test_engine/unit/test_route_spec.py new file mode 100644 index 0000000..3fa02d6 --- /dev/null +++ b/tests/test_engine/unit/test_route_spec.py @@ -0,0 +1,79 @@ +from __future__ import annotations +import pytest + + +def _make_endpoint(): + async def endpoint(): + return {"ok": True} + return endpoint + + +def test_routespec_minimal(): + from nest.engine.route_spec import RouteSpec + from nest.engine.types import HttpMethod + ep = _make_endpoint() + spec = RouteSpec(method=HttpMethod.GET, path="/", endpoint=ep) + assert spec.method == HttpMethod.GET + assert spec.path == "/" + assert spec.endpoint is ep + assert spec.params == () + assert spec.guards == () + assert spec.filters == () + assert spec.status_code is None + assert spec.tags == () + assert spec.name is None + assert spec.summary is None + assert spec.description is None + assert spec.extra == {} + + +def test_routespec_is_frozen(): + from nest.engine.route_spec import RouteSpec + from nest.engine.types import HttpMethod + ep = _make_endpoint() + spec = RouteSpec(method=HttpMethod.POST, path="/items", endpoint=ep) + with pytest.raises((AttributeError, TypeError)): + spec.path = "/other" # type: ignore[misc] + + +def test_routespec_with_params(): + from nest.engine.route_spec import RouteSpec + from nest.engine.params import ParamSpec + from nest.engine.types import HttpMethod + ep = _make_endpoint() + p = ParamSpec(source="query", name="page", default=1) + spec = RouteSpec(method=HttpMethod.GET, path="/items", endpoint=ep, params=(p,)) + assert spec.params == (p,) + + +def test_routespec_extra_is_independent_per_instance(): + from nest.engine.route_spec import RouteSpec + from nest.engine.types import HttpMethod + ep = _make_endpoint() + spec1 = RouteSpec(method=HttpMethod.GET, path="/a", endpoint=ep) + spec2 = RouteSpec(method=HttpMethod.GET, path="/b", endpoint=ep) + assert spec1.extra is not spec2.extra + + +def test_routespec_full(): + from nest.engine.route_spec import RouteSpec + from nest.engine.params import ParamSpec + from nest.engine.types import HttpMethod + ep = _make_endpoint() + p = ParamSpec(source="body", name="data") + spec = RouteSpec( + method=HttpMethod.POST, + path="/users", + endpoint=ep, + params=(p,), + status_code=201, + tags=("users",), + name="create_user", + summary="Create a user", + description="Creates a new user record.", + extra={"response_model_exclude_none": True}, + ) + assert spec.status_code == 201 + assert spec.tags == ("users",) + assert spec.name == "create_user" + assert spec.extra == {"response_model_exclude_none": True} diff --git a/tests/test_engine/unit/test_types.py b/tests/test_engine/unit/test_types.py new file mode 100644 index 0000000..efa7bbb --- /dev/null +++ b/tests/test_engine/unit/test_types.py @@ -0,0 +1,40 @@ +# tests/test_engine/unit/test_types.py +from __future__ import annotations + +import typing + + +def test_http_method_values(): + from nest.engine.types import HttpMethod + assert HttpMethod.GET.value == "GET" + assert HttpMethod.POST.value == "POST" + assert HttpMethod.DELETE.value == "DELETE" + assert HttpMethod.PUT.value == "PUT" + assert HttpMethod.PATCH.value == "PATCH" + assert HttpMethod.HEAD.value == "HEAD" + assert HttpMethod.OPTIONS.value == "OPTIONS" + + +def test_endpoint_is_callable_alias(): + from nest.engine.types import Endpoint + # Verify Endpoint is the correct generic alias, not just non-None + assert Endpoint == typing.Callable[..., typing.Any] + + +def test_http_method_is_same_object_as_original(): + from nest.engine.types import HttpMethod + from nest.core.decorators.http_method import HTTPMethod + # Re-export must be identity — no copy or subclass + assert HttpMethod is HTTPMethod + + +def test_engine_package_exports(): + import nest.engine as engine + assert hasattr(engine, "AbstractHttpAdapter") + assert hasattr(engine, "RouteSpec") + assert hasattr(engine, "ParamSpec") + assert hasattr(engine, "HttpMethod") + assert hasattr(engine, "Endpoint") + assert hasattr(engine, "ExecutionContext") + assert hasattr(engine, "HttpExecutionContext") + assert hasattr(engine, "VALID_SOURCES")